Игра Ken’s Labyrinth была разработана Ken’ом Silverman’ом и выпущена компанией Epic MegaGames в далёком 1993 году. Ken Silverman в первую очередь известен как американский программист, создавший игровой движок Build Engine для компании 3D Realms, на нём впоследствии будут выпущены такие известные игры, как Duke Nukem 3D, Blood и Shadow Warrior. Движок, используемый в Ken’s Labyrinth, является неким прародителем Build Engine, в техническом плане он сопоставим с движком Wolfenstein 3D от id Software, но имеет несколько интересных преимуществ, например, интерактивные спрайты и текстуры, благодаря которым в игре реализованы карты уровней и специальные игровые автоматы, известные как слот-машины «однорукий бандит». Спустя несколько лет после выхода игры, когда её продажи практически прекратились, Ken Silverman выложил наиболее полный дистрибутив Ken’s Labyrinth на свой сайт и игра стала доступна для свободной загрузки. Немного позднее был опубликован и её исходный код.
После открытия исходного кода Jan Lönnberg портировал игру на современные операционные системы, используя библиотеку OpenGL для вывода графики, библиотеку SDL для обработки событий ввода, а также для вывода звука и эмулятор AdLib, написанный тоже Ken’ом Silverman’ом, для воспроизведения игровой музыки. Так родился проект LAB3D/SDL, предоставляющий возможность запустить Ken’s Labyrinth на любых операционных системах, на которых доступны библиотеки OpenGL и SDL. Позже Jared Stafford, использующий никнейм jspenguin, внёс некоторые улучшения в проект LAB3D/SDL, добавив способ загрузки PNG-текстур высокого разрешения, метод рендеринга теней и световых бликов в корректной перспективе, правильную скорость анимации и множество других исправлений. В 2010 году разработчик Scott Smith, использующий никнейм Pickle, переписал рендеринг LAB3D/SDL с OpenGL на OpenGL ES, что позволило запускать Ken’s Labyrinth на различных портативных игровых устройствах, таких как GP2X Caanoo или Pandora.
Порт игры Ken’s Labyrinth, запущенный на Android-устройстве Motorola Droid 4.
Впервые в Ken’s Labyrinth я поиграл в 2010 году на портативной консоли GP2X Caanoo, имеющейся у моего друга. Меня сильно привлекают подобные старые игры, поэтому я решил портировать Ken’s Labyrinth и на своё мобильное устройство: Motorola ZN5 на платформе MotoMAGX. Увы, но этот смартфон не имел графического 3D-ускорителя с поддержкой OpenGL ES, а потому портирование без переписывания значительной части движка, отвечающей за вывод графики, было бы весьма затруднительным. Время шло, у меня появлялись новые интересные гаджеты на Android OS и вот, спустя пять лет, я вспомнил про ту неудачную попытку портирования игры и решил её повторить, но уже для современных смартфонов, которых у меня на момент написания статьи несколько: Motorola Droid 4 и Motorola Photon Q. Эти устройства на Android OS имеют на борту графические 3D-ускорители, обладающие достаточной производительностью и функциональностью для подобного рода игр.
За основу будущего проекта я решил взять исходные коды порта LAB3D/SDL, модифицированные jspenguin’ом и применить к ним патчи для поддержки OpenGL ES от Pickle: таким образом задействуются возможности OpenGL ES версии 1.0, что благоприятно отразится на списке поддерживаемых Android-устройств. Как и в оригинале, на компьютере LAB3D/SDL при своём первом запуске отображает специальный конфигуратор, где можно настроить различные опции и параметры, а после выхода из него и по повторному запуску исполнительного файла уже запускается сама игра. Я бы хотел сохранить подобное поведение приложения и в Android-версии порта.
Содержание:
1. Портирование с SDL 1.2 на SDL 2.0
2. Портирование с OpenGL на OpenGL ES: решение проблем
3. Интересная проблема с отображением некоторых PNG-текстур
4. Освобождение от библиотеки SDL2_image
5. Дополнительные возможности для Android OS в SDL 2.0
6. Java-обёртка и лаунчер
7. Реализация сенсорного управления
8. Заключение, полезные ссылки и ресурсы
1. Портирование с SDL 1.2 на SDL 2.0
Сначала я решил полностью отказаться от использования библиотеки SDL, но посмотрев код, я увидел, что она весьма активно используется движком для потоков, звука и для самой графики. Мне пришлось её оставить, но с небольшими оговорками. LAB3D/SDL использует старую SDL версии 1.2, которая официально не имеет поддержки Android OS, а её реализации от энтузиастов не отличаются приемлемым качеством работы. В новой ветке SDL версии 2.0 появилась официальная поддержка как Android OS, так и iOS, но увы, API библиотеки несовместим с версиями прошлой ветки, поэтому код будет требовать внесения изменений, хотя и весьма незначительных. Мной было решено сначала портировать LAB3D/SDL на SDL 2.0, а потом уже проверить возможность работы игры под Android OS.
Разработчиками SDL 2.0 был написан специальный документ: SDL 1.2 to 2.0 Migration Guide, следуя предписаниям которого можно без особых трудностей перенести на SDL 2.0 любое SDL-приложение. Вкратце, суть сводится к следующему:
- Вместо SDL_SetVideoMode() следует использовать SDL_CreateWindow() и SDL_CreateRenderer();
- Вместо SDL_ListModes() следует использовать SDL_GetDisplayMode() и SDL_GetNumDisplayModes();
- Вместо SDL_UpdateRect() и SDL_Flip() следует использовать SDL_RenderPresent();
- В дополнение к SDL_Surface можно использовать SDL_Texture;
- Вместо SDL_VideoInfo() следует использовать SDL_GetRendererInfo() и SDL_GetRenderDriverInfo();
- Вместо SDL_GetCurrentVideoDisplay() следует использовать SDL_GetWindowDisplayIndex();
- Эквивалент события SDL_VIDEORESIZE теперь SDL_WINDOWEVENT_RESIZED;
- Вместо SDL_GL_SwapBuffers() следует использовать SDL_GL_SwapWindow().
Кроме того, необходимо проверить способ обработки нажатия клавиш на клавиатуре, так как в SDL 2.0 поддержка Unicode была реализована без костылей вроде функции SDL_EnableUNICODE(), которую нужно было вызывать, когда необходимо было получить символ, отличный от ASCII.
Трудности переноса LAB3D/SDL на SDL 2.0 у меня возникли лишь в обработчике событий клавиатуры, поскольку там использовались разные методы взаимодействия с ней. Пришлось писать небольшие вспомогательные функции-обёртки, которые я выделил в отдельные файлы sdl2keyhelper.h и sdl2keyhelper.c:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// sdl2keyhelper.h // Old Scancodes from SDL 1.2 typedef enum { OLDK_UNKNOWN = 0, OLDK_FIRST = 0, OLDK_BACKSPACE = 8, OLDK_TAB = 9, ... OLDK_MENU = 319, OLDK_POWER = 320, /**< Power Macintosh power key */ OLDK_EURO = 321, /**< Some european keyboards */ OLDK_UNDO = 322, /**< Atari keyboard has Undo */ OLDK_LAST } OLDKey; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// sdl2keyhelper.c int getUpperChar(int smallCharKeyCode) { return smallCharKeyCode - 0x20; } int getOldAsciiKeyCode(int newKeyCode) { switch (newKeyCode) { default: return newKeyCode; case SDLK_UNKNOWN: return OLDK_UNKNOWN; case SDLK_BACKSPACE: return OLDK_BACKSPACE; case SDLK_TAB: return OLDK_TAB; ... case SDLK_MENU: return OLDK_MENU; case SDLK_POWER: return OLDK_POWER; case SDLK_UNDO: return OLDK_UNDO; } } |
Функция getOldAsciiKeyCode() принимает новый код клавиши, используемый в SDL 2.0, и возвращает старый, который использовался в прошлой SDL-библиотеке. Кроме того, в SDL версии 2.0.3 никак не обрабатывались события нажатий кнопок D-Pad’а, поэтому мне пришлось написать и использовать функцию patchAndroidKeysDpadSDL2():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
int patchAndroidKeysDpadSDL2(int keyCode) { switch (keyCode) { case OLDK_w: return OLDK_UP; case OLDK_s: return OLDK_DOWN; case OLDK_a: return OLDK_LEFT; case OLDK_d: return OLDK_RIGHT; case SDLK_SELECT: return OLDK_RETURN; case OLDK_r: case SDLK_AC_BACK: return OLDK_ESCAPE; default: return keyCode; } } |
К счастью, буквально спустя два дня после начала работы над проектом, библиотека SDL обновилась до версии 2.0.4 и D-Pad заработал без подобных ухищрений и надобность в этой функции отпала.
Таким образом, LAB3D/SDL перестал зависеть от старой SDL-библиотеки и перебрался на современную SDL 2.0, что хорошо поспособствует портированию игры на Android OS. Немного позже я узнал, что jspenguin совсем недавно уже портировал движок на SDL 2.0 и выложил наработки в свой публичный репозиторий, так что этот шаг можно было пропустить, так как я проделал аналогичную работу. Мне лишь нужно было взять исходники более новой версии.
2. Портирование с OpenGL на OpenGL ES: решение проблем
Устройства, работающие на Android OS, как и многие другие портативные гаджеты, имеют поддержку OpenGL ES, но не имеют поддержки обычного «компьютерного» OpenGL. Для реализации последнего используется специальная сторонняя библиотека, NanoGL, написанная программистом Olli Hinkka. Мне не хотелось тянуть дополнительные зависимости в проект и я с удовольствием воспользовался наработками Pickle, который после портирования LAB3D/SDL на GP2X Caanoo и Pandora поделился с сообществом своими OpenGL ES-патчами. Казалось бы: бери их, применяй и будет всё отлично, но не тут-то было!
После тестовой сборки игра отлично запустилась и заработала на моём Droid 4, но на Photon Q, при выходе в главное меню игры, я увидел следующее:
Битое искажённое изображение, скриншот с Motorola Photon Q (превью, увеличение по клику).
Изображение сильно билось, искажалось, но восстанавливалось после выхода из главного меню. На поиск причины такого поведения я потратил почти целый день. Оказывается, что этот баг присутствует только в устройствах, которые используют графический ускоритель Adreno, на PowerVR, который, к слову, используется в Pandora и Droid 4, всё работает отлично. Дело было в том, что LAB3D/SDL для отрисовки изображения использует технологию «грязных прямоугольников», когда прорисовывается не весь кадр целиком, а лишь необходимая прямоугольная область, а то что находится за областью отрисовки, остаётся с предыдущего кадра. Это сделано в целях экономии производительности, но некоторые графические ускорители, в числе которых и находится Adreno, не гарантируют, что при вызове функции обновления экрана, за областью обновления останется буфер из предыдущего кадра. Документация SDL 2.0 по этому поводу тоже предупреждает:
================================================================================
A note regarding the use of the «dirty rectangles» rendering technique
================================================================================
If your app uses a variation of the «dirty rectangles» rendering technique, where you only update a portion of the screen on each frame, you may notice a variety of visual glitches on Android, that are not present on other platforms. This is caused by SDL’s use of EGL as the support system to handle OpenGL ES/ES2 contexts, in particular the use of the eglSwapBuffers function. As stated in the documentation for the function «The contents of ancillary buffers are always undefined after calling eglSwapBuffers». Setting the EGL_SWAP_BEHAVIOR attribute of the surface to EGL_BUFFER_PRESERVED is not possible for SDL as it requires EGL 1.4, available only on the API level 17+, so the only workaround available on this platform is to redraw the entire screen each frame.
Reference: http://www.khronos.org/registry/egl/specs/EGLTechNote0001.html
Кстати, на stackoverflow.com и gamedev.stackexchange.com обсуждалось подобное поведение графических 3D-ускорителей Adreno.
Повышать минимальную версию Android OS до 4.2 (API 17) мне решительно не хотелось и я начал искать подходящие способы устранения проблемы. Наиболее правильное и очевидное решение состоит в том, чтобы рисовать каждый кадр полностью, то есть в движке игры должен быть лишь один вызов функции SDL_GL_SwapWindow(). К сожалению, это бы потребовало множества серьёзных изменений в коде движка, детальный разбор того, как он работает, а вникать в это очень не хотелось. Я не сильный знаток OpenGL и OpenGL ES, но немного поэкспериментировав, я обнаружил, что если после каждого вызова SDL_GL_SwapWindow() вызывать такую вот функцию clearScreen():
1 2 3 4 5 |
void clearScreen() { glClear(GL_COLOR_BUFFER_BIT); glDepthMask(1); glClear(GL_DEPTH_BUFFER_BIT); } |
То ситуация исправляется в лучшую сторону и баг в отрисовке полностью пропадает. Но в расход идёт фоновая картинка, было:
Фоновая картинка в игровых меню, скриншот с Motorola Droid 4 (превью, увеличение по клику).
Стало:
Отсутствие фоновой картинки в игровых меню, скриншот с Motorola Droid 4 (превью, увеличение по клику).
Помимо добавления этой функции, пришлось ещё серьёзно подрихтовать вложенные меню и внутриигровые окна, вынося их отрисовку на передний план. К счастью, потеря фона оказалась не столь страшной, главное, что картинка теперь нормально отображается на всех моих устройствах. Возможно, когда у меня появится немного больше свободного времени, я перепишу отрисовку движка без использования технологии «грязных прямоугольников».
После того, как я получил тестовую сборку LAB3D/SDL, не имеющую проблем в отрисовке как на Adreno, так и на PowerVR, я решил проверить работу движка на старых устройствах с Android OS 2.3 и PowerVR SGX530. Здесь я снова наткнулся на неполадку: почему-то размер глубины цвета OpenGL ES-контекста устанавливался только в 24 бит, но не в 16 бит. Мне пришлось повысить глубину цветности в проекте, что, кстати, в лучшую сторону сказалось на качестве отображаемой картинки, было:
Глубина цвета 16 бит, фрагмент скриншота с Motorola Droid 4.
1 2 3 4 |
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 5); SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE,6); SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE,5); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE,16); |
Стало:
Глубина цвета 24 бита, фрагмент скриншота с Motorola Droid 4.
1 2 3 4 |
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8); SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE,8); SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE,8); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE,24); |
Движок LAB3D/SDL кроме, непосредственно, OpenGL использует специальную библиотеку-надстройку над ним, называемую GLU (OpenGL Utility Library). Поскольку в Android OS нет этой нативной библиотеки, мне пришлось использовать её порт на OpenGL ES, который называется GLU ES и доступен под свободной лицензией SGI FREE. GLU ES распространяется вместе со специальной библиотекой для тесселяции TESS, которая по сути бесполезна для LAB3D/SDL, поэтому её исходные коды можно не включать в проект.
Кстати, ещё патчи от Pickle исправили ситуацию с вводом текста во внутриигровых окнах, что пришлось весьма кстати, ведь многие устройства на Android OS, как и портативные игровые консоли, не имеют полноценной физической клавиатуры.
3. Интересная проблема с отображением некоторых PNG-текстур
При тестировании сборки LAB3D/SDL на своих устройствах я обнаружил ещё один странный баг, который заключался в том, что некоторые подгружаемые PNG-текстуры высокого разрешения отображались битыми:
Битая PNG-текстура, скриншот с Motorola Photon Q (превью, увеличение по клику).
А так должно быть:
Нормальная PNG-текстура, скриншот с Motorola Photon Q (превью, увеличение по клику).
При этом на компьютерной сборке движка ничего подобного замечено не было. Оказалось, что по какой-то причине на мобильных устройствах альфа-канал на этих текстурах не определяется, хотя он там точно присутствует. Пришлось явно выставить для всех текстур GL_RGBA, вместо GL_RGB, так как они абсолютно все используют альфа-канал.
4. Освобождение от библиотеки SDL_image 2.0
Улучшенная jspenguin’ом версия LAB3D/SDL для подгрузки текстур высокого разрешения в формате PNG использует библиотеку SDL_image 1.2, соответственно при портировании на SDL 2.0 я задействовал SDL_image 2.0, которая как раз и предназначена для работы с SDL-библиотекой новой версии. Но поскольку SDL_image 2.0 имеет слишком большой размер и избыточную для меня функциональность, я решил полностью отказаться от её использования и воспользоваться альтернативой. Такой альтернативой оказалась небольшая библиотека LodePNG, состоящая всего из двух файлов: заголовочного и исходного. Она предназначена только для загрузки и обработки PNG-файлов и не является таким всеядным комбайном, как SDL_image 2.0, которая работает со множеством различных форматов изображений. Переписанная функция подгрузки текстур стала выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
imgcache* LoadImageCache(const char* fname, int repeatx, int minfilt, int magfilt) { imgcache* cur=img_cache; while (cur) { if (!strcmp(fname,cur->name)) { return cur; } cur=cur->next; } unsigned int png_error; unsigned char *png_image; char *png_file; unsigned int png_width, png_height; // Load PNG from assets to RAM SDL_RWops *imgRAM = SDL_RWFromFile(fname, "rb"); if (!imgRAM) { TO_DEBUG_LOG("ERROR LOADING PNG FILE: %s\n", fname); SDL_Quit(); exit(1); } long int png_size = SDL_RWsize(imgRAM); png_file = (char *)malloc(png_size); SDL_RWread(imgRAM, png_file, png_size, 1); // Use LodePNG library png_error = lodepng_decode32(&png_image, &png_width, &png_height, png_file, png_size); if (png_error) { TO_DEBUG_LOG("LODEPNG Error %d: %s.\n", png_error, lodepng_error_text(png_error)); } imgcache* newImg=(imgcache*)malloc(sizeof(imgcache)); newImg->name=strdup(fname); glGenTextures (1, &newImg->texnum); // Temp Buffer for convert PNG Uint32* temptex=(Uint32*)malloc(png_width*png_height*4); // Convert loop int xx,yy; Uint32* temp=(Uint32*)temptex; for(xx=0;xx<png_width;xx++) for(yy=0;yy<png_height;yy++) *temp++=((Uint32*)png_image)[xx+png_width*yy]; newImg->w=png_width; newImg->h=png_height; UploadTexture(newImg->texnum,temptex,png_height,png_width,0,repeatx,1,minfilt,magfilt); free(png_file); SDL_FreeRW(imgRAM); free(temptex); free(png_image); newImg->next=img_cache; img_cache=newImg; return newImg; } |
Благодаря использованию LodePNG размер установочного APK-пакета уменьшился на целый мегабайт.
5. Дополнительные возможности для Android OS в SDL 2.0
Поскольку поддержка Android OS у SDL 2.0 теперь официальная, то разработчики реализовали несколько удобных возможностей, которые позволяют получить доступ к различным местам хранения игровых данных. К примеру, чтобы открыть файл texture.bin, который хранится в APK-пакете в каталоге assets, достаточно воспользоваться функцией SDL_RWFromFile():
1 |
SDL_RWFromFile("texture.bin", "rb"); |
Как это обычно делается и на других платформах. Но не стоит забывать, что assets’ы имеют доступ только для чтения, поэтому если в файл будет записана какая-либо информация, его необходимо куда-нибудь скопировать, например, во внутреннее хранилище, доступное как для чтения, так и для записи и записывать информацию уже там. У себя в LAB3D/SDL я имею пару таких файлов, поэтому я написал небольшую функцию copyFileFromAssetsToInternalRWDirAndroid(), которая занимается их копированием во внутреннее хранилище:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
void copyFileFromAssetsToInternalRWDirAndroid(const char *source_file, const char *desc1, const char *desc2) { char path1[256]; char path2[256]; snprintf(path1, sizeof(path1), "%s/%s", globalDataDir, source_file); snprintf(path2, sizeof(path2), "%s/%s", globalAndroidRWdir, source_file); SDL_RWops *io_in = SDL_RWFromFile(path1, desc1); if (!io_in) { TO_DEBUG_LOG("cFFATIRWDA: Error opening in file: %s.\n", path1); } SDL_RWops *io_out = SDL_RWFromFile(path2, desc1); if (io_out) { TO_DEBUG_LOG("cFFATIRWDA: File: %s found.\n", source_file); SDL_FreeRW(io_in); SDL_FreeRW(io_out); return; } else { TO_DEBUG_LOG("cFFATIRWDA: Copying file %s.\n", source_file); } SDL_FreeRW(io_out); io_out = SDL_RWFromFile(path2, desc2); if (!io_out) { TO_DEBUG_LOG("cFFATIRWDA: Error opening out file: %s.\n", path2); } long int size = SDL_RWsize(io_in); char *in = (char *)malloc(size); SDL_RWread(io_in, in, size, 1); SDL_RWwrite(io_out, in, size, 1); SDL_FreeRW(io_in); SDL_FreeRW(io_out); free(in); } |
С помощью специальных функций, в названии которых встречается слово Android, можно получить путь и доступ к различным областям хранения, узнать их состояние или получить указатель на окружение JNI. Я использовал функцию SDL_AndroidGetInternalStoragePath() для доступа к внутреннему хранилищу и скопировал необходимые файлы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
int main(int argc,char **argv) { ... globalDataDir = "KenLabData"; // There is directory in assets from APK-package of game clearCurrentMenuState(); globalAndroidRWdir = SDL_AndroidGetInternalStoragePath(); TO_DEBUG_LOG("Copying RW files from assets to: %s\n", globalAndroidRWdir); copyFileFromAssetsToInternalRWDirAndroid("HISCORE.DAT", "rb", "wb"); copyFileFromAssetsToInternalRWDirAndroid("SAVGAME4.DAT", "rb", "wb"); copyFileFromAssetsToInternalRWDirAndroid("SAVGAME5.DAT", "rb", "wb"); copyFileFromAssetsToInternalRWDirAndroid("SAVGAME6.DAT", "rb", "wb"); copyFileFromAssetsToInternalRWDirAndroid("SAVGAME7.DAT", "rb", "wb"); copyFileFromAssetsToInternalRWDirAndroid("wallparams.ini", "rt", "wt"); copyFileFromAssetsToInternalRWDirAndroid("ksmmidi.txt", "rt", "wt"); TO_DEBUG_LOG("End copying files."); ... } |
В заголовочном файле jni/SDL2-2.0.4-compact/include/SDL_system.h задекларирован список всех доступных платформозависимых функций, которые помогут взаимодействовать с Android OS.
6. Java-обёртка и лаунчер
Порт SDL 2.0 на Android OS работает следующим образом: большинство Android-приложений написаны на языке Java, но опционально они могут обращаться к функциям нативных библиотек на C/C++. SDL-приложения написаны на языках C/C++ и официальный порт использует небольшую Java-прослойку, которая с помощью JNI общается с SDL-библиотекой. Это означает, что SDL-приложение должно быть расположено внутри Java-проекта для Android OS, чтобы код на C/C++ имел возможность коммуникации с кодом на Java. Таким образом можно собрать стандартный установочный APK-пакет вашего SDL-приложения. Java-прослойка, реализующая Activity, располагается в файле src/org/libsdl/app/SDLActivity.java, она занимается загрузкой кода SDL-приложения и динамической SDL-библиотеки. Нативные функции, используемые Java-прослойкой, реализованы в файле jni/SDL2-2.0.4-compact/src/core/android/SDL_android.c. Для правильной работы SDL-приложения функция main() перед свои вызовом должна быть обёрнута специальным кодом, который располагается в файле jni/SDL2-2.0.4-compact/src/main/android/SDL_android_main.c, этот файл должен быть скомпилирован вместе с исходниками SDL-приложения.
Для того, чтобы внести какие-либо изменения в проект, например, изменить стандартное имя и идентификатор приложения, нужно отредактировать файл AndroidManifest.xml, затем создать исходный Java-файл с классом, который будет унаследован от SDLActivity. Данный способ можно использовать и для модификации методов родительского класса, достаточно их перегрузить. К примеру, в моём проекте унаследованный класс KenLab3DActivity выглядит следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
package ru.exlmoto.kenlab3d; import org.libsdl.app.SDLActivity; import ru.exlmoto.kenlab3d.KenLab3DLauncherActivity.KenLab3DSettings; import android.content.Context; import android.os.Bundle; import android.os.Vibrator; import android.util.Log; import android.view.ViewGroup.LayoutParams; import android.widget.LinearLayout; public class KenLab3DActivity extends SDLActivity { private static final String APP_TAG = "KenLab3D_App"; private static Vibrator m_vibrator; // Access from JNI public static boolean m_hiResState; public static void toDebugLog(String debugMessage) { Log.d(APP_TAG, "=== " + debugMessage); } @SuppressWarnings("deprecation") @Override public void onCreate(Bundle savedInstanceState) { toDebugLog("Start SDL Activity from KenLab3DActivity"); super.onCreate(savedInstanceState); toDebugLog("Setting Vibration"); m_vibrator = (Vibrator)getSystemService(Context.VIBRATOR_SERVICE); if (KenLab3DSettings.s_TouchControls) { LinearLayout ll = new LinearLayout(this); if (KenLab3DLauncherActivity.g_isStateGame) { ll.setBackgroundDrawable(getResources().getDrawable(R.drawable.overlay_controls_game)); } else { ll.setBackgroundDrawable(getResources().getDrawable(R.drawable.overlay_controls_settings)); } addContentView(ll, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } } // JNI-method public static void doVibrate(int duration) { if (KenLab3DSettings.s_VibrationHaptics) { m_vibrator.vibrate(duration); } } } |
Кроме перегрузки родительского метода onCreate(), здесь у меня реализован ещё и метод doVibrate(), который я могу дёргать из нативного C/C++ кода SDL-приложения с помощью JNI.
Для изменения различных, часто используемых опций, я решил создать простой лаунчер, позволяющий сразу запустить движок с необходимыми параметрами. Как было отмечено в самом начале статьи, решено было сохранить оригинальное поведение игры, которое заключалось в следующем: при первом запуске игра запускает конфигуратор, в котором можно изменить различные опции и параметры. После выхода из конфигуратора создаётся специальный конфигурационный файл и при последующих запусках движок игры видит и читает этот файл, запуская, соответственно, уже саму игру. Файл после выхода из настроек создаётся во внутреннем хранилище приложения, а потому из самого лаунчера мы легко можем отследить его отсутствие или присутствие и, например, изменить название кнопки с Setup Ken’s Labyrinth! на Run Ken’s Labyrinth!:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Override public void onCreate(Bundle savedInstanceState) { settingsIniFile = new File(getFilesDir().getAbsolutePath() + "/settings.ini"); ... } private void updateRunOrSetupButton() { if (settingsIniFile.exists()) { buttonRunOrSetup.setText(R.string.buttonRunKen); g_isStateGame = true; } else { buttonRunOrSetup.setText(R.string.buttonRunSetup); g_isStateGame = false; } } |
Для того, чтобы снова настроить игру и запустить конфигуратор нужно лишь нажать кнопку Reconfigure Game, которая просто удалит файл с настройками, а затем необходимо вызывать функцию выше, чтобы надпись на кнопке обновилась.
Конфигуратор, отображающийся при первом запуске игры, скриншот с Motorola Photon Q (превью, увеличение по клику).
Для реализации лаунчера я создал отдельный класс KenLab3DLauncherActivity, набросал к нему простейший графический интерфейс с несколькими опциями и связал всё вместе, получилось следующее:
Готовый лаунчер для запуска игры, скриншот с Motorola Photon Q (превью, увеличение по клику).
В качестве небольшого украшения я вставил в лаунчер изображение с коробочной версии Ken’s Labyrinth и реализовал диалог About Game:
Диалог About Game, скриншот с Motorola Photon Q (превью, увеличение по клику).
Очень удобно, когда выставленные настройки сохраняются и не нужно снова настраивать игру перед её запуском. Для сохранения и восстановления выставленных параметров я задействовал класс SharedPreferences. С помощью этого класса можно сделать так, чтобы настройки приложения восстанавливались после его запуска и сохранялись после выхода из лаунчера. Для хранения настроек я создал вложенный класс KenLab3DSettings:
1 2 3 4 5 6 7 8 9 10 |
public static class KenLab3DSettings { public static boolean s_TouchControls = true; public static boolean s_VibrationHaptics = true; public static boolean s_HiResTextures = true; // Access from JNI public static int s_VibroDelay = 50; public static boolean s_Sound = true; public static boolean s_Music = true; } |
К некоторым параметрам можно обращаться из нативного C/C++ кода с помощью JNI следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
int getVibarateDelayFromJNI() { JNIEnv *javaEnviron = SDL_AndroidGetJNIEnv(); if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/kenlab3d/KenLab3DLauncherActivity$KenLab3DSettings"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/kenlab3d/KenLab3DLauncherActivity$KenLab3DSettings not found!"); return 0; } jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "s_VibroDelay", "I"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field s_VibroDelay I not found!"); return 0; } jint vibrateDelay = (*javaEnviron)->GetStaticIntField(javaEnviron, clazz, fieldID); TO_DEBUG_LOG("JNI: s_VibroDelay is: %d", (int)vibrateDelay); (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); return (int)vibrateDelay; } return 0; } int getMusicSettingsValue() { JNIEnv *javaEnviron = SDL_AndroidGetJNIEnv(); if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/kenlab3d/KenLab3DLauncherActivity$KenLab3DSettings"); if (clazz == 0) { TO_DEBUG_LOG("Error JNI: Class ru/exlmoto/kenlab3d/KenLab3DLauncherActivity$KenLab3DSettings not found!"); return 0; } jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "s_Music", "Z"); if (fieldID == 0) { TO_DEBUG_LOG("Error JNI: fieldID is 0, field s_Music Z not found!"); return 0; } jboolean music = (*javaEnviron)->GetStaticBooleanField(javaEnviron, clazz, fieldID); TO_DEBUG_LOG("JNI: s_Music is: %d", (int)music); (*javaEnviron)->DeleteLocalRef(javaEnviron, clazz); return (int)music; } return 0; } |
Подобные JNI-функции я вынес в отдельный файл, который назвал androidutils.c, теперь достаточно вызывать эти функции во время инициализации игрового движка, чтобы применить к нему параметры, расставленные в графическом интерфейсе лаунчера.
Кстати, при тестировании приложения на Android OS 6.0.1, где ART включен по умолчанию, я заметил интересный факт: у меня в коде была небольшая ошибка — для получения значения переменной типа boolean я использовал метод GetStaticIntField(), вместо необходимого GetStaticBooleanField() и на ART приложение не запускалось, а на Dalvik‘е в Android OS 4.1.2 отлично работало.
7. Реализация сенсорного управления
Сперва для реализации сенсорного управления я решил использовать обычные стандартные кнопки, доступные в Android OS, именно таким способом я воспользовался в своём порте игры Spout. Когда я поместил кнопки на Layout, который совместил с OpenGL ES-контекстом игры, я ужаснулся, насколько всё получилось медленно. FPS сразу просел с шестидесяти до десяти. Стало понятно, что мне этот способ категорически не подходит. Я начал смотреть, как сенсорное управление реализовано в других играх для Android OS и совсем расстроился, поскольку в большинстве случаев сам нативный движок отображал и обрабатывал необходимые элементы управления. А в других случаях использовал достаточно объёмные библиотеки, вроде MobileTouchControls. Тогда я начал экспериментировать и мои эксперименты показали неожиданный результат: если вместо слоя с кнопками перекрыть OpenGL ES-контекст обычным прозрачным изображением, то FPS вообще не проседает. А если нарисовать на этом изображении полупрозрачные кнопки и отловить нажатия на дисплей устройства, то получится весьма неплохое сенсорное управление. Я занялся реализацией идеи и с помощью свободного графического редактора GIMP подготовил прозрачный холст с кнопками размером 854×480:
Изображение-холст с полупрозрачными кнопками (превью, увеличение по клику).
Осталось лишь разобраться, как расчитать и сопоставить координаты нажатий на экран с кнопками на изображении, ведь разрешение дисплея на устройствах под управлением Android OS различается и постоянно увеличивается. Для того, чтобы координаты совпадали, достаточно x и y нажатия делить на width и height физического разрешения экрана устройства, а затем координаты кнопок вычислить точно таким же способом, то есть x и y кнопки делить на размер подложки, то есть на 854 и 480 соответственно. Полученные относительные значения можно сохранить в прямоугольники, далее если координата попадает в прямоугольник — кнопка нажата, получается очень просто. Для этой цели мной был создан специальный класс KenLab3DTouchButtonsRects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
package ru.exlmoto.kenlab3d; import java.util.ArrayList; import java.util.List; import org.libsdl.app.SDLActivity; import android.view.KeyEvent; import ru.exlmoto.kenlab3d.KenLab3DLauncherActivity.KenLab3DSettings; public class KenLab3DTouchButtonsRects { private List<KenLab3DButton> initializedButtons = null; private class KenLab3DButton { final private int VIBRO_OFFSET = 20; private float m_x0; private float m_y0; private float m_x1; private float m_y1; private int m_buttonCode; private boolean m_buttonPushed = false; // -1 for no touches on button private int m_buttonTouchId = -1; // Useful for DEBUG private String m_buttonName; public KenLab3DButton(String buttonName, float x, float y, float width, float height, int keyCode) { m_buttonName = buttonName; m_x0 = x; m_y0 = y; m_x1 = x + width; m_y1 = y + height; m_buttonCode = keyCode; } public boolean checkButtonRect(float touchX, float touchY) { return (touchX > m_x0 && touchX < m_x1 && touchY > m_y0 && touchY < m_y1); } public void press() { m_buttonPushed = true; if (KenLab3DSettings.s_VibrationHaptics) { KenLab3DActivity.doVibrate(KenLab3DSettings.s_VibroDelay - VIBRO_OFFSET); } SDLActivity.onNativeKeyDown(m_buttonCode); } public void release() { m_buttonPushed = false; m_buttonTouchId = -1; SDLActivity.onNativeKeyUp(m_buttonCode); } @SuppressWarnings("unused") public String getName() { return m_buttonName; } public void setTouchId(int touchId) { m_buttonTouchId = touchId; } public int getTouchId() { return m_buttonTouchId; } public boolean getState() { return m_buttonPushed; } } public KenLab3DTouchButtonsRects() { initButtonsRects(); } public void checkTouchButtons(float touchX, float touchY, int touchId) { for (KenLab3DButton button : initializedButtons) { if (button.checkButtonRect(touchX, touchY)) { button.setTouchId(touchId); } } } public void pressSingleTouchButtons() { for (KenLab3DButton button : initializedButtons) { if (button.getTouchId() == 0) { button.press(); } } } public void pressMultiTouchButtons() { for (KenLab3DButton button : initializedButtons) { if (button.getTouchId() > 0 && !button.getState()) { button.press(); } } } public void releaseMultiTouchButtons(int touchId) { for (KenLab3DButton button : initializedButtons) { if (button.getTouchId() == touchId) { button.release(); } } } public void releaseAllButtons() { for (KenLab3DButton button : initializedButtons) { button.release(); button.setTouchId(-1); } } private void initButtonsRects() { /************************************************************************************ ** +------------------------------------------------+ ** | overlay (overlay_width x overlay_height) | ** | | ** | | ** | btn_x, btn_y -> +--------+ | ** | | button | | ** | | | | ** | | | | ** | +--------+ <- btn_w, btn_h | ** | | ** +------------------------------------------------+ ** ** btn_x and btn_y is coordinates of start point of button on an overlay ** btn_w and btn_h is coordinates of end point of button on an overlay ** ** float x = btn_x / overlay_width; ** float y = btn_y / overlay_height; ** float width = btn_w / overlay_width; ** float height = btn_h / overlay_height; ** ** Example for 854x480 overlay: ** float x = 125.0 / 854.0; ** float y = 455.0 / 480.0; ** float width = 200.0 / 854.0; ** float height = 475.0 / 480.0; ************************************************************************************/ initializedButtons = new ArrayList<KenLab3DTouchButtonsRects.KenLab3DButton>(); initializedButtons.add(new KenLab3DButton("Left", 0.0421f, 0.6583f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_DPAD_LEFT)); initializedButtons.add(new KenLab3DButton("Down", 0.2295f, 0.6583f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_DPAD_DOWN)); initializedButtons.add(new KenLab3DButton("Right", 0.4180f, 0.6583f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_DPAD_RIGHT)); initializedButtons.add(new KenLab3DButton("Up", 0.2295f, 0.3250f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_DPAD_UP)); initializedButtons.add(new KenLab3DButton("Center", 0.8079f, 0.3250f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_DPAD_CENTER)); if (KenLab3DLauncherActivity.g_isStateGame) { initializedButtons.add(new KenLab3DButton("Space", 0.8079f, 0.6604f, 0.1569f, 0.2792f, KeyEvent.KEYCODE_SPACE)); initializedButtons.add(new KenLab3DButton("E", 0.5503f, 0.3583f, 0.1218f, 0.2166f, KeyEvent.KEYCODE_E)); initializedButtons.add(new KenLab3DButton("Z", 0.5503f, 0.0333f, 0.1218f, 0.2166f, KeyEvent.KEYCODE_Z)); // 1 initializedButtons.add(new KenLab3DButton("X", 0.7025f, 0.0333f, 0.1218f, 0.2166f, KeyEvent.KEYCODE_X)); // 2 initializedButtons.add(new KenLab3DButton("C", 0.8548f, 0.0333f, 0.1218f, 0.2166f, KeyEvent.KEYCODE_C)); // 3 } } } |
Внутри этого класса имеется вложенный класс KenLab3DButton, описывающий каждую кнопку. Координаты кнопок на изображении можно легко получить с помощью того же GIMP’а, только нужно брать верхний левый угол кнопки. Рассчитав все значения координат и добавив объекты кнопок в массив, я могу сопоставить с ними координаты нажатий. Для перехвата нажатия я немного измененил метод onTouch() класса SDLSurface, который находится в файле SDLActivity.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// Touch events @Override public boolean onTouch(View v, MotionEvent event) { if (KenLab3DSettings.s_TouchControls) { int touchId = event.getPointerCount() - 1; if (touchId < 0) { return false; } float touchX = event.getX(touchId) / getWidth(); float touchY = event.getY(touchId) / getHeight(); switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: mTouchButtons.checkTouchButtons(touchX, touchY, touchId); mTouchButtons.pressSingleTouchButtons(); break; case MotionEvent.ACTION_POINTER_DOWN: mTouchButtons.checkTouchButtons(touchX, touchY, touchId); mTouchButtons.pressMultiTouchButtons(); break; case MotionEvent.ACTION_POINTER_UP: mTouchButtons.checkTouchButtons(touchX, touchY, touchId); mTouchButtons.releaseMultiTouchButtons(touchId); break; case MotionEvent.ACTION_UP: mTouchButtons.releaseAllButtons(); break; default: break; } } ... } |
Первоначально реализованное мной управление не поддерживало нажатия более одной кнопки одновременно. Но немного позже я разобрался и реализовал MultiTouch. Обрабатываются прикосновения следующим образом: когда на экран нажимает один палец, прилетает событие ACTION_DOWN, при нажатии второго и последующих пальцев (при условии, что первый отпущен не был), генерируется событие ACTION_POINTER_DOWN. Когда какой-либо палец покидает экран, выполняется ветвь ACTION_POINTER_UP, а когда все пальцы покинут экран — ACTION_UP. Ещё имеется событие ACTION_MOVE, которое выполняется при любом движении пальцев.
Перекрытый изображением игровой OpenGL ES-контекст игры выглядит следующим образом:
Готовое сенсорное управление, скриншот с Motorola Photon Q (превью, увеличение по клику).
Теперь у меня получилось обработать несколько нажатий, а игрок получил возможность идти и стрелять, или, например, открыть дверь не убирая пальца с кнопки, отвечающей за движение вперёд.
8. Заключение, полезные ссылки и ресурсы
Портирование игры Ken’s Labyrinth на Android OS дало мне огромное количество ценного опыта. Я убедился в том, что порт библиотеки SDL 2.0 для Android OS получился весьма работоспособным. Я смог решить некоторые графические проблемы, связанные с работой OpenGL ES на разных устройствах и реализовал интересный способ сенсорного управления.
Порт игры Ken’s Labyrinth, запущенный на Android-устройстве Motorola Droid 4, демонстрация на видеохостинге YouTube.
Размер установочного APK-пакета получился весьма небольшим, всего лишь 1.8 МБ для одной архитектуры armeabi-v7a и 2.8 МБ для трёх официально поддерживаемых SDL 2.0 архитектур: armeabi, armeabi-v7a и x86.
Скачать APK-пакет Ken’s Labyrinth, готовый для запуска на любом Android-устройстве, можно по этим ссылкам:
[Скачать APK-пакет Ken’s Labyrinth, armeabi-v7a, 1.8 МБ | Download Ken’s Labyrinth APK-package, armeabi-v7a, 1.8 MB]
[Скачать APK-пакет Ken’s Labyrinth, armeabi, armeabi-v7a, x86, 2.8 МБ | Download Ken’s Labyrinth APK-package, armeabi, armeabi-v7a, x86, 2.8 MB]
Все исходные коды и проект в целом выложен в репозиторий на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT. Ссылка на репозиторий:
https://github.com/EXL/KenLab3d
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
- Официальная страница игры Ken’s Labyrinth на сайте Ken’а Silverman’а;
- Официальная документация SDL 2.0 для Android OS;
- Руководство по сборке SDL-приложений на Android OS;
- Руководство по миграции с SDL 1.2 на SDL 2.0;
- Сайт Jared’а Stafford’а, страница улучшений LAB3D-SDL;
- Официальная страница порта LAB3D/SDL от Jan’a Lönnberg’a;
- Порт LAB3D/SDL на OpenGL ES от Scott’a Smith’a.
Update 06-MAR-2017: Прочитать информацию о новой версии порта Ken’s Labyrinth v1.1 с незначительными исправлениями и улучшениями можно по этой ссылке.
Update 05-MAY-2017: Проект был переведён с устаревших технологий ant и Eclipse ADT на Gradle и Android Studio. Это ознаменовало выход новой версии Ken’s Labyrinth v1.2, скачать которую можно со странички проектов.
Update 16-SEP-2019: Ken Silverman добавил на официальную страничку Ken’s Labyrinth у себя на сайте информацию о моём портировании игры на Android OS.
Отличная работа! Будет доступно в Google Play?
Если у меня появится время на доработку некоторых шероховатостей, в том же сенсорном управлении, то буду думать насчёт Google Play.
FPS впечатляет 🙂
У тебя на Droid 3 ведь Android 2.3.6? Если есть время, попробуй запустить пожалуйста.
Работает отлично на Droid3
Спасибо за тестирование!