Портирование Spout на Android OS: первый опыт работы с NDK, JNI и OpenGL ES

Когда-то давно я портировал на платформу MotoMAGX простенькую, но сильно затягивающую игру Spout. Цель этой игрушки весьма проста: необходимо подниматься на кораблике всё выше и выше по экрану, набирая очки и сражаясь с гравитацией. Кораблик управляется с помощью реактивной струи, частички которой могут служить не только средством передвижения, но и грозным оружием. Реактивной струёй можно разрушать стены и препятствия, преграждающие путь наверх. Если управляемый вами кораблик столкнётся с преградой или упадёт в самый низ экрана, то вы проиграете. С увеличением набираемой высоты плотность расположения препятствий постепенно увеличивается, что сильно влияет на игровую сложность. Пространство, по которому поднимается кораблик, разделено на равные части, границы которых отмечены горизонтальной полоской чёрного цвета, которую, кстати, тоже нужно разрушать. На преодоление первой части пространства вам даётся ровно одна минута, а после того, как вы пересечёте чёрную черточку, к оставшемуся времени снова добавится 60 секунд. Таким образом, быстро пролетая первые части пространства, вы сможете сохранить драгоценное время для более сложных участков выше. Звучит очень просто, но на практике постоянная борьба с гравитацией и набираемой скоростью — весьма сложное испытание.



Порт игры Spout, запущенный на Android-устройстве Motorola Photon Q.

Игра настолько сильно захватила меня простотой своего исполнения и в то же время достаточной сложностью, что я решил портировать Spout и на современные устройства под управлением Android OS. В первоначальном варианте движок Spout, портированный на MotoMAGX, работал через библиотеку SDL. Благодаря этому игра могла запуститься на всей линейке MotoMAGX-устройств, для которых была портирована библиотека SDL. В случае с портированием игры на Android OS, я решил использовать только библиотеки и средства, доступные в стандартной поставке, поэтому зависимость от SDL отпала за ненадобностью. Было решено не переписывать движок Spout с языка программирования C на Java, а воспользоваться Android NDK и стандартным механизмом JNI (Java Native Interface) для взаимодействия между Java-прослойкой и нативным кодом на языке C. В качестве библиотеки для отображения игрового контекста на экране, был взят OpenGL ES; эта библиотека доступна практически в каждом Android-устройстве, которое имеет возможность аппаратного ускорения графики посредством GPU. Таких устройств большинство, Android-девайсы, которые могут обходиться только программным рендерингом, давно уже исчезли с витрин салонов сотовой связи. Игровой контекст будет рендериться на текстуру, которую я и буду выводить на экран устройства. К моему огромному сожалению, обладателей Android-устройств с эргономичной физической клавиатурой остались единицы, поэтому для удобного управления корабликом в Spout мной было реализовано простейшее сенсорное управление.

Содержание:

1. История создания игры Spout
1.1. Краткий обзор исходного кода игры Spout
2. Портирование игры на GNU/Linux (64-bit)
3. Привлекаем к работе OpenGL
4. Начало портирования на Android OS: создание необходимого окружения
5. Связываем Java-код и C-код вместе: работа с NDK и JNI
6. Работа с OpenGL ES из Java-кода и из C-кода
7. Основы создания Launcher’a
7.1. Сохранение параметров с помощью класса SharedPreferences
7.2. Передача параметров в С-движок игры из Launcher’a
8. Органы управления
8.1. Физическая клавиатура
8.2. Наэкранное сенсорное управление
8.3. Акселерометр
9. Параметры Tweaks
9.1. Цветные частицы
9.2. Длинный хвост-прицел
9.3. Звук
9.4. Виброотдача
10. Реализация 3D-куба с изменяющимися текстурами
11. Заключение, полезные ссылки и ресурсы

1. История создания игры Spout

История игры Spout берёт своё начало с мероприятия под названием P/ECE Software Contest, проводимого японской компанией AQUAPLUS Co., Ltd. в 2002 году. Годом ранее эта компания выпустила весьма интересный девайс под названием P/ECE (или PIECE), который представлял собой простенький PDA, выполняющий функции и портативной игровой консольки. Чтобы поддержать продажи этого гаджета и привлечь внимание разработчиков, и был проведён P/ECE Software Contest (осторожно, по ссылке сайт на японском языке): программисты должны были создавать приложения для P/ECE и отправлять их на конкурс. Самые достойные могли получить весьма хорошие призы. Японский разработчик с интересным ником kuni написал игру Spout и продемонстрировал её судьям конкурса. Spout настолько сильно понравился жюри, что занял на P/ECE Software Contest первое место в номинации Games, Utility. Программист kuni за свою игру получил хороший приз от компании: один миллион японских йен; и видимо на этой радостной ноте он опубликовал исходный код игры.



GIF-анимация, демонстрирующая игровой процесс Spout.

Сам по себе P/ECE был похож на Visual Memory Unit от Sega Dreamcast, для которого тоже были доступны различные игры и приложения. P/ECE подключался не к игровой консоли, а к обычному PC посредством USB, на самом девайсе для этого использовался популярный разъём USB Type-B. Для P/ECE можно было писать программы и игры, в комплекте шёл SDK для разработчиков, содержащий необходимые библиотеки, заголовочные файлы и компилятор gcc (GNU project C and C++ compiler).



Внешний вид портативной игровой консоли P/ECE, модель PME-001.

Технические характеристики у P/ECE были следующие:

  • Дисплей: жидкокристаллический, изготовленный по технологии FSTN, отображающий 4 оттенка серого цвета;
  • Разрешение дисплея: 128×88 пикселей;
  • CPU: EPSON S1C33209 24 MHz (32-bit RISC);
  • Оперативная память: SRAM 256KB;
  • Основная память: flash RAM 512KB;
  • Звук: PWM (Pulse-width modulation);
  • Коммуникации: USB Type-B, IrDA.

К большому сожалению, P/ECE так и не выбрался за пределы Японии. На мой взгляд, такой гаджет имел бы успех и в других странах: программируемый тетрис или тамагочи были мечтой многих ребят.

После того, как kuni опубликовал исходники, в 2004 году разработчик с ником rohan портировал игру Spout на Unix-like операционные системы, в которых имелась библиотека SDL, в том числе и на Mac OS X. Позже игру перенесли на различные портативные игровые консоли, такие как GP32, GP2X (авторы порта: no_skill, MichaelA) и Pandora (автор порта: PokeParadox). Для портирования игры на Android OS я взял за основу последние исходники Spout для Pandora от PokeParadox, датирующиеся 2011 годом.

<< Перейти к содержанию

1.1. Краткий обзор исходного кода игры Spout

Код игры Spout не слишком простой для понимания. Устройство P/ECE имело весьма ограниченные ресурсы, поэтому для подобной игры требовалось применить кучу различных ухищрений и оптимизаций. В коде весьма интенсивно используется адресная арифметика и другая различная магия, связанная с операциями над указателями.

Исходный код главным образом размещён в двух компилируемых исходных файлах. В файле spout.c по сути расположен игровой движок, а в файле piece.c его обвязка (вывод изображения на экран, SDL-routines) и различные вспомогательные функции, вроде отрисовки текстовой строки на игровое поле и выбор размера и типа шрифта для этого дела.

Частицы реактивного выхлопа кораблика представлены в виде двусвязного списка:

Огромная функция pceAppProc() занимается обработкой каждого кадра игры. В ней происходят разные вещи, например: генерируется уровень, в буферы заносится необходимое расположение пикселей на экране, рассчитываются коллизии и скорости частиц. Функция большая и сложная, потому я выделю лишь её основную часть, связанную с совмещением двух игровых буферов, первый отвечает за экран, а второй за прокрутку изображения:

Благодаря операции побитового умножения (&), в конечном буфере vbuff остаётся лишь четыре разновидности байтов: 0x0, 0x1, 0x2 и 0x3.

  • 0x0: белый цвет, отвечает за фон;
  • 0x1: зарезервирован одним оттенком серого цвета и используется только в логотипе на экране приветствия;
  • 0x2: другой оттенок серого цвета, используемый для отображения преград в игре;
  • 0x3: черный цвет, часто используемый в игре, к примеру, это управляемый вами кораблик или прицел.

Функция pceLCDTrans() занимается выводом изображения на экран с помощью SDL и вызывается из движка игры, из функции pceAppProc().

Переменной zoom можно задавать степень увеличения выводимого изображения.

В функции pcePadGet() происходит обработка событий клавиатуры.

Статус нажатых кнопок движок получает при каждом новом кадре и сохраняет его в переменную pad в функции pceAppProc(), далее в ней же осуществляется обработка нажатых кнопок. Указатель keys инициализируется в главном цикле игры функцией SDL_GetKeyState(), после чего указывает на место в памяти, которое хранит состояние всех кнопок, доступных из SDL-библиотеки.

<< Перейти к содержанию

2. Портирование игры на GNU/Linux (64-bit)

Перед тем, как портировать игру на Android OS, мне было необходимо проверить её работоспособность на GNU/Linux-десктопе. Всё-таки порт на эту систему делался достаточно давно и многое с течением времени могло измениться. Движок игр подобного рода очень удобно в первую очередь отлаживать на хостовой операционной системе, а потом уже использовать различные реальные Android-устройства или их эмуляторы. Компиляция и запуск бинарного исполнительного файла на компьютере гораздо лучше экономит время, нежели промежуточный деплой на какое-либо устройство и его запуск там. Конечно, после отладки движка на рабочей станции, нужно будет посмотреть и тщательно проверить его работу в неродном для него Android-окружении, желательно на каком-нибудь реальном устройстве. Но об этом будет рассказано ниже, пока у меня была простая цель: добиться компилируемой и полностью рабочей сборки Spout для современного GNU/Linux.

Для того, чтобы не разбираться в различных особенностях работы Makefile’ов и генерировать их автоматически, был создан простой проектный файл под интегрированную среду разработку Qt Creator (см. блок кода выше). Кроме того, с помощью этого инструмента можно удобно отладить неправильно работающую программу: Qt Creator содержит весьма мощный Fronted к известному отладчику gdb (The GNU Debugger). Помимо этого IDE предоставляет достаточно удобные возможности просмотра дерева проектных файлов, навигации по исходному коду игры и наглядные параметры его сборки.



Проект с исходным кодом игры Spout, открытый в IDE Qt Creator.

Первым делом были исправлены различные предупреждения компилятора, указывающие, в основном, на неявные преобразования указателей одного типа к другому. Чтобы своевременно отслеживать все изменения и иметь возможность их отката, проект был взят под версионный контроль. Для этой цели я использовал одну из самых мощных и удобных DCVS — Git. На удивление, игра сразу собралась без каких-либо трудностей, потребовалось лишь явно пробросить линковшику параметр, задающий использование программой библиотеки SDL; всё это легко задаётся в проектном файле. А вот запустив игру я сильно удивился: изображение выглядело битым и распадалось на повторяющиеся сегменты, разделённые равными промежутками.



Битое изображение в Spout на GNU/Linux сразу после его сборки.

Более того, спустя некоторое время игра просто падала с ошибкой Segmentation fault. Это было весьма странное поведение, однако, немного погодя, я понял почему так происходит. Всё дело в том, что игра была разработана в далёком 2002 году, специально под 32-битную платформу гаджета P/ECE, когда на десктопах во всю силу использовались 16-битные операционные системы, а 32-битные были в новинку. Позже, в 2004 году игру портировали на 32-битные Unix-like системы, и, конечно, 64-битные системы в то время тоже были не так сильно распространены. Поскольку я ещё не совсем потерял связь с реальным миром, в 2015 году я использую 64-битную операционную систему. Но, как видно корректную работу кода никто не проверял под современными 64-битными окружениями, отсюда и полезли различные баги. Эту теорию я решил проверить следующим образом: прокинуть флаг -m32 компилятору и линковшику с помощью проектного файла и посмотреть на результат. Этот флажок указывает компилятору gcc собирать 32-битное приложение на 64-битной системе. Конечно же для компиляции и работы такого приложения необходим набор различных 32-битных библиотек, как минимум это libc и SDL с её зависимостями. Чудо свершилось и Spout, код которого был скомпилирован в 32-битный исполнительный файл, оказался вполне рабочим: изображение было нормальным, без багов и глитчей.

Проблема затрагивала лишь 64-битный исполнительный код. А поскольку значительное большинство Android-устройств работают на 32-битных процессорах, этот баг, как может показаться, не является столь критичным. Но всё-таки от этой проблемы хорошо было бы избавиться и найти причину такого странного поведения. Чем я и занялся, погрузившись на полчаса в пошаговую отладку как 32-битной, так и 64-битной версии Spout и сравнивая результаты работы тех или иных функций в них. Поскольку кода было не слишком много, баг был быстро локализован и исправлен.

Суть проблемы оказалась в том, что типы данных long и int на 32-битной архитектуре имеют одинаковый размер, равный четырём байтам, а на 64-битной они разные: 8 байт для long и 4 байта для int. Так как P/ECE имеет 32-битный процессор и прошлый порт предназначался для 32-битных Unix-like операционных систем, никаких отклонений в работе Spout не было обнаружено. Это можно наглядно продемонстрировать на следующем примере:

Если программу выше скомпилировать с помощью gcc и запустить на 64-битной системе, мы получим следующее:

А если скомпилировать её в 32-битный исполнительный файл, то получится следующее:

Как видно, различия в размере указателей и в размере long. Код Spout, достаточно часто прибегающий к использованию адресной арифметики, на 64-битной системе будет работать совсем не так, как ожидал программист. К примеру, упомянутый выше код отрисовки содержимого буферов будет выполняться некорректно:

При выполнении операции инкрементирования указателей pL и pL2, предполагается, что сдвиг будет осуществлён на 4 байта по памяти, а не на 8 байт. Если указатель в этих циклах будет сдвигаться на 8 байт, то мы как раз увидим сегментированное изображение со свободными равными промежутками. А при выходе за границы буфера нас ожидает вполне объяснимый Segmentation fault.



После исправления ошибки, связанной с разным размером типа long, Spout на GNU/Linux (64-bit) заработал нормально.

Для того, чтобы избавиться от этой проблемы и заставить код корректно работать как на 64-битной, так и на 32-битной архитектуре, нужно просто заменить все long на обычный int. Что мной и было сделано, после чего Spout правильно заработал на моей операционной системе. Теперь я мог легко вносить какие-либо изменения в исходный код, компилировать его и сразу же запускать, наблюдая за результатом.

<< Перейти к содержанию

3. Привлекаем к работе OpenGL

На устройствах, работающих под управлением Android OS, библиотека SDL в стандартной поставке отсутствует. Существует несколько неофициальных проектов по переносу SDL на эту операционную систему, а новая версия библиотеки, названная SDL2, так и вообще получила официальную поддержку Android OS. Как было оговорено выше, я не хотел тянуть такую мощную библиотеку для такой простой игры и решил обойтись только стандартными средствами и библиотеками. Раз SDL отсутствует в Android OS по-дефолту, то нужно максимально абстрагироваться от использования этой библиотеки и в компьютерной версии приложения. Поскольку для отображения картинки в Android-версии мной была выбрана стандартная библиотека OpenGL ES, то было бы логично использовать в сборке Spout под GNU/Linux библиотеку OpenGL, благо перенос OpenGL-приложения на OpenGL ES не представляет особых трудностей. Полностью отказываться от использования SDL, в случае с компьютерной версией игры, не требуется, так как эта библиотека предоставляет удобную систему управления OpenGL-контекстом и окном. Именно этой функциональностью и нужно ограничить использование SDL в Spout для GNU/Linux.



Пример testgl.c из исходного кода библиотеки SDL.

В исходном коде библиотеки SDL есть пример testgl.c (ссылка на исходный код), исследуя который можно разобраться в аспектах использования OpenGL-контекста для отображения различных 2D-поверхностей, например, спрайтов. Компилируется и запускается пример следующим образом:

Параметр -logo говорит приложению о том, чтобы помимо вращающегося 3D-куба на экране должен быть отображён и 2D-спрайт. SDL версия Spout для вывода картинки использует специальную поверхность SDL_Surface, а в этом примере SDL_Surface конвертируется в OpenGL-текстуру, которую потом можно будет вывести на OpenGL-контекст и использовать в своих целях. Именно так я и поступил: первая версия Spout, в которой использовался для отображения OpenGL, использовала функцию SDL_GL_LoadTexture() из примера testgl.c; вот исходный код этой функции и вспомогательной функции power_of_two():

Смысл подобных действий состоял в том, чтобы отрисовать SDL_Surface на OpenGL-текстуру, с которой уже можно проводить различные преобразования, к примеру, растягивать её на весь экран с применением разных фильтров. Это работало, но имело несколько фатальных недостатков:

  • Во-первых, текстуру при каждом вызове функции рендеринга кадра необходимо было удалять и пересоздавать заново по изменившемуся SDL_Surface;
  • Во-вторых, этого самого SDL_Surface на Android OS никогда и в помине не было;
  • Во-третьих, такой подход был весьма монструозен: буфер игры сначала отрисовался в SDL_Surface, потом этот SDL_Surface конвертировался в OpenGL-текстуру и лишь потом она выводилась на экран, после чего удалялась до следующего вызова функции отрисовки.

Было ясно, что с этим следует что-то делать. Например, было бы неплохо вообще избавиться от SDL_Surface таким образом, чтобы движок игры рисовал видеобуфер прямиком на OpenGL-текстуру, избегая какого-либо участия SDL вообще. Я начал экспериментировать в этом направлении. Оказалось, что сделать так действительно можно, вот только видеобуфер игры требовалось подвергнуть некоторым изменениям. Поскольку движок игры Spout рендерил в восьмибитный буфер unsigned char и отображал всего четыре цвета (P/ECE отображал лишь 4 монохромных оттенка серого), необходимо было привести буфер к удобоваримому для OpenGL формату. Посмотрев документацию на OpenGL и на OpenGL ES, я выбрал формат, доступный двум этим библиотекам: GL_UNSIGNED_SHORT_5_6_5 и буфер типа unsigned short как нельзя кстати подходили для решения проблемы.

Я остановился перед следующим вопросом: создавать отдельный корректный для OpenGL буфер или же модифицировать движок игры так, чтобы он работал с unsigned short? Мной был выбран вариант с отдельным буфером и вот почему: движок Spout оставался бы нетронутым, а он мог бы мне ещё пригодиться, если бы я захотел портировать Spout на какой-нибудь современный девайс с монохромным экраном. В этом случае мне не пришлось бы менять что-то в коде движка в обратную сторону. Накладные расходы на содержание отдельного буфера незначительны: всего 22 КБ (128 * 88 * 2), в современных реалиях Android OS это просто смешно. Была ещё идея использовать GL_UNSIGNED_BYTE так же доступный в OpenGL ES, в этом случае отдельный буфер бы не требовался, необходимо было бы просто пропатчить старый. Вот только GL_UNSIGNED_SHORT_5_6_5 на мой взгляд на современном устройстве более предпочтителен, хотя для четырёхцветного Spout он может показаться излишним. Позже я буду раскрашивать реактивный выхлоп кораблика в разные цвета и 16-битный буфер для этой цели мне подойдёт больше.

Итак, рядом с буфером pixelData, с которым работает движок игры, я создал вдвое больший буфер testP, с которым будет работать OpenGL. Далее, в функции pceLCDTrans(), отвечающей за рендеринг кадра и обновление буфера pixelData, с помощью цикла я наполняю свой новый буфер testP в соответствии с содержимым четырёхцветного pixelData. В массиве testP после выхода из цикла будет содержаться корректный пиксельный буфер в формате GL_UNSIGNED_SHORT_5_6_5, из которого можно будет сделать OpenGL-текстуру. Не слишком красивый цикл, наверное нужно было бы выделить это всё в функцию, но я решил оставить это так.

Как было сказано выше, в первой версии я создавал текстуру, выводил её на экран, а потом удалял при каждом вызове функции отрисовки pceLCDTrans(). Теперь, когда у нас есть буфер pixelData, готовый к тому, чтобы из него создали OpenGL-текстуру, её требуется создать лишь один раз при запуске игры; соответственно, удалять эту текстуру нужно будет тоже один раз — после того, как произойдёт выход из приложения. А в функции pceLCDTrans() мы будем просто обновлять созданную нами текстуру. Для этого имеется специальная OpenGL-функция glTexSubImage2D(), доступная и для OpenGL ES.

Таким образом были решены все три имеющиеся проблемы, обозначенные выше: SDL_Surface теперь не используется, а текстуру не нужно постоянно создавать и удалять. Получившийся рабочий код выглядит следующим образом:

Рассмотрим механизм работы. Сразу после запуска игры вызывается функция initSDL(), которая создаёт с помощью SDL окно, в котором будет отображён OpenGL-контекст. В этой же функции происходит создание глобальной OpenGL-текстуры global_texture. Но поскольку теперь текстура не зависит от SDL_Surface, огромная функция SDL_GL_LoadTexture() больше нам не нужна, её заменяет SDL_GL_LoadTexture_fromPixelData(), которая делает тоже самое, но использует вместо SDL_Surface пиксельный буфер. В данном случае это поверхность окна — video->pixels.

Далее начинает своё выполнение главный игровой цикл, в котором из движка вызывается функция отрисовки pceLCDTrans(). Так как мы теперь рендерим картинку в OpenGL-текстуру, нам не нужно искусственно увеличивать изображение: переменная zoom теперь локальная и всегда равна единице, а макросы TEXTURE_WIDTH и TEXTURE_HEIGHT раскрываются в 128 и 88 соответственно с оригинальным разрешением экрана в P/ECE. После того, как буфер pixelData будет обновлён, за ним обновится и наш буфер testP, из которого строится OpenGL-текстура. Это происходит ниже, с помощью OpenGL-функции glTexSubImage2D(), аргументами которой являются как сам буфер testP, так и формат хранимых в нём пикселей; при каждом своём вызове эта функция просто обновляет текстуру из буфера. Далее, с помощью функций glBegin() и glEnd(), а также текстурных координат, происходит отображение текстуры в буфер экрана посредством GL_TRIANGLE_STRIP — нескольких соединённых между собой треугольников с общими вершинами и сторонами. Текстурные координаты расчитываются в зависимости от размера экрана (переменные display_w, display_h) и отступов (переменные x_off, y_off). Буфер экрана отображается в окне с помощью SDL-функции SDL_GL_SwapBuffers(). Функции SDL_GL_Enter2DMode() и SDL_GL_Leave2DMode() отвечают за вход в режим отрисовки 2D-спрайтов и, соответственно, выход из этого режима; они были взяты из примера testgl.c, про который я рассказывал выше.

После выхода из главного игрового цикла, OpenGL-функция glDeleteTextures() удаляет созданную в initSDL() текстуру и приложение завершается.



Преимущество рендеринга игрового контекста в OpenGL-текстуру в том, что параметры текстуры можно произвольно изменять. В данном случае текстура подгоняется под установленное разрешение окна в 480×272 пикселей.

Благодаря тому, что для отрисовки игры я использовал OpenGL-текстуру, теперь стало возможным свободно изменять параметры окна, текстура размером 128×88 будет растягиваться посредством переменных display_w, display_h, x_off и y_off. Ранее, в SDL-версии, увеличение экрана происходило лишь через переменную zoom. Кроме того, можно задать фильтрацию текстуры, тем самым сгладив острые грани. Достаточно лишь изменить GL_NEAREST на GL_LINEAR в функции SDL_GL_LoadTexture_fromPixelData(). Зависимость Spout от SDL-библиотеки теперь минимальна, что благоприятным образом поспособствует портированию этой игры на Android OS.

<< Перейти к содержанию

4. Начало портирования на Android OS: создание необходимого окружения

Для автоматической генерации различной Android-мишуры я выбрал IDE Eclipse Luna с установленным ADT-плагином. Eclipse не только крутая среда разработки под самые разные языки программирования, но и мощное средство для создания приложений на Android OS. При желании вы можете настроить деплой как на реальное устройство, так и на эмулятор. Поскольку я решил использовать технологию JNI, мне нужно было установить кроме Android SDK, так же и Android NDK, ну а про JDK и вообще речи нет, он обязателен для того же Eclipse. С помощью мастера создания Android-проектов, который вызывается через главное меню «File -> New -> Other… -> Android -> Android Application Project», я создал проект с Empty Activity. Минимальным Android, на котором должен был заработать мой порт Spout, я выбрал API 9 (Android 2.3), а в качестве Target SDK и Compile with мной был выбран API 16 (Android 4.1). Хотя нижнюю планку можно было опустить намного ниже, вплоть до API 4 (Android 1.6), ибо это позволяло сделать использование обычного OpenGL ES 1.0 без различных шейдеров; но так как таких устройств осталось очень мало, я всё-таки остановился на Android 2.3. Здесь же, в этом мастере, можно сделать и красивую иконку приложения, чем я и воспользовался.



Проект с исходным кодом игры Spout, открытый в IDE Eclipse и запущенный эмулятор для отладки.

К сожалению, Eclipse даже для Empty Activity тащит всякие огромные библиотеки совместимости вроде android-support-v7-appcompat.jar и android-support-v4.jar весом в целых 1.5 МБ. Подобные библиотеки маленькому приложению не нужны вообще, поэтому будет полезным удалить всё это. Первым делом нужно убрать из файла project.properties строку:

Затем удалить файл libs/android-support-v4.jar и удалить в файлах res/values*/styles.xml упоминание о AppCompat, заменив Theme.AppCompat.*, например, на android:Theme.Black. После этих манипуляций приложение перестанет зависеть от библиотек совместимости и будет компактным.

Теперь имея на руках чистый проект, я решил создать три Java-класса. Первый Activity-класс, который должен будет управлять всем приложением, мне уже сгенерировал Eclipse, он называется SpoutActivity.java и является главным и пока единственным Activity. Движок игры Spout должен куда-то рендерить свой контекст, а из Java мы должны всё это обрабатывать и рисовать на экран. Для этого процесса мной был создан специальный класс SpoutNativeSurface.java, отнаследованный от системного класса GLSurfaceView и реализующий интерфейс GLSurfaceView.Renderer. В созданном мной классе нужно реализовать и перегрузить несколько методов, о которых я расскажу ниже. Последний класс SpoutNativeLibProxy.java будет использован для работы с нативным кодом: в нём будут определены публичные статические методы с идентификатором native и будет подгружаться нативная библиотека, в которой должен «крутиться» движок игры Spout.

Исходный код игры, написанный на языке программирования C, я поместил в каталог jni, в корень проекта. В Eclipse есть возможность работать с исходниками, содержащими как привычные Java-классы, так и нативный код на языках C и C++. Нативный код подгружается как библиотека и вызывается из Java-обёртки. Для того, чтобы воспользоваться этой замечательной возможностью Eclipse, необходимо кликнуть правой кнопкой мыши на нужный проект в Project Explorer и в контекстном меню выбрать «Android Tools -> Add Native Support…». В появившемся окне необходимо задать имя библиотеки нажать кнопку Finish. После этого в каталог jni будет добавлен пустой исходный файл с расширением *.cpp и сборочный файл Android.mk, с которым мы будем работать немного позже. Сгенерированный файл Spout.cpp я переименовал в SpoutNativeLibProxy.cpp и добавил к нему ещё заголовочный файл SpoutNativeLibProxy.h. В этих файлах будут содежаться функции, которые будет вызывать Java-оболочка: этакие ниточки за которые можно «подёргать» движок. Кстати, если каталог jni отсутствует, то он будет создан автоматически.

Окончательная картина созданного окружения выглядела следующим образом:

<< Перейти к содержанию

5. Связываем Java-код и C-код вместе: работа с NDK и JNI

Поскольку исходный код игры Spout будет использоваться в виде библиотеки функций, его необходимо должным образом приготовить. Ранее точкой входа, с которой приложение начинало своё выполнение, являлась обязательная функция main(). В этой функции так же был реализован главный игровой цикл. Теперь правила поменялись: главный цикл будет упразднён, поскольку его заменит вызов метода onDrawFrame() в классе SpoutNativeSurface. Этот метод вызывается в бесконечном цикле после того, как Activity начнёт свою работу. В него и нужно будет поместить вызовы функций редеринга кадра.

Для сокрытия ненужных функций в исходном коде Spout я использовал проверку на предопределённый макрос ANDROID_NDK: с помощью директив препроцессора #ifdef, #ifndef, #else и #endif можно гибко управлять платформозависимыми кусками исходного кода, благодаря чему компиляция Spout как под GNU/Linux, так и под Android будет проходить успешно.

Для компиляции библиотеки libSpout.so я написал следующий сборочный файл Android.mk:

Обратите внимание на линкуемые системные библиотеки liblog и libGLESv1_CM. Первая необходима для работы различных DEBUG-макросов, выводящих сообщения в системный лог, ну а вторая, собственно, отвечает за реализацию OpenGL ES. Помимо этого файла я так же добавил файл Application.mk, в котором прописана рекомендуемая сборочная платформа в NDK и то, под какие архитектуры следует собирать приложение:

Значение переменной APP_ABI := all говорит о том, что код Spout будет скомпилирован под все доступные архитектуры в Android NDK. Их всего семь штук:

  1. arm64-v8a;
  2. x86_64;
  3. mips64;
  4. armeabi-v7a;
  5. armeabi;
  6. x86;
  7. mips.

Из файла piece.c, который содержал SDL-обвязку движка, я аккуратно «откусил» всё связанное с SDL c помощью директив препроцессора. В этом же файле было решено также реализовать обвязку OpenGL ES для Android. Но об этом будет ниже. Пока мне нужно было лишь добиться успешной сборки библиотеки. Закончив разбираться с разными ошибками компиляции, я получил вожделенную libSpout.so, которую теперь следовало ловко подцепить с помощью механизма JNI, чтобы иметь возможность воздействовать на библиотеку из Java-кода и обмениваться с ней информацией.

Для этого вернёмся к классу SpoutNativeLibProxy на Java, в котором я подгружаю при запуске приложения библиотеку libSpout.so (пишется только имя библиотеки) и определяю кучу необходимых мне в будущем методов:

Все методы с идентификатором native используются внутри Java-обёртки, и вызывают соответствующие им функции из нативной библиотеки, которых в libSpout.so пока ещё нет. В их реализации нам поможет замечательная утилита javah, идущая в комплекте с JDK. Вызов утилиты с необходимыми параметрами сгенерирует нам специальный заголовочный файл:

В результате выполнения команды создался файл ru_exlmoto_spout_SpoutNativeLibProxy.h, содержащий монструозные объявления функций, построенных по классу SpoutNativeLibProxy:

В принципе, такой заголовочный файл можно было бы написать и руками, но javah здорово сэкономил мне время. Осталось реализовать эти функции, для этой цели я и создавал SpoutNativeLibProxy.cpp. С помощью директивы препроцессора #include подключим ru_exlmoto_spout_SpoutNativeLibProxy.h и реализуем необходимое:

Как видно, одни функции изменяют переменные движка, а другие вызывают его внутренние части обвязки piece.c. К примеру, функция SpoutNativeInit() проинициализирует игровой движок и создаст OpenGL ES контекст, а SpoutNativeDraw() отрисует кадр. В качестве вспомогательного заголовочного файла был создан SpoutNativeLibProxy.h:

В нём определены некоторые вспомогательные макросы, массивы и переменные-параметры, изменяемые из Java-обёртки. Этот заголовочный файл был подключён к SpoutNativeLibProxy.cpp, piece.c и spout.c. Кстати, обратите внимание, что хедеры, используемые движком, в файле SpoutNativeLibProxy.cpp обёрнуты в extern «C».

При завершении работы приложения библиотека может остаться в памяти и сохранить своё состояние, поэтому в Activity-классе SpoutActivity необходимо явно выйти из приложения и выгрузить библиотеку:

Теперь, когда обмен информацией с библиотекой libSpout.so налажен с помощью механизма JNI, можно перейти к непосредственной отрисовке буфера игры на экран устройства.

<< Перейти к содержанию

6. Работа с OpenGL ES из Java-кода и из C-кода

Как было сказано выше, класс SpoutNativeSurface был отнаследован от системного GLSurfaceView и в нём же был реализован интерфейс GLSurfaceView.Renderer. Именно этот класс и будет заниматься выводом графики на экран устройства. Рассмотрим конструктор класса:

Сначала в нём устанавливается рендеринг «на самого себя», затем устанавливается фокус для получения различных событий, вроде нажатий на сенсорный экран. Функция setKeepScreenOn() запрещает устройству гасить подсветку экрана и переходить в режим ожидания. Основные перегруженные методы у класса SpoutNativeSurface следующие:

Работает всё это следующем образом: когда запускается приложение, первым делом вызывается метод onSurfaceCreated(), который инициализирует движок и контекст OpenGL ES, потом вызывается метод onSurfaceChanged(), в котором в библиотеку передаётся необходимый размер экрана и контекст OpenGL ES переинициализируется снова, подстроившись под необходимое разрешение. И лишь затем получает управление метод onDrawFrame(), в котором встроен ограничитель FPS на 60 кадров в секунду. Именно onDrawFrame() и начинает рисовать игру на экране, постоянно вызывая нативный метод SpoutNativeDraw() из класса SpoutNativeLibProxy, управление которого передаётся в нативную библиотеку.

Перегруженный метод onCreate() класса SpoutActivity выглядит следующим образом:

В нём я, во-первых, настраиваю показ окна в полный экран и без заголовка, а, во-вторых, устанавливаю источником видимого контента объект класса SpoutNativeSurface, в котором как раз производится обработка и отображение игровых кадров в виде контекста OpenGL ES.

На этом с Java-обёрткой закончено, вернёмся в файл piece.c, в новую обвязку движка. Благодаря тому, что в версии Spout для GNU/Linux я использовал OpenGL, перенос кода на OpenGL ES оказался не слишком трудным, пришлось выкинуть некоторые недоступные функции и заменить их аналогами. Единственное, с чем пришлось долго сражаться, это белый экран. Поскольку для отладки я использовал устройство в котором ускоритель графики был от фирмы PowerVR, размеры текстуры должны были быть степенью двойки, только в таком случае текстура будет отображена. Мне пришлось изменить размер текстуры с 128×88 на 256×128 и немного поправить текстурные координаты, чтобы растянуть контекст.

В функции initSpoutGLES() создаётся специальный буфер texture_map, который будет привязан к текстуре. С помощью функции render_pixels() в этот буфер можно отправлять различные данные, которые незамедлительно отобразятся на экране. В resizeSpoutGLES() перенесена вся инициализация контекста OpenGL ES и подготовка к отображению игровых кадров. В pceLCDTrans() происходит обновление текстуры и её отображение на экран специальной функцией из OpenGL ES — glDrawTexiOES(). Помимо этого сохранена функциональность, унаследованная от моего GNU/Linux-порта, связанная с манипулированием размером текстуры с помощью отступов и задаваемых размеров.

После завершения игры память, выделенная под texture_map, освобождается в функции deinitSpoutGLES(). А вот видеопамять, в которой была размещена текстура, освобождается автоматически после разрушения EGL-контекста.

Функция stepSpoutGLES() вызывается внутри каждого вызова Java-метода onDrawFrame(). Ранее движок сам из себя вызывал функцию pceLCDTrans(), это было несколько некрасиво и с этим был связан баг отображения экрана паузы, теперь эта функция вызывается после выполнения «шага» движка, опрашивая игровой видеобуфер.



Spout, запущенный на Android OS.

С помощью этих манипуляций мне удалось добиться отображения Spout на своём Android-устройстве.

<< Перейти к содержанию

7. Основы создания Launcher’a

Так как я пробрасывал в движок большое количество параметров с помощью JNI, у меня возникла потребность в их удобном редактировании. Было решено сделать простой Launcher, чтобы иметь возможность отключить ту или иную функцию. Для этого я создал ещё один класс, отнаследованный от Activity — SpoutLauncher.java (ссылка на исходный код) и добавил его в AndroidManifest.xml. В Eclipse есть удобный редактор стандартного Android GUI, поэтому я быстро разместил необходимые виджеты на форме и получил следующее:



Launcher, специально созданный для игры Spout.

Смысл работы такого Launcher’а весьма прост: необходимо собрать все доступные параметры с формы, и по нажатию кнопки Run Spout! отправить их в движок, а затем запустить игру на исполнение. Описание у параметров следующее:

  1. Apply Filter (Smooth): применить GL_LINEAR фильтр к отображаемой текстуре;
  2. 3D Cube: включить демонстрационный 3D-режим;
  3. Accelerometer: включить управление акселерометром;
  4. Strong Buttons: включить хорошо видимые на экране кнопки (иначе — прозрачные);
  5. Disable Buttons: отключить сенсорное управление посредством кнопок, использовать части экрана;
  6. Screen Offset ‘x’, ‘y’: установить текстурные отступы;
  7. Aspect Ratio: соблюдать на текстуре соотношение размеров;
  8. Fullscreen: отображение текстуры на весь экран;
  9. Colorize: раскрасить логотип игры и выхлоп кораблика;
  10. Long Tail: длинный хвост-прицел;
  11. Sound: включить звуковые эффекты;
  12. Vibro: использовать виброотдачу.

Большинство этих опций будет упомянуто ниже. Некоторые из них используются внутри самого SpoutLauncher, другие пробрасываются в SpoutActivity или SpoutNativeSurface и движок игры.

В библиотеку из Launcher’а можно пробросить произвольные текстурные отступы, к примеру, чтобы растянуть текстуру на весь экран, отступы должны быть установлены в x = 0 и y = 0. Именно этим и занимается параметр Fullscreen. Опция Aspect Ratio тоже устанавливает отступы, но она должна их расчитать в зависимости от разрешения экрана Android-устройства:



Результат работы опции Aspect Ratio.

Алгоритм следующий: увеличиваем каждую из сторон оригинального размера экрана P/ECE на себя саму до тех пор, пока не вылетим за границы экрана на Android-устройстве. После чего берём наименьший корректный множитель, на который умножаем обе стороны оригинального размера (128×88) и получаем необходимое разрешение из которого вычисляем требуемые отступы. К примеру, для Android-устройства с разрешением экрана 960×540 это будет x = 96 и y = 6. Следует заметить, что диапазон значений я ограничил таким образом, что максимальное значение будет давать оригинальный размер экрана P/ECE.

Некоторые параметры исключают использование других опций, поэтому они будут отключены при их включении. Для реализации такого поведения применяются специальные обработчики-Listener’ы:

Их методы вызываются при различных событиях. Например, при нажатии кнопки (см. код выше) будет запущен Activity-класс SpoutActivity. Завершиться он должен именно выходом с помощью System.exit(0), иначе библиотека останется в памяти и при следующем запуске Spout нас могут поджидать неприятности.

<< Перейти к содержанию

7.1. Сохранение параметров с помощью класса SharedPreferences

После создания Launcher’а было бы удобно сохранять выставленные параметры, чтобы после перезапуска приложения не задавать их снова. Для этой цели нам как нельзя кстати пригодится системный класс SharedPreferences, сохраняющий настройки приложения в специальной системной RW-области в виде XML-файла.

Механизм работы прост: сразу после запуска игры расставляем все сохранённые опции на форму, а после завершения работы приложения собираем параметры с формы. Для этого в классе SpoutLauncher были созданы методы writeSettings() и readSettings():

Проверить, запущено ли приложение в первый раз или уже запускалось, можно таким образом:

Помимо опций на форме Launcher’а я решил сохранять с помощью класса SharedPreferences и игровой счёт. Здесь точно так же, как в случае с формой: при запуске игры сохранённые значения отправляются в движок, а после выхода из игры они обновляются, если набранный ранее рекорд был побит. Метод сохранения рекорда реализован в классе SpoutActivity:

JNI является интерфейсом работающим в обе стороны. Это значит, что не только из Java-кода можно вызывать функции нативных библиотек, но и из библиотек можно вызывать методы Java-классов. Благодаря возможности проброса указателя на окружение Java в движок игры Spout, я могу вызвать из файла piece.c Java-метод setScores() класса SpoutActivity следующим образом:

Проброс указателя и функции получения текущего состояния счёта реализованы в файле SpoutNativeLibProxy.cpp:

Указатель javaEnviron объявлен как extern в хедере SpoutNativeLibProxy.h, который подключён и к piece.c.

Для чего такие сложности?! Тут есть маленькая хитрость: движок игры отправляет набранные очки на сохранение лишь тогда, когда это необходимо. В первой версии работы со счётом у меня выявился небольшой баг: если получить Game Over с рекордом и нажать выход, очки не сохраняются. Поэтому при завершении работы Activity я получаю актуальное состояние счёта и сравниваю его с тем, что мне отправил ранее движок. Если мои актуальные значения больше, то я сохраняю их, а если меньше или равны, то я записываю те, которые получил от Spout. Таким образом удалось исправить эту ситуацию.

<< Перейти к содержанию

7.2. Передача параметров в С-движок игры из Launcher’a

Чтение сохранённых настроек приложения осуществляется в специальный вложенный публичный статический класс SpoutSettings, являющийся подклассом SpoutLauncher:

Именно значениями этого класса заполняется форма Launcher’а и именно отсюда после нажатия кнопки Run Spout! большинство из них отправляются в движок из метода onSurfaceCreated() класса SpoutNativeSurface:

Сложный пример игрового счёта: очки читаются при запуске приложения в классе SpoutLauncher, в поля подкласса SpoutSettings; затем, после нажатия кнопки запуска Spout, значения отправляются в библиотеку в классе SpoutNativeSurface. Во время игры движок может обновить поля подкласса SpoutSettings, а после выхода из игры актуальные значения счёта сохранятся во временные переменные, которые сравниваются с соответствующими значениями полей SpoutSettings. По результату этого сравнения в хранилище будет отправлен максимальный игровой счёт.

<< Перейти к содержанию

8. Органы управления

Теперь, когда движок мог рисовать свой контекст на экран Android-устройства, мне необходимо было подумать о возможности управления корабликом в игре. Как я уже упоминал выше, я являюсь счастливым обладателем Android-гаджета с аппаратной клавиатурой. Поэтому управление физическими клавишами я решил реализовать в первую очередь. К моему большому сожалению, количество подобных аппаратов стремительно сокращается и миром правят устройства, в которых сенсорный экран заменил привычную клавиатуру. Так что мне пришлось заниматься и сенсорным управлением, которое является де-факто стандартом в Android OS. Помимо этих двух вариантов я решил ещё опробовать и третий: манипулирование корабликом с помощью акселерометра.

<< Перейти к содержанию

8.1. Физическая клавиатура

Android OS используется для широкого спектра различных устройств: кроме привычных всем смартфонов и планшетов на этой операционной системе могут работать различные игровые консоли, телевизионные приставки, нетбуки и портативные переносные компьютеры, размером с брелок. У этих устройств может отсутствовать сенсорный экран или они будут подключены к обычному монитору или телевизору, а потому реализация управления с помощью аппаратных клавиш крайне желательна.

Android API предоставляет возможность использования физической клавиатуры в приложениях. Мой порт Spout на GNU/Linux использовал библиотеку SDL для того, чтобы опрашивать клавиатуру моего ноутбука и отправлять состояние нажатых кнопок в движок. Так как SDL больше недоступен, мной было придумано получать состояние клавиш через Java-обёртку, а потом перенаправлять все события клавиатуры с помощью JNI в нативный код, в специальный массив, отвечающий за состояние кнопок.

В классе SpoutNativeSurface мной были перегружены методы onKeyDown() и onKeyUp(), отлавливающие нужные мне события. В этом же классе было создано перечисление отправляемых в нативный код идентификаторов необходимых клавиш. Получилось следующее:

В зависимости от того, какая клавиша нажата или отжата, нативные методы SpoutNativeKeyDown и SpoutNativeKeyUp отправляют специальный идентификатор кнопки в Spout. Далее, в файле SpoutNativeLibProxy.cpp происходит обновление массива keysState, который отвечает за состояние необходимой части клавиатуры.

Индексом массива является идентификатор кнопки. Если она нажата, в keysState по индексу keyCode кладётся единичка, а если отжата — нолик. Массив keysState объявлен в заголовочном файле SpoutNativeLibProxy.h как внешний (extern), а потому он доступен и из piece.c — обвязки движка. Функция pcePadGet() была модифицирована следующим образом:

Указатель keys после вызова функции инициализации начинает ссылаться на массив состояний keysState. Перечисление KeyCodes аналогично перечислению из Java-обёртки и создано для понимания происходящей ситуации.

На моё удивление, после внесённых модификаций в код, клавиатура заработала сразу правильным образом, именно так, как и должна была работать. Кораблик получил управление и я смог немного поиграть в Spout на своём Android-устройстве.

<< Перейти к содержанию

8.2. Наэкранное сенсорное управление

По-хорошему, создавая игровой проект для Android OS, о сенсорном управлении нужно думать заранее. Оно сразу должно поддерживаться движком без исключений. Но поскольку я просто переносил одну из любимых игр на своё Android-устройство, я решил реализовать несколько простейших вариантов такого управления и вынести их переключение в Launcher. Изначально я просто делил экран на три части, нажатия на которые обрабатывались точно так же, как нажатия на кнопки физической клавиатуры. Первая версия метода была реализована весьма грязно, поэтому я столкнулся с различными багами. Например, при свайпах, когда палец игрока переходит из одной части экрана в другую, нажатая клавиша не отжималась и кораблик продолжал выполнять действие.



Разделение сенсорного экрана устройства на функциональные части.

Я заметил, что когда «заедала» кнопка, отвечающая за выброс реактивной струи, управлять корабликом на сенсорном экране становилось значительно проще. Отпало требование постоянно держать нажатой клавишу огня, нужно было просто отклонять реактивный выброс частиц. Таким образом баг поспособствовал идее создать такую часть на экране, нажатие на которую включало бы автоматический огонь. Поэтому я решил разделить экран на четыре части (см. изображение выше) и закрепить за каждой из них следующие действия:

  1. Отклонение реактивной струи влево;
  2. Автоматический огонь;
  3. Огонь;
  4. Отклонение реактивной струи вправо.

Отловить события нажатий на сенсорный экран можно с помощью перегрузки метода onTouchEvent() в классе SpoutNativeSurface. Я исправил баги первой реализации и получил следующий код:

К сожалению, не всё оказалось гладко и из-за автоматического огня я был вынужден подпереть код костылём-задержкой, чтобы после Game Over не приходилось дважды нажимать на часть экрана, отвечающую за клавишу огня. Поскольку состояние этой кнопки в массиве так и остаётся нажатым.

Управление, реализованное выше, доступно при включенной опции Disable Buttons в Launcher’е. На таком управлении можно было бы и остановиться, но я заинтересовался возможностью отображения наэкранных кнопок. В основном все Android-игры, исходники которых я просматривал, реализуют отображение сенсорных элементов управления внутри своей нативной библиотеки. Это мне не слишком подходило, поскольку требовалось отображать текстуры и делать прочие сложные вещи в обвязке движка piece.c; хотелось какого-нибудь стандартного средства. Просмотрев примеры работы с Android API в эмуляторе (приложение API Demos) я обнаружил возможность отрисовки стандартных виджетов поверх видимого контекста (пример Graphics/SurfaceView Overlay). Это отлично подходило для реализации наэкранного управления для такой маленькой игры, как Spout.



Сенсорное управление в игре Spout, реализация кнопок. Используются стандартные виджеты Android OS 4.1.2.

Класс SpoutActivity идеально подходил для этой работы. В его перегруженный метод onCreate() я добавил следующий код:

Здесь происходит следующее: создаются четыре стандартных для Android GUI кнопки, действующих по типу частей экрана, описанных выше. Затем в кнопках реализуются методы, отвечающие за нажатия на них. Эти методы отправляют в нативный движок необходимые состояния, изменяя внутренний массив представления клавиатуры. Кнопка Hold может изменять свой цвет в зависимости от того, зажата она или нет. Параметр s_ShowButtons отвечает за прозрачность элементов управления: они могут быть как полностью прозрачные, со слегка видимой надписью, так и явно видимые. Тут тоже используется костыль-задержка для кнопки Fire!, поскольку это управление технически аналогично тому, про которое я рассказал выше. Далее кнопки добавляются на два слоя ll0 и ll, которые потом накладываются на игровой контекст. Следует заметить, что промежутки между кнопками расчитываются в зависимости от плотности пикселей на экране.



Сенсорное управление в игре Spout, реализация полностью прозрачных кнопок: слабо видны лишь их надписи. Используются стандартные виджеты Android OS 4.1.2.

Подобное нагромождение слоёв может сильно повлиять на FPS, однако я такого поведения не реальном устройстве не заметил. Checkbox под названием Strong Buttons на Launcher’е отвечает за степень прозрачности кнопок, а отключить отображение элементов сенсорного управления можно опцией Disable Buttons. В таком случае будет задействован механизм работы с частями экрана, описанный выше.

В итоге я реализовал целых три различных вариантов сенсорного управления: без каких-либо кнопок, с полностью прозрачными элементами и с явно видимыми.

<< Перейти к содержанию

8.3. Акселерометр

Помимо стандартных вариантов управления с помощью сенсорного экрана и клавиатуры, мне было интересно попробовать поработать и с акселерометром, доступным практически во всех современных Android-устройствах. Я решил управлять прицелом в игре посредством отклонения гаджета в левую или в правую стороны. Для этого я использовал класс SpoutActivity, в котором было необходимо реализовать интерфейс SensorEventListener. После чего потребовалось имплементировать методы onAccuracyChanged() и onSensorChanged():

В перегруженном методе onCreate() происходит выбор и настройка параметров акселерометра, экземпляр класса подписывается на события, связанные с изменением положения устройства в пространстве. Метод onAccuracyChanged() нам не требуется, а вот в onSensorChanged() я отлавливаю изменения положения по оси Y. Соответственно, в зависимости от полученного значения, вызываю приватный метод toLeft(), который отправляет в движок Spout необходимые нажатия для управления углом наклона реактивной струи.



Сенсорное управление в игре Spout, когда параметр Accelerometer активен, кнопки Left и Right недоступны. Используются стандартные виджеты Android OS 4.1.2.

Протестировав такой способ управления в боевых условиях на Android-устройстве, я заметил, что движок на изменения реагирует слишком быстро. А потому мне снова пришлось заморочиться с задержкой в отдельном потоке. И даже после этого управлять корабликом с помощью акселерометра весьма трудно. Видимо эта игра просто не подходит для такого типа управления, так как тут прицел сопла реактивного двигателя вращается вокруг корабля. Включить акселерометр можно в Launcher’е с помощью опции Accelerometer, при этом возможность управлять отклонениями кораблика с помощью сенсорного экрана отключаются.

<< Перейти к содержанию

9. Параметры Tweaks

В процессе работы над портом Spout на Android-устройства я решил сделать некоторые улучшения самой игры и поработать с Android API, отвечающим за простые звуковые эффекты и вибрацию. Во-первых, было бы интересно добавить немного цвета и раскрасить выхлоп реактивных частиц кораблика. Во-вторых, стандартный хвост-прицел оказался слишком коротким и иногда перекрывался пальцем, а потому было бы неплохо сделать его длиннее. События нажатий на сенсорный экран, было бы замечательно сигнализировать звуком и вибрацией: своеобразный Haptics-эффект. Это также справедливо и для индикации состояния Game Over.

Таким образом в Launcher’е появился раздел опций Tweaks, своеобразных улучшений игры, которые можно включать и отключать.

<< Перейти к содержанию

9.1. Цветные частицы

Поскольку игра использует лишь четыре оттенка серого цвета, мной было придумано следующее: в файле spout.c собрать все значения цветов, отвечающих за частицы во внутреннем буфере vbuff2 и привести их к одному зарезервированному оттенку серого цвета:

Затем в файле piece.c в функции pceLCDTrans(), заменить эти пиксели другими, со случайным цветом из таблицы colorTable:



Spout, активированная опция Colorize.

Это сработало: частички стали цветными и начали случайным образом менять свой цвет. Изменился так же и логотип игры Spout в начальной заставке, но это лишь пошло ему на пользу, на мой взгляд.

<< Перейти к содержанию

9.2. Длинный хвост-прицел

С прицелом в оригинальном движке игры связана интересная особенность: если приблизиться к какой-либо грани экрана и резко повернуть хвост в сторону стены, то он может вылезти на противоположной стороне игрового поля. В файле piece.c в функции pceLCDTrans() я нашёл цикл, в котором реализована отрисовка хвоста, и попробовал его удлинить. Это сработало, но из-за особенности, которую я упомянул выше, в некоторых местах за границей игрового поля длинный хвост оставлял за собой вереницу пикселей, а при пересечении верхней границы приложение так и вообще падало с ошибкой Segmentation fault. Проблема оказалась в определениях границ, которые изначально были расставлены немного неправильно, возможно в целях оптимизации. Я поправил эту ошибку исправлением значений границ и всё отлично заработало. Цикл рисования хвоста получился таким:



Spout, активированная опция Long Tail.

Максимально возможная длина хвоста определена гипотенузой, поэтому по теореме Пифагора зная размеры сторон экрана расчитать её не составило труда. Она уменьшена в четыре раза, поскольку длина одного сегмента хвоста занимает четыре пикселя.

<< Перейти к содержанию

9.3. Звук

В качестве звуков нажатия на сенсорные элементы управления я решил использовать звуки клавиатуры от мобильных телефонов Motorola E398 и Siemens CX65, которые я заблаговременно скачал с тематического форума MotoFan.Ru. Один из этих звуков я использовал также для индикации Game Over. Рекомендуется использовать WAV-файлы с частотой дискретизации 44100 Гц. Чтобы звуки были доступны в процессе работы приложения, их требуется положить в директорию с ресурсами res или assets, тогда они попадут в APK-пакет; я выбрал последнее. Для проигрывания коротких звуковых файлов в Android OS рекомендуется использовать системный класс SoundPool. Весь код работы со звуком я разместил в классе SpoutActivity:

Вложенный статический класс SpoutSounds содержит специальные идентификаторы звуковых файлов, которые устанавливаются в методе onCreate(). Вызывая playSound() с аргументом-идентификатором, можно воспроизвести нужный звук. Метод playSound() публичный и статический, поскольку вызывается не только из Java-обёртки, но и из движка игры. Для этого в piece.c реализована функция playGameOverSoundFromJNI(), которую дёргает движок spout.c. Выглядит она следующим образом:

С помощью проброшенного указателя на Java-окружение javaEnviron мы получаем идентификатор звукового файла, отвечающий за Game Over и передаём его в метод playSound(), тем самым проигрывая необходимый звук.

<< Перейти к содержанию

9.4. Виброотдача

Для управления вибромоторчиком устройства из игры, необходимо получить специальное разрешение, добавив в файл AndroidManifest.xml следующее:

Эту строку необходимо вставить внутрь корневого тега manifest. В классе SpoutActivity в перегруженном методе onCreate() я добавил инициализацию объекта класса Vibrator и публичный статический метод doVibrate(), аргументом которого является задержка в миллисекундах:

Для того, чтобы вибрировать при Game Over этот метод необходимо вызывать из нативной библиотеки Spout. Первоначально я не вызывал doVibrate() из движка игры, а использовал переменную-флажок, которая принимала значение единички, когда игра была проиграна. Далее в методе onDrawFrame() класса SpoutNativeSurface я проверял значение этой переменной и вызывал вибрацию, если это было необходимо. К сожалению, проверки в onDrawFrame() снизили FPS и мне пришлось сделать так, чтобы doVibrate() мог быть вызван из нативной обвязки движка, расположенной в файле piece.c. Для этого была реализована функция vibrateFromJNI(), похожая на ту, что использовалась для проигрывания звуков:

Таким образом vibrateFromJNI() мог вызываться из самого кода движка в spout.c:

Вспомогательные флажки используются для того, чтобы функции vibrateFromJNI() и playGameOverSoundFromJNI() вызывались единожды.

<< Перейти к содержанию

10. Реализация 3D-куба с изменяющимися текстурами

Ради интереса и более углубленного изучения OpenGL ES, я реализовал в игре специальный демо-режим, который демонстрирует вращающийся куб. Поскольку для отображения игрового контекста используется текстура, её можно легко натянуть на объект и обновлять. Тем самым у меня получился кубик, сторонами которого служат «игровые экраны». Опция 3D Cube в Launcher’е как раз и активирует этот демо-режим.



Spout, активированная опция 3D Cube.

Необходимые для этого изменения были внесены в нативную обвязку движка piece.c:

При запуске игры текстурные координаты куба инициализируются функцией initCubeTexturesCoords(). Нативная библиотека GLU, из которой мне потребовалась функция gluPerspective(), отсутствует в стандартной поставке Android OS. Потому был реализован её аналог emulateGLUperspective(). Рисование куба производится функцией draw_cube().

Сперва было необычно играть в Spout в таком режиме, но позже мне удалось даже пройти первую часть игрового пространства. Глаза автоматически переключаются на следующую грань куба, когда текущая исчезает из поля зрения. В любом случае, этот режим является демонстрационным и наверняка вреден для зрения.

<< Перейти к содержанию

11. Заключение, полезные ссылки и ресурсы

В процессе портирования игры Spout на Android OS я разобрался в аспектах взаимодействия механизма JNI, научился компилировать код с помощью Android NDK, познакомился с OpenGL ES 1.0, смог реализовать Java-обёртку над нативной библиотекой и попробовал создание Android-проектов в Eclipse IDE. Я получил большое количество ценного опыта, и рад потраченному времени на возню со Spout и написание этой статьи.

Кстати, с одной лишь нативной библиотекой под «общую» архитектуру ARM-устройств armeabi и без звуков, собранный APK-пакет Spout занимает смешной объём памяти: 55 КБ. И то, наверное, большая часть этого размера это PNG-иконка для устройств с большим HiDPI. Соответственно, финальный APK-пакет со всеми ресурсами и нативными библиотеками под семь основных архитектур, занимает 195 КБ. Это тоже весьма маленький объём, судя по современным меркам.



Архитектуры нативных библиотек в APK-пакете Spout, программа Ark.

Скачать APK-пакет Spout, готовый для запуска на Android-устройстве, можно по этим ссылкам:

[Скачать APK-пакет Spout, 195 КБ | Download Spout APK-package, 195 KB]
[Скачать APK-пакет Spout, ARM, 76 КБ, версия сенсорного управления от yakimka | Download Spout APK-package, ARM, 76 KB, touch controls by yakimka]

Исходный код выложен на ресурсе Github. Мои изменения и исходные файлы доступны под лицензией MIT. Файлы оригинального разработчика игры были выложены без указания лицензии. Ссылка на репозиторий:

https://github.com/EXL/Spout

В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.

  1. Персональный блог kuni, разработчика игры Spout — осторожно, по ссылке блог на японском языке;
  2. Руководство по портированию с OpenGL на OpenGL ES от сообщества обладателей портативной игровой консоли Pandora;
  3. Туториалы от NeHe, портированные для Android OS — одни из самых известных уроков по OpenGL в сети;
  4. Отличная статья, которая рассказывает как рисовать свой контекст на текстуре, автор статьи и блога Richard Quirk;
  5. Коммерческая реализация игры Spout с помощью фреймворка Cocos2d, доступная в Google Play, автор Airmind.

Update 06-MAR-2017: Прочитать информацию о новой версии порта Spout v1.1 с новым управлением и улучшениями можно по этой ссылке.



Новый вариант управления сенсорным джойстиком, скриншот с Motorola Photon Q (превью, увеличение по клику).

Скачать пакеты новых версий можно там же.

Update 05-MAY-2017: Проект был переведён с устаревших технологий ant и Eclipse ADT на Gradle и Android Studio. Это ознаменовало выход новой версии Spout 1.2, скачать которую можно со странички проектов.

<< Перейти к содержанию

Android, Dev

Комментарии: 2

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *