Когда-то давно я портировал на платформу 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) и различные вспомогательные функции, вроде отрисовки текстовой строки на игровое поле и выбор размера и типа шрифта для этого дела.
Частицы реактивного выхлопа кораблика представлены в виде двусвязного списка:
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 |
#define MAX_GRAIN 500 typedef struct { short x, y; } SVECTOR; typedef struct { int x, y; } VECTOR; typedef struct tagGRAIN { struct tagGRAIN *next; struct tagGRAIN *prev; SVECTOR s, v; short pos; unsigned char color; } GRAIN; GRAIN *grainUseLink, *grainFreeLink; GRAIN grain[MAX_GRAIN]; void initGrain (void) { int i; for (i = 0; i < MAX_GRAIN - 1; i++) { grain[i].next = &grain[i + 1]; } grain[i].next = NULL; grainFreeLink = grain; grainUseLink = NULL; return; } GRAIN * allocGrain (void) { GRAIN *current = grainFreeLink; if (current) { grainFreeLink = current->next; current->next = grainUseLink; current->prev = NULL; if (current->next) { current->next->prev = current; } grainUseLink = current; } return current; } GRAIN * freeGrain (GRAIN * current) { GRAIN *next = current->next; if (next) { next->prev = current->prev; } if (current->prev) { current->prev->next = next; } else { grainUseLink = next; } current->next = grainFreeLink; grainFreeLink = current; return next; } |
Огромная функция pceAppProc() занимается обработкой каждого кадра игры. В ней происходят разные вещи, например: генерируется уровень, в буферы заносится необходимое расположение пикселей на экране, рассчитываются коллизии и скорости частиц. Функция большая и сложная, потому я выделю лишь её основную часть, связанную с совмещением двух игровых буферов, первый отвечает за экран, а второй за прокрутку изображения:
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 |
unsigned char vbuff[128 * 88]; unsigned char vbuff2[128 * 128]; void pceAppProc () { ... { unsigned long *pL, *pL2, *pLe; pL = (unsigned long *) (vbuff + 2 * 128); pL2 = (unsigned long *) (vbuff2 + dispPos * 128); pLe = pL2 + 128 * 78 / 4; if (pLe > (unsigned long *) (vbuff2 + 128 * 128)) { pLe = (unsigned long *) (vbuff2 + 128 * 128); } while (pL2 < pLe) { *pL = *pL2 & 0x03030303; pL++; pL2++; } pL2 = (unsigned long *) (vbuff2); while (pL < (unsigned long *) (vbuff + 128 * (78 + 2))) { *pL = *pL2 & 0x03030303; pL++; pL2++; } } ... } |
Благодаря операции побитового умножения (&), в конечном буфере vbuff остаётся лишь четыре разновидности байтов: 0x0, 0x1, 0x2 и 0x3.
- 0x0: белый цвет, отвечает за фон;
- 0x1: зарезервирован одним оттенком серого цвета и используется только в логотипе на экране приветствия;
- 0x2: другой оттенок серого цвета, используемый для отображения преград в игре;
- 0x3: черный цвет, часто используемый в игре, к примеру, это управляемый вами кораблик или прицел.
Функция pceLCDTrans() занимается выводом изображения на экран с помощью SDL и вызывается из движка игры, из функции pceAppProc().
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
SDL_Surface *video, *layer; void pceLCDTrans () { int x, y; unsigned char *vbi, *bi; unsigned char *bline; const int zoom = 2; const int offsetx = SDL_WIDTH/2 - 128*zoom/2; const int offsety = SDL_HEIGHT/2 - 88*zoom/2; bi = layer->pixels; for (y = 0; y < (88*zoom); y++) { vbi = vBuffer + (y/zoom) * 128; //the actual line on the pce internal buffer (128x88) bline = bi + layer->pitch * (y + offsety); bline += offsetx; for (x = 0; x < (128*zoom); x++) { *bline++ = *(vbi + x/zoom); } } SDL_BlitSurface (layer, NULL, video, &layerRect); SDL_Flip (video); } |
Переменной zoom можно задавать степень увеличения выводимого изображения.
В функции pcePadGet() происходит обработка событий клавиатуры.
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 |
unsigned char *keys; int pcePadGet () { static int pad = 0; int i = 0, op = pad & 0x00ff; int k[] = { SDLK_PAGEUP, SDLK_PAGEDOWN, SDLK_LEFT, SDLK_RIGHT, SDLK_KP4, SDLK_KP6, SDLK_x, SDLK_z, SDLK_SPACE, SDLK_RETURN, SDLK_ESCAPE, SDLK_LSHIFT, SDLK_RSHIFT, SDLK_PLUS, SDLK_MINUS }; int p[] = { PAD_UP, PAD_DN, PAD_LF, PAD_RI, PAD_LF, PAD_RI, PAD_A, PAD_B, PAD_A, PAD_B, PAD_C, PAD_D, PAD_D, -1 }; pad = 0; do { if (keys[k[i]] == SDL_PRESSED) { pad |= p[i]; } i++; } while (p[i] >= 0); pad |= (pad & (~op)) << 8; return pad; } |
Статус нажатых кнопок движок получает при каждом новом кадре и сохраняет его в переменную pad в функции pceAppProc(), далее в ней же осуществляется обработка нажатых кнопок. Указатель keys инициализируется в главном цикле игры функцией SDL_GetKeyState(), после чего указывает на место в памяти, которое хранит состояние всех кнопок, доступных из SDL-библиотеки.
2. Портирование игры на GNU/Linux (64-bit)
Перед тем, как портировать игру на Android OS, мне было необходимо проверить её работоспособность на GNU/Linux-десктопе. Всё-таки порт на эту систему делался достаточно давно и многое с течением времени могло измениться. Движок игр подобного рода очень удобно в первую очередь отлаживать на хостовой операционной системе, а потом уже использовать различные реальные Android-устройства или их эмуляторы. Компиляция и запуск бинарного исполнительного файла на компьютере гораздо лучше экономит время, нежели промежуточный деплой на какое-либо устройство и его запуск там. Конечно, после отладки движка на рабочей станции, нужно будет посмотреть и тщательно проверить его работу в неродном для него Android-окружении, желательно на каком-нибудь реальном устройстве. Но об этом будет рассказано ниже, пока у меня была простая цель: добиться компилируемой и полностью рабочей сборки Spout для современного GNU/Linux.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
###################################################################### # Automatically generated by qmake (3.0) ?? ???. 15 02:05:59 2015 ###################################################################### CONFIG -= qt TEMPLATE = app TARGET = Spout INCLUDEPATH += . QMAKE_CFLAGS = $$system(sdl-config --cflags) LIBS = $$system(sdl-config --libs) # Input HEADERS += config.h font.h piece.h sintable.h \ spout.h SOURCES += piece.c spout.c |
Для того, чтобы не разбираться в различных особенностях работы 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 не было обнаружено. Это можно наглядно продемонстрировать на следующем примере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <stdio.h> int main (int argc, char *argv[]) { char *a; long *b; int *c; void *d; char e; long f; int g; printf("Bitness:\t%u\n\n" "char\t*a:\t%u\n" "long\t*b:\t%u\n" "int\t*c:\t%u\n" "void\t*d:\t%u\n\n" "char\te:\t%u\n" "long\tf:\t%u\n" "int\tg:\t%u\n", (unsigned int)(sizeof(void *)<<3), (unsigned int)sizeof(a), (unsigned int)sizeof(b), (unsigned int)sizeof(c), (unsigned int)sizeof(d), (unsigned int)sizeof(e), (unsigned int)sizeof(f), (unsigned int)sizeof(g)); return 0; } |
Если программу выше скомпилировать с помощью gcc и запустить на 64-битной системе, мы получим следующее:
1 2 3 4 5 6 7 8 9 10 11 12 |
$ gcc size_of_types.c $ ./a.out Bitness: 64 char *a: 8 long *b: 8 int *c: 8 void *d: 8 char e: 1 long f: 8 int g: 4 |
А если скомпилировать её в 32-битный исполнительный файл, то получится следующее:
1 2 3 4 5 6 7 8 9 10 11 12 |
$ gcc -m32 size_of_types.c $ ./a.out Bitness: 32 char *a: 4 long *b: 4 int *c: 4 void *d: 4 char e: 1 long f: 4 int g: 4 |
Как видно, различия в размере указателей и в размере long. Код Spout, достаточно часто прибегающий к использованию адресной арифметики, на 64-битной системе будет работать совсем не так, как ожидал программист. К примеру, упомянутый выше код отрисовки содержимого буферов будет выполняться некорректно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ unsigned long *pL, *pL2, *pLe; pL = (unsigned long *) (vbuff + 2 * 128); pL2 = (unsigned long *) (vbuff2 + dispPos * 128); pLe = pL2 + 128 * 78 / 4; if (pLe > (unsigned long *) (vbuff2 + 128 * 128)) { pLe = (unsigned long *) (vbuff2 + 128 * 128); } while (pL2 < pLe) { *pL = *pL2 & 0x03030303; pL++; pL2++; } pL2 = (unsigned long *) (vbuff2); while (pL < (unsigned long *) (vbuff + 128 * (78 + 2))) { *pL = *pL2 & 0x03030303; pL++; pL2++; } } |
При выполнении операции инкрементирования указателей 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-поверхностей, например, спрайтов. Компилируется и запускается пример следующим образом:
1 2 |
$ gcc testgl.c -DHAVE_OPENGL `sdl-config --cflags` `sdl-config --libs` -lGL $ ./a.out -logo -slow |
Параметр -logo говорит приложению о том, чтобы помимо вращающегося 3D-куба на экране должен быть отображён и 2D-спрайт. SDL версия Spout для вывода картинки использует специальную поверхность SDL_Surface, а в этом примере SDL_Surface конвертируется в OpenGL-текстуру, которую потом можно будет вывести на OpenGL-контекст и использовать в своих целях. Именно так я и поступил: первая версия Spout, в которой использовался для отображения OpenGL, использовала функцию SDL_GL_LoadTexture() из примера testgl.c; вот исходный код этой функции и вспомогательной функции power_of_two():
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 |
GLuint SDL_GL_LoadTexture(SDL_Surface *surface, GLfloat *texcoord) { GLuint texture; int w, h; SDL_Surface *image; SDL_Rect area; Uint32 saved_flags; Uint8 saved_alpha; /* Use the surface width and height expanded to powers of 2 */ w = power_of_two(surface->w); h = power_of_two(surface->h); texcoord[0] = 0.0f; /* Min X */ texcoord[1] = 0.0f; /* Min Y */ texcoord[2] = (GLfloat)surface->w / w; /* Max X */ texcoord[3] = (GLfloat)surface->h / h; /* Max Y */ image = SDL_CreateRGBSurface( SDL_SWSURFACE, w, h, 32, #if SDL_BYTEORDER == SDL_LIL_ENDIAN /* OpenGL RGBA masks */ 0x000000FF, 0x0000FF00, 0x00FF0000, 0xFF000000 #else 0xFF000000, 0x00FF0000, 0x0000FF00, 0x000000FF #endif ); if ( image == NULL ) { return 0; } /* Save the alpha blending attributes */ saved_flags = surface->flags&(SDL_SRCALPHA|SDL_RLEACCELOK); saved_alpha = surface->format->alpha; if ( (saved_flags & SDL_SRCALPHA) == SDL_SRCALPHA ) { SDL_SetAlpha(surface, 0, 0); } /* Copy the surface into the GL texture image */ area.x = 0; area.y = 0; area.w = surface->w; area.h = surface->h; SDL_BlitSurface(surface, &area, image, &area); /* Restore the alpha blending attributes */ if ( (saved_flags & SDL_SRCALPHA) == SDL_SRCALPHA ) { SDL_SetAlpha(surface, saved_flags, saved_alpha); } /* Create an OpenGL texture for the image */ glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); SDL_FreeSurface(image); /* No longer needed */ return texture; } /* Quick utility function for texture creation */ static int power_of_two(int input) { int value = 1; while ( value < input ) { value <<= 1; } return value; } |
Смысл подобных действий состоял в том, чтобы отрисовать 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-битный буфер для этой цели мне подойдёт больше.
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 |
unsigned char pixelData[TEXTURE_WIDTH * TEXTURE_HEIGHT]; unsigned short testP[TEXTURE_WIDTH * TEXTURE_HEIGHT]; void pceLCDTrans() { ... // Convert buffer to RGB565 int rz; for (rz = 0; rz < TEXTURE_HEIGHT * TEXTURE_WIDTH; ++rz) { switch (pixelData[rz]) { case 0x1: testP[rz] = 0xAD55; // Gray #1 break; case 0x2: testP[rz] = 0x52AA; // Gray #2 break; case 0x3: testP[rz] = 0x0000; // Black 00000 000000 00000 break; case 0x0: testP[rz] = 0xFFFF; // White 11111 111111 11111 break; } } ... } |
Итак, рядом с буфером 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 теперь не используется, а текстуру не нужно постоянно создавать и удалять. Получившийся рабочий код выглядит следующим образом:
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 174 175 176 177 178 179 |
SDL_Surface *video; static GLuint global_texture = 0; static GLfloat texcoord[4]; int display_w = 640; int display_h = 480; int x_off = 20; int y_off = 20; int fullscreen = 0; #define TEXTURE_WIDTH (128) #define TEXTURE_HEIGHT (88) void initSDL () { ... Uint32 flags = SDL_OPENGL | SDL_SWSURFACE; if (fullscreen) { flags |= SDL_FULLSCREEN; } video = SDL_SetVideoMode (display_w, display_h, 16, flags); if (video == NULL) { fprintf (stderr, "Couldn't set video mode: %s\n", SDL_GetError ()); exit (1); } /* OpenGL Init */ SDL_GL_SetAttribute( SDL_GL_RED_SIZE, 3 ); SDL_GL_SetAttribute( SDL_GL_GREEN_SIZE, 3 ); SDL_GL_SetAttribute( SDL_GL_BLUE_SIZE, 2 ); SDL_GL_SetAttribute( SDL_GL_DEPTH_SIZE, 16 ); SDL_GL_SetAttribute( SDL_GL_DOUBLEBUFFER, 1 ); SDL_GL_SetAttribute( SDL_GL_SWAP_CONTROL, 0 ); // ? glClearColor( 255.0, 255.0, 255.0, 1.0 ); glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); if (!global_texture) { global_texture = SDL_GL_LoadTexture_fromPixelData(TEXTURE_WIDTH, TEXTURE_HEIGHT, texcoord, video->pixels); } } GLuint SDL_GL_LoadTexture_fromPixelData (int w, int h, GLfloat *texcoord, void *pixels) { GLuint texture; texcoord[0] = 0.0f; /* Min X */ texcoord[1] = 0.0f; /* Min Y */ texcoord[2] = 1.0f; /* Max X */ texcoord[3] = 1.0f; /* Max Y */ glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels); return texture; } void pceLCDTrans () { static int w, h; int x, y; unsigned char *vbi, *bi; unsigned char *bline; const int zoom = 1; const int offsetx = TEXTURE_WIDTH/2 - 128*zoom/2; const int offsety = TEXTURE_HEIGHT/2 - 88*zoom/2; bi = pixelData; w = TEXTURE_WIDTH; h = TEXTURE_HEIGHT; for (y = 0; y < (88*zoom); y++) { vbi = vBuffer + (y/zoom) * 128; //the actual line on the pce internal buffer (128x88) bline = bi + TEXTURE_WIDTH * (y + offsety); bline += offsetx; for (x = 0; x < (128*zoom); x++) { *bline++ = *(vbi + x/zoom); } } // Convert buffer to RGB565 int rz; for (rz = 0; rz < TEXTURE_HEIGHT * TEXTURE_WIDTH; ++rz) { switch (pixelData[rz]) { case 0x1: testP[rz] = 0xAD55; // Gray #1 break; case 0x2: testP[rz] = 0x52AA; // Gray #2 break; case 0x3: testP[rz] = 0x0000; // Black 00000 000000 00000 break; case 0x0: testP[rz] = 0xFFFF; // White 11111 111111 11111 break; } } glClearColor( 255.0, 255.0, 255.0, 1.0 ); glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); SDL_GL_Enter2DMode(); GLfloat texMinX = texcoord[0]; GLfloat texMinY = texcoord[1]; GLfloat texMaxX = texcoord[2]; GLfloat texMaxY = texcoord[3]; int x_coord = 0; int y_coord = 0; glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, testP); glBegin(GL_TRIANGLE_STRIP); glTexCoord2f(texMinX, texMinY); glVertex2i(x_coord + x_off, y_coord + y_off ); glTexCoord2f(texMaxX, texMinY); glVertex2i(x_coord + display_w - x_off, y_coord + y_off ); glTexCoord2f(texMinX, texMaxY); glVertex2i(x_coord + x_off, y_coord + display_h - y_off); glTexCoord2f(texMaxX, texMaxY); glVertex2i(x_coord + display_w - x_off, y_coord + display_h - y_off); glEnd(); SDL_GL_Leave2DMode(); SDL_GL_SwapBuffers(); } void SDL_GL_Enter2DMode () { SDL_Surface *screen = SDL_GetVideoSurface(); glPushAttrib(GL_ENABLE_BIT); glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); glEnable(GL_TEXTURE_2D); glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity(); glOrtho(0.0, (GLdouble)screen->w, (GLdouble)screen->h, 0.0, 0.0, 1.0); glMatrixMode(GL_MODELVIEW); glPushMatrix(); glLoadIdentity(); glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_DECAL); } void SDL_GL_Leave2DMode () { glMatrixMode(GL_MODELVIEW); glPopMatrix(); glMatrixMode(GL_PROJECTION); glPopMatrix(); glPopAttrib(); } int main (/*int argc, char *argv[]*/) { ... initSDL (); pceAppInit (); // Enter to Main Loop ... pceAppExit (); if (global_texture) { glDeleteTextures(1, &global_texture); global_texture = 0; } return 0; } |
Рассмотрим механизм работы. Сразу после запуска игры вызывается функция 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 строку:
1 |
android.library.reference.1=../appcompat_v7 |
Затем удалить файл 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 отсутствует, то он будет создан автоматически.
Окончательная картина созданного окружения выглядела следующим образом:
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 |
$ tree . ├── AndroidManifest.xml ├── ic_launcher-web.png ├── jni │ ├── Android.mk │ ├── Spout │ │ ├── docs │ │ │ ├── README │ │ │ ├── README.GP2X │ │ │ └── readme.pandora.txt │ │ ├── icons │ │ │ ├── Spout_out_icon.png │ │ │ └── spout.png │ │ ├── others │ │ │ ├── createPND.sh │ │ │ └── PXML.xml │ │ ├── projects │ │ │ ├── ReadMe.txt │ │ │ ├── spout.cbp │ │ │ ├── spout.layout │ │ │ ├── Spout.pro │ │ │ ├── spoutwin32.cbp │ │ │ └── spoutwin32.layout │ │ └── src │ │ ├── config.h │ │ ├── font.h │ │ ├── piece.c │ │ ├── piece.h │ │ ├── sintable.h │ │ ├── spout.c │ │ └── spout.h │ ├── SpoutNativeLibProxy.cpp │ └── SpoutNativeLibProxy.h ├── proguard-project.txt ├── project.properties ├── res │ ├── drawable-hdpi │ │ └── ic_launcher.png │ ├── drawable-mdpi │ │ └── ic_launcher.png │ ├── drawable-xhdpi │ │ └── ic_launcher.png │ ├── drawable-xxhdpi │ │ └── ic_launcher.png │ ├── layout │ │ └── main.xml │ ├── values │ │ ├── strings.xml │ │ └── style.xml │ ├── values-v11 │ │ └── style.xml │ └── values-v14 │ └── style.xml └── src └── ru └── exlmoto └── spout ├── SpoutActivity.java ├── SpoutNativeLibProxy.java └── SpoutNativeSurface.java |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) DEF := -DANDROID_NDK LOCAL_CFLAGS += -O3 -ffast-math -fomit-frame-pointer $(DEF) LOCAL_CPPFLAGS += -O3 -frtti -ffast-math -fomit-frame-pointer $(DEF) LOCAL_C_INCLUDES := $(LOCAL_PATH)/Spout/src LOCAL_CPP_INCLUDES := $(LOCAL_C_INCLUDES) LOCAL_MODULE := Spout LOCAL_SRC_FILES := SpoutNativeLibProxy.cpp Spout/src/spout.c Spout/src/piece.c LOCAL_LDLIBS += -llog -lGLESv1_CM include $(BUILD_SHARED_LIBRARY) |
Обратите внимание на линкуемые системные библиотеки liblog и libGLESv1_CM. Первая необходима для работы различных DEBUG-макросов, выводящих сообщения в системный лог, ну а вторая, собственно, отвечает за реализацию OpenGL ES. Помимо этого файла я так же добавил файл Application.mk, в котором прописана рекомендуемая сборочная платформа в NDK и то, под какие архитектуры следует собирать приложение:
1 2 |
APP_ABI := all APP_PLATFORM := android-9 |
Значение переменной APP_ABI := all говорит о том, что код Spout будет скомпилирован под все доступные архитектуры в Android NDK. Их всего семь штук:
- arm64-v8a;
- x86_64;
- mips64;
- armeabi-v7a;
- armeabi;
- x86;
- mips.
Из файла piece.c, который содержал SDL-обвязку движка, я аккуратно «откусил» всё связанное с SDL c помощью директив препроцессора. В этом же файле было решено также реализовать обвязку OpenGL ES для Android. Но об этом будет ниже. Пока мне нужно было лишь добиться успешной сборки библиотеки. Закончив разбираться с разными ошибками компиляции, я получил вожделенную libSpout.so, которую теперь следовало ловко подцепить с помощью механизма JNI, чтобы иметь возможность воздействовать на библиотеку из Java-кода и обмениваться с ней информацией.
Для этого вернёмся к классу SpoutNativeLibProxy на Java, в котором я подгружаю при запуске приложения библиотеку libSpout.so (пишется только имя библиотеки) и определяю кучу необходимых мне в будущем методов:
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 |
package ru.exlmoto.spout; public class SpoutNativeLibProxy { // Load native library static { System.loadLibrary("Spout"); } public native static void SpoutNativeInit(); public native static void SpoutNativeDeinit(); public native static void SpoutNativeSurfaceChanged(int width, int height); public native static void SpoutNativeDraw(); public native static void SpoutNativeKeyDown(int keyCode); public native static void SpoutNativeKeyUp(int keyCode); public native static void SpoutFilter(boolean filterGLES); public native static void SpoutNativePushScore(int height, int score); public native static boolean SpoutVibrate(); public native static void SpoutSetSound(boolean sound); public native static void SpoutSetColor(boolean color); public native static void SpoutSetTail(boolean tail); public native static void SpoutSet3DCube(boolean cube); public native static int SpoutGetScoreScores(); public native static int SpoutGetScoreHeight(); public native static void SpoutDisplayOffsetX(int offset_x); public native static void SpoutDisplayOffsetY(int offset_y); public native static void SpoutInitilizeGlobalJavaEnvPointer(); } |
Все методы с идентификатором native используются внутри Java-обёртки, и вызывают соответствующие им функции из нативной библиотеки, которых в libSpout.so пока ещё нет. В их реализации нам поможет замечательная утилита javah, идущая в комплекте с JDK. Вызов утилиты с необходимыми параметрами сгенерирует нам специальный заголовочный файл:
1 |
$ javah -jni -d jni -classpath src ru.exlmoto.spout.SpoutNativeLibProxy |
В результате выполнения команды создался файл ru_exlmoto_spout_SpoutNativeLibProxy.h, содержащий монструозные объявления функций, построенных по классу SpoutNativeLibProxy:
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 |
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class ru_exlmoto_spout_SpoutNativeLibProxy */ #ifndef _Included_ru_exlmoto_spout_SpoutNativeLibProxy #define _Included_ru_exlmoto_spout_SpoutNativeLibProxy #ifdef __cplusplus extern "C" { #endif /* * Class: ru_exlmoto_spout_SpoutNativeLibProxy * Method: SpoutNativeInit * Signature: ()V */ JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeInit (JNIEnv *, jclass); /* * Class: ru_exlmoto_spout_SpoutNativeLibProxy * Method: SpoutNativeDeinit * Signature: ()V */ JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeDeinit (JNIEnv *, jclass); /* * Class: ru_exlmoto_spout_SpoutNativeLibProxy * Method: SpoutGetScoreScores * Signature: ()I */ JNIEXPORT jint JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutGetScoreScores (JNIEnv *, jclass); ... #ifdef __cplusplus } #endif #endif |
В принципе, такой заголовочный файл можно было бы написать и руками, но javah здорово сэкономил мне время. Осталось реализовать эти функции, для этой цели я и создавал SpoutNativeLibProxy.cpp. С помощью директивы препроцессора #include подключим ru_exlmoto_spout_SpoutNativeLibProxy.h и реализуем необходимое:
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 |
#include "ru_exlmoto_spout_SpoutNativeLibProxy.h" // JNI header extern "C" { #include "piece.h" #include "SpoutNativeLibProxy.h" JNIEnv *javaEnviron = NULL; } // end extern "C" static int appRunning = 0; // Init JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeInit (JNIEnv *env, jclass c) { if (appRunning == 0) { LOGI("Spout_Native_Init..."); initSpoutGLES(); appRunning = 1; } } // Deinit JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeDeinit (JNIEnv *env, jclass c) { if (appRunning == 1) { LOGI("Spout_Native_Deinit..."); deinitSpoutGLES(); appRunning = 0; } } // Surface Changed JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeSurfaceChanged (JNIEnv *env, jclass c, jint width, jint height) { if (appRunning == 1) { resizeSpoutGLES(width, height); } } // Draw JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeDraw (JNIEnv *env, jclass c) { if (appRunning == 1) { stepSpoutGLES(); } } // KeyDown JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeKeyDown (JNIEnv *env, jclass c, jint keyCode) { keysState[keyCode] = 1; } // KeyUp JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeKeyUp (JNIEnv *env, jclass c, jint keyCode) { keysState[keyCode] = 0; } ... |
Как видно, одни функции изменяют переменные движка, а другие вызывают его внутренние части обвязки piece.c. К примеру, функция SpoutNativeInit() проинициализирует игровой движок и создаст OpenGL ES контекст, а SpoutNativeDraw() отрисует кадр. В качестве вспомогательного заголовочного файла был создан SpoutNativeLibProxy.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <android/log.h> #include <jni.h> #define LOG_TAG "Spout_App" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO,LOG_TAG,__VA_ARGS__) extern unsigned char keysState[]; extern int interval; extern int filter; extern int cube_on; extern int sound_on; extern int color_on; extern int tail_on; extern int dis_x; extern int dis_y; extern int score_height; extern int score_score; extern JNIEnv *javaEnviron; |
В нём определены некоторые вспомогательные макросы, массивы и переменные-параметры, изменяемые из Java-обёртки. Этот заголовочный файл был подключён к SpoutNativeLibProxy.cpp, piece.c и spout.c. Кстати, обратите внимание, что хедеры, используемые движком, в файле SpoutNativeLibProxy.cpp обёрнуты в extern «C».
При завершении работы приложения библиотека может остаться в памяти и сохранить своё состояние, поэтому в Activity-классе SpoutActivity необходимо явно выйти из приложения и выгрузить библиотеку:
1 2 3 4 5 6 7 8 9 10 11 12 |
private SpoutNativeSurface m_spoutNativeSurface; @Override public void onBackPressed() { toDebug("Back key pressed!, Exiting..."); m_spoutNativeSurface.onPause(); m_spoutNativeSurface.onClose(); // Because we want drop all memory of library System.exit(0); } |
Теперь, когда обмен информацией с библиотекой libSpout.so налажен с помощью механизма JNI, можно перейти к непосредственной отрисовке буфера игры на экран устройства.
6. Работа с OpenGL ES из Java-кода и из C-кода
Как было сказано выше, класс SpoutNativeSurface был отнаследован от системного GLSurfaceView и в нём же был реализован интерфейс GLSurfaceView.Renderer. Именно этот класс и будет заниматься выводом графики на экран устройства. Рассмотрим конструктор класса:
1 2 3 4 5 6 7 8 9 10 11 12 |
public SpoutNativeSurface(Context context) { super(context); setRenderer(this); // We wants events setFocusable(true); setFocusableInTouchMode(true); requestFocus(); // We wants keep on screen setKeepScreenOn(true); } |
Сначала в нём устанавливается рендеринг «на самого себя», затем устанавливается фокус для получения различных событий, вроде нажатий на сенсорный экран. Функция setKeepScreenOn() запрещает устройству гасить подсветку экрана и переходить в режим ожидания. Основные перегруженные методы у класса SpoutNativeSurface следующие:
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 |
private static final int FPS_RATE = 60; private long m_lastFrame = 0; @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { SpoutActivity.toDebug("== GL_SURFACE CREATED =="); SpoutNativeLibProxy.SpoutInitilizeGlobalJavaEnvPointer(); SpoutNativeLibProxy.SpoutNativeInit(); } public void onClose() { SpoutActivity.toDebug("== GL_SURFACE CLOSE =="); SpoutNativeLibProxy.SpoutNativeDeinit(); } @Override public void onDrawFrame(GL10 gl) { long currentFrame = SystemClock.uptimeMillis(); long diff = currentFrame - m_lastFrame; m_lastFrame = currentFrame; SpoutNativeLibProxy.SpoutNativeDraw(); try { long sleepfor = (1000 / FPS_RATE) - diff; if (sleepfor > 0) { // SpoutActivity.toDebug("Sleep now: " + sleepfor); Thread.sleep(sleepfor); } } catch (InterruptedException ex) { } } @Override public void onSurfaceChanged(GL10 gl, int width, int height) { SpoutNativeLibProxy.SpoutNativeSurfaceChanged(width, height); } |
Работает всё это следующем образом: когда запускается приложение, первым делом вызывается метод onSurfaceCreated(), который инициализирует движок и контекст OpenGL ES, потом вызывается метод onSurfaceChanged(), в котором в библиотеку передаётся необходимый размер экрана и контекст OpenGL ES переинициализируется снова, подстроившись под необходимое разрешение. И лишь затем получает управление метод onDrawFrame(), в котором встроен ограничитель FPS на 60 кадров в секунду. Именно onDrawFrame() и начинает рисовать игру на экране, постоянно вызывая нативный метод SpoutNativeDraw() из класса SpoutNativeLibProxy, управление которого передаётся в нативную библиотеку.
Перегруженный метод onCreate() класса SpoutActivity выглядит следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
private SpoutNativeSurface m_spoutNativeSurface; @Override protected void onCreate(Bundle savedInstanceState) { /* We like to be fullscreen */ getWindow().setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); requestWindowFeature(Window.FEATURE_NO_TITLE); super.onCreate(savedInstanceState); m_spoutNativeSurface = new SpoutNativeSurface(this); setContentView(m_spoutNativeSurface); } |
В нём я, во-первых, настраиваю показ окна в полный экран и без заголовка, а, во-вторых, устанавливаю источником видимого контента объект класса SpoutNativeSurface, в котором как раз производится обработка и отображение игровых кадров в виде контекста OpenGL ES.
На этом с Java-обёрткой закончено, вернёмся в файл piece.c, в новую обвязку движка. Благодаря тому, что в версии Spout для GNU/Linux я использовал OpenGL, перенос кода на OpenGL ES оказался не слишком трудным, пришлось выкинуть некоторые недоступные функции и заменить их аналогами. Единственное, с чем пришлось долго сражаться, это белый экран. Поскольку для отладки я использовал устройство в котором ускоритель графики был от фирмы PowerVR, размеры текстуры должны были быть степенью двойки, только в таком случае текстура будет отображена. Мне пришлось изменить размер текстуры с 128×88 на 256×128 и немного поправить текстурные координаты, чтобы растянуть контекст.
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 |
#define IN_SCREEN_WIDTH (128) #define IN_SCREEN_HEIGHT (88) #define TEXTURE_WIDTH 256 #define TEXTURE_HEIGHT 128 #define S_PIXELS_SIZE (sizeof(texture_map[0]) * TEXTURE_WIDTH * TEXTURE_HEIGHT) static uint16_t *texture_map = 0; unsigned char pixelData[IN_SCREEN_WIDTH * IN_SCREEN_HEIGHT]; unsigned short pixelDataRGB565[IN_SCREEN_WIDTH * IN_SCREEN_HEIGHT]; int filter = GL_NEAREST; void initSpoutGLES() { pceAppInit (); texture_map = malloc(S_PIXELS_SIZE); resizeSpoutGLES(display_w, display_h); } static void render_pixels(uint16_t *pixels, uint16_t *from_pixels) { int x, y; for (y = s_y; y < s_y + IN_SCREEN_HEIGHT; y++) { for (x = s_x; x < s_x + IN_SCREEN_WIDTH; x++) { int idx = x + y * TEXTURE_WIDTH; int ry = y - s_y; int rx = x - s_x; int idx2 = rx + ry * IN_SCREEN_WIDTH; pixels[idx++] = from_pixels[idx2]; } } } void deinitSpoutGLES() { if (texture_map) { LOGI("Free texture_map..."); free(texture_map); } } void resizeSpoutGLES(int w, int h) { LOGI("Resize Spout GLES: %d, %d", w, h); glDeleteTextures(1, &global_texture); glEnable(GL_TEXTURE_2D); glGenTextures(1, &global_texture); glBindTexture(GL_TEXTURE_2D, global_texture); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); glShadeModel(GL_FLAT); check_gl_error("glShadeModel"); glColor4x(0x10000, 0x10000, 0x10000, 0x10000); check_gl_error("glColor4x"); int rect[4] = { 0, IN_SCREEN_HEIGHT, IN_SCREEN_WIDTH, -IN_SCREEN_HEIGHT }; glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_CROP_RECT_OES, rect); check_gl_error("glTexParameteriv"); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, TEXTURE_WIDTH, TEXTURE_HEIGHT, 0, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, NULL); check_gl_error("glTexImage2D"); glClearColor(0.0f, 0.0f, 0.0f, 0.0f); check_gl_error("glClearColor"); display_w = w; display_h = h; } void pceLCDTrans () { ... memset(texture_map, 0, S_PIXELS_SIZE); render_pixels(texture_map, pixelDataRGB565); glClear(GL_COLOR_BUFFER_BIT); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, texture_map); check_gl_error("glTexSubImage2D"); glDrawTexiOES(dis_x, dis_y, 0, display_w - dis_x * 2, display_h - dis_y * 2); check_gl_error("glDrawTexiOES"); } void stepSpoutGLES() { pceAppProc(); pceLCDTrans(); } |
В функции 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! отправить их в движок, а затем запустить игру на исполнение. Описание у параметров следующее:
- Apply Filter (Smooth): применить GL_LINEAR фильтр к отображаемой текстуре;
- 3D Cube: включить демонстрационный 3D-режим;
- Accelerometer: включить управление акселерометром;
- Strong Buttons: включить хорошо видимые на экране кнопки (иначе — прозрачные);
- Disable Buttons: отключить сенсорное управление посредством кнопок, использовать части экрана;
- Screen Offset ‘x’, ‘y’: установить текстурные отступы;
- Aspect Ratio: соблюдать на текстуре соотношение размеров;
- Fullscreen: отображение текстуры на весь экран;
- Colorize: раскрасить логотип игры и выхлоп кораблика;
- Long Tail: длинный хвост-прицел;
- Sound: включить звуковые эффекты;
- Vibro: использовать виброотдачу.
Большинство этих опций будет упомянуто ниже. Некоторые из них используются внутри самого SpoutLauncher, другие пробрасываются в SpoutActivity или SpoutNativeSurface и движок игры.
В библиотеку из Launcher’а можно пробросить произвольные текстурные отступы, к примеру, чтобы растянуть текстуру на весь экран, отступы должны быть установлены в x = 0 и y = 0. Именно этим и занимается параметр Fullscreen. Опция Aspect Ratio тоже устанавливает отступы, но она должна их расчитать в зависимости от разрешения экрана Android-устройства:
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 |
private static final int originalWidth = 128; private static final int originalHeight = 88; private int getAspectSideScaler(int original, int display) { int i = 1; int o = original; for (; display > o; ++i, o = original * i); return (i - 1); } private int min(int a, int b) { return (a > b) ? b : a; } private void getAspectRatioOffsets(int originalW, int originalH, int displayW, int displayH) { int wS = getAspectSideScaler(originalW, displayW); int hS = getAspectSideScaler(originalH, displayH); int scaler = min(wS, hS); int scaledWidth = originalWidth * scaler; int scaledHeight = originalHeight * scaler; SpoutSettings.s_OffsetX = (displayW - scaledWidth) / 2; SpoutSettings.s_OffsetY = (displayH - scaledHeight) / 2; } private boolean testOffsets(int offsetX, int offsetY, int displayWidth, int displayHeight) { int result = 0; max_offX = (displayWidth / 2) - (originalWidth / 2); max_offY = (displayHeight / 2) - (originalHeight / 2); if (offsetX > max_offX) { result++; } if (offsetY > max_offY) { result++; } return (result == 0); } |
Результат работы опции Aspect Ratio.
Алгоритм следующий: увеличиваем каждую из сторон оригинального размера экрана P/ECE на себя саму до тех пор, пока не вылетим за границы экрана на Android-устройстве. После чего берём наименьший корректный множитель, на который умножаем обе стороны оригинального размера (128×88) и получаем необходимое разрешение из которого вычисляем требуемые отступы. К примеру, для Android-устройства с разрешением экрана 960×540 это будет x = 96 и y = 6. Следует заметить, что диапазон значений я ограничил таким образом, что максимальное значение будет давать оригинальный размер экрана P/ECE.
Некоторые параметры исключают использование других опций, поэтому они будут отключены при их включении. Для реализации такого поведения применяются специальные обработчики-Listener’ы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Override public void onCreate(Bundle savedInstanceState) { ... Button button = (Button)findViewById(R.id.buttonRunSpout); button.requestFocus(); button.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (testOffsets(SpoutSettings.s_OffsetX, SpoutSettings.s_OffsetY, displayW, displayH)) { Intent intent = new Intent(v.getContext(), SpoutActivity.class); startActivity(intent); } else { showDialog(OFFSET_ERROR); } } }); } |
Их методы вызываются при различных событиях. Например, при нажатии кнопки (см. код выше) будет запущен Activity-класс SpoutActivity. Завершиться он должен именно выходом с помощью System.exit(0), иначе библиотека останется в памяти и при следующем запуске Spout нас могут поджидать неприятности.
7.1. Сохранение параметров с помощью класса SharedPreferences
После создания Launcher’а было бы удобно сохранять выставленные параметры, чтобы после перезапуска приложения не задавать их снова. Для этой цели нам как нельзя кстати пригодится системный класс SharedPreferences, сохраняющий настройки приложения в специальной системной RW-области в виде XML-файла.
Механизм работы прост: сразу после запуска игры расставляем все сохранённые опции на форму, а после завершения работы приложения собираем параметры с формы. Для этого в классе SpoutLauncher были созданы методы writeSettings() и readSettings():
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 |
private void readSettings() { ... SpoutSettings.s_AspectRatio = settings.getBoolean("s_AspectRatio", SpoutSettings.s_AspectRatio); SpoutSettings.s_Fullscreen = settings.getBoolean("s_Fullscreen", SpoutSettings.s_Fullscreen); SpoutSettings.s_OffsetX = settings.getInt("s_OffsetX", SpoutSettings.s_OffsetX); SpoutSettings.s_OffsetY = settings.getInt("s_OffsetY", SpoutSettings.s_OffsetY); } private void writeSettings() { fillSettingsByLayout(); SharedPreferences.Editor editor = settings.edit(); editor.putBoolean("s_AspectRatio", SpoutSettings.s_AspectRatio); editor.putBoolean("s_Fullscreen", SpoutSettings.s_Fullscreen); if (testOffsets(SpoutSettings.s_OffsetX, SpoutSettings.s_OffsetY, displayW, displayH)) { editor.putInt("s_OffsetX", SpoutSettings.s_OffsetX); editor.putInt("s_OffsetY", SpoutSettings.s_OffsetY); } else { SpoutActivity.toDebug("Error: what's wrong! oX: " + SpoutSettings.s_OffsetX + " oY: " + SpoutSettings.s_OffsetY); } ... editor.commit(); } |
Проверить, запущено ли приложение в первый раз или уже запускалось, можно таким образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Override public void onCreate(Bundle savedInstanceState) { ... settings = getSharedPreferences("ru.exlmoto.spout", MODE_PRIVATE); // Check the first run if (settings.getBoolean("firstrun", true)) { // The first run, fill GUI layout with default values settings.edit().putBoolean("firstrun", false).commit(); } else { // Read settings from Shared Preferences readSettings(); } } |
Помимо опций на форме Launcher’а я решил сохранять с помощью класса SharedPreferences и игровой счёт. Здесь точно так же, как в случае с формой: при запуске игры сохранённые значения отправляются в движок, а после выхода из игры они обновляются, если набранный ранее рекорд был побит. Метод сохранения рекорда реализован в классе SpoutActivity:
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 |
// JNI-method public static void setScores(int scoresH, int scoresS) { SpoutActivity.toDebug("--- From JNI!, a1: " + scoresH + " a2: " + scoresS); SpoutSettings.s_scoreHeight = scoresH; SpoutSettings.s_scoreScore = scoresS; } private void writeScoresToSharedPreferences() { int scoreS = SpoutNativeLibProxy.SpoutGetScoreScores(); int scoreH = SpoutNativeLibProxy.SpoutGetScoreHeight(); if (scoreS > SpoutSettings.s_scoreScore) { SpoutSettings.s_scoreScore = scoreS; SpoutSettings.s_scoreHeight = scoreH; toDebug("Update scores!"); } SharedPreferences settings = getSharedPreferences("ru.exlmoto.spout", MODE_PRIVATE); SharedPreferences.Editor editor = settings.edit(); editor.putInt("s_scoreHeight", SpoutSettings.s_scoreHeight); editor.putInt("s_scoreScore", SpoutSettings.s_scoreScore); editor.commit(); toDebug("write scores... " + SpoutSettings.s_scoreHeight + " " + SpoutSettings.s_scoreScore); } |
JNI является интерфейсом работающим в обе стороны. Это значит, что не только из Java-кода можно вызывать функции нативных библиотек, но и из библиотек можно вызывать методы Java-классов. Благодаря возможности проброса указателя на окружение Java в движок игры Spout, я могу вызвать из файла piece.c Java-метод setScores() класса SpoutActivity следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
void pceFileWriteSct (const void *ptr, /*int sct,*/ int len) { const int *hiScore = (const int *)(ptr); if (javaEnviron != NULL) { // Set high-scores to JAVA-launcher/activity jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/spout/SpoutActivity"); if (clazz == 0) { LOGI("Error JNI: Class ru/exlmoto/spout/SpoutActivity not found!"); } jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "setScores", "(II)V"); if (methodId == 0) { LOGI("Error JNI: methodId is 0, method setScores (II)V not found!"); } // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, hiScore[1], hiScore[0]); } } |
Проброс указателя и функции получения текущего состояния счёта реализованы в файле SpoutNativeLibProxy.cpp:
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 |
extern "C" { JNIEnv *javaEnviron = NULL; } // end extern "C" // Push Score JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativePushScore (JNIEnv *env, jclass c, jint scoreHeight, jint scoreScore) { score_score = scoreScore; score_height = scoreHeight; } // Get ScoreScores JNIEXPORT jint JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutGetScoreScores (JNIEnv *env, jclass c) { return (jint)getScoreScores(); } // Get ScoreHeight JNIEXPORT jint JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutGetScoreHeight (JNIEnv *env, jclass c) { return (jint)getScoreHeight(); } // Initialize Pointer JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutInitilizeGlobalJavaEnvPointer (JNIEnv *env, jclass c) { LOGI("Initialize pointer..."); if (!javaEnviron) { javaEnviron = env; } } |
Указатель javaEnviron объявлен как extern в хедере SpoutNativeLibProxy.h, который подключён и к piece.c.
Для чего такие сложности?! Тут есть маленькая хитрость: движок игры отправляет набранные очки на сохранение лишь тогда, когда это необходимо. В первой версии работы со счётом у меня выявился небольшой баг: если получить Game Over с рекордом и нажать выход, очки не сохраняются. Поэтому при завершении работы Activity я получаю актуальное состояние счёта и сравниваю его с тем, что мне отправил ранее движок. Если мои актуальные значения больше, то я сохраняю их, а если меньше или равны, то я записываю те, которые получил от Spout. Таким образом удалось исправить эту ситуацию.
7.2. Передача параметров в С-движок игры из Launcher’a
Чтение сохранённых настроек приложения осуществляется в специальный вложенный публичный статический класс SpoutSettings, являющийся подклассом SpoutLauncher:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class SpoutLauncher extends Activity { ... public static class SpoutSettings { public static boolean s_Filter = false; public static boolean s_CubeDemo = false; public static boolean s_ShowButtons = true; public static boolean s_DisableButtons = false; public static boolean s_Sensor = false; public static int s_OffsetX = 25; public static int s_OffsetY = 25; public static boolean s_AspectRatio = false; public static boolean s_Fullscreen = false; public static boolean s_Color = false; public static boolean s_Tail = false; public static boolean s_Sound = false; public static boolean s_Vibro = true; public static int s_scoreHeight = 0; public static int s_scoreScore = 0; } } |
Именно значениями этого класса заполняется форма Launcher’а и именно отсюда после нажатия кнопки Run Spout! большинство из них отправляются в движок из метода onSurfaceCreated() класса SpoutNativeSurface:
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 |
public class SpoutNativeSurface extends GLSurfaceView implements android.opengl.GLSurfaceView.Renderer { ... @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { SpoutActivity.toDebug("== GL_SURFACE CREATED =="); SpoutNativeLibProxy.SpoutInitilizeGlobalJavaEnvPointer(); SpoutNativeLibProxy.SpoutSetColor(SpoutLauncher.SpoutSettings.s_Color); SpoutNativeLibProxy.SpoutSetTail(SpoutLauncher.SpoutSettings.s_Tail); SpoutNativeLibProxy.SpoutSet3DCube(SpoutLauncher.SpoutSettings.s_CubeDemo); SpoutNativeLibProxy.SpoutNativePushScore(SpoutLauncher.SpoutSettings.s_scoreHeight, SpoutLauncher.SpoutSettings.s_scoreScore); SpoutNativeLibProxy.SpoutSetSound(SpoutLauncher.SpoutSettings.s_Sound); SpoutNativeLibProxy.SpoutNativeInit(); SpoutNativeLibProxy.SpoutFilter(SpoutLauncher.SpoutSettings.s_Filter); SpoutNativeLibProxy.SpoutDisplayOffsetX(SpoutLauncher.SpoutSettings.s_OffsetX); SpoutNativeLibProxy.SpoutDisplayOffsetY(SpoutLauncher.SpoutSettings.s_OffsetY); } } |
Сложный пример игрового счёта: очки читаются при запуске приложения в классе 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(), отлавливающие нужные мне события. В этом же классе было создано перечисление отправляемых в нативный код идентификаторов необходимых клавиш. Получилось следующее:
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 |
public static final int KEY_LEFT = 0x01; public static final int KEY_RIGHT = 0x02; private static final int KEY_UP = 0x03; private static final int KEY_DOWN = 0x04; public static final int KEY_FIRE = 0x05; ... @Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_A: case KeyEvent.KEYCODE_DPAD_LEFT: { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_LEFT); break; } case KeyEvent.KEYCODE_D: case KeyEvent.KEYCODE_DPAD_RIGHT: { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_RIGHT); break; } case KeyEvent.KEYCODE_W: case KeyEvent.KEYCODE_DPAD_UP: { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_UP); break; } case KeyEvent.KEYCODE_S: case KeyEvent.KEYCODE_DPAD_DOWN: { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_DOWN); break; } case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_SPACE: case KeyEvent.KEYCODE_E: case KeyEvent.KEYCODE_Z: case KeyEvent.KEYCODE_X: case KeyEvent.KEYCODE_C: case KeyEvent.KEYCODE_DPAD_CENTER: { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_FIRE); break; } } return super.onKeyDown(keyCode, event); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_A: case KeyEvent.KEYCODE_DPAD_LEFT: { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_LEFT); break; } case KeyEvent.KEYCODE_D: case KeyEvent.KEYCODE_DPAD_RIGHT: { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_RIGHT); break; } case KeyEvent.KEYCODE_W: case KeyEvent.KEYCODE_DPAD_UP: { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_UP); break; } case KeyEvent.KEYCODE_S: case KeyEvent.KEYCODE_DPAD_DOWN: { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_DOWN); break; } case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_SPACE: case KeyEvent.KEYCODE_E: case KeyEvent.KEYCODE_Z: case KeyEvent.KEYCODE_X: case KeyEvent.KEYCODE_C: case KeyEvent.KEYCODE_DPAD_CENTER: { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_FIRE); break; } } return super.onKeyUp(keyCode, event); } |
В зависимости от того, какая клавиша нажата или отжата, нативные методы SpoutNativeKeyDown и SpoutNativeKeyUp отправляют специальный идентификатор кнопки в Spout. Далее, в файле SpoutNativeLibProxy.cpp происходит обновление массива keysState, который отвечает за состояние необходимой части клавиатуры.
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 |
extern "C" { ... unsigned char keysState[] = { 0, 0, // KEY_LEFT 0, // KEY_RIGHT 0, // KEY_UP 0, // KEY_DOWN 0, // KEY_FIRE 0, // KEY_QUIT 0, // KEY_PAUSE 0 // KEY_UNKNOWN }; } // end extern "C" // KeyDown JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeKeyDown (JNIEnv *env, jclass c, jint keyCode) { keysState[keyCode] = 1; } // KeyUp JNIEXPORT void JNICALL Java_ru_exlmoto_spout_SpoutNativeLibProxy_SpoutNativeKeyUp (JNIEnv *env, jclass c, jint keyCode) { keysState[keyCode] = 0; } |
Индексом массива является идентификатор кнопки. Если она нажата, в keysState по индексу keyCode кладётся единичка, а если отжата — нолик. Массив keysState объявлен в заголовочном файле SpoutNativeLibProxy.h как внешний (extern), а потому он доступен и из piece.c — обвязки движка. Функция pcePadGet() была модифицирована следующим образом:
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 |
#define KEY_PRESSED 1 enum KeyCodes { KEY_LEFT = 0x01, KEY_RIGHT = 0x02, KEY_UP = 0x03, KEY_DOWN = 0x04, KEY_FIRE = 0x05, KEY_QUIT = 0x06, KEY_PAUSE = 0x07, KEY_UNKNOWN = 0x08 }; int pcePadGet () { static int pad = 0; int i = 0, op = pad & 0x00ff; int k[] = { KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_LEFT, KEY_RIGHT, KEY_FIRE, KEY_FIRE, KEY_FIRE, KEY_FIRE, KEY_PAUSE, KEY_QUIT, KEY_UNKNOWN, KEY_FIRE, KEY_FIRE }; int p[] = { PAD_UP, PAD_DN, PAD_LF, PAD_RI, PAD_LF, PAD_RI, PAD_A, PAD_B, PAD_A, PAD_B, PAD_C, PAD_D, PAD_D, -1 }; pad = 0; do { if (keys[k[i]] == KEY_PRESSED) { pad |= p[i]; } i++; } while (p[i] >= 0); pad |= (pad & (~op)) << 8; return pad; } |
Указатель keys после вызова функции инициализации начинает ссылаться на массив состояний keysState. Перечисление KeyCodes аналогично перечислению из Java-обёртки и создано для понимания происходящей ситуации.
На моё удивление, после внесённых модификаций в код, клавиатура заработала сразу правильным образом, именно так, как и должна была работать. Кораблик получил управление и я смог немного поиграть в Spout на своём Android-устройстве.
8.2. Наэкранное сенсорное управление
По-хорошему, создавая игровой проект для Android OS, о сенсорном управлении нужно думать заранее. Оно сразу должно поддерживаться движком без исключений. Но поскольку я просто переносил одну из любимых игр на своё Android-устройство, я решил реализовать несколько простейших вариантов такого управления и вынести их переключение в Launcher. Изначально я просто делил экран на три части, нажатия на которые обрабатывались точно так же, как нажатия на кнопки физической клавиатуры. Первая версия метода была реализована весьма грязно, поэтому я столкнулся с различными багами. Например, при свайпах, когда палец игрока переходит из одной части экрана в другую, нажатая клавиша не отжималась и кораблик продолжал выполнять действие.
Разделение сенсорного экрана устройства на функциональные части.
Я заметил, что когда «заедала» кнопка, отвечающая за выброс реактивной струи, управлять корабликом на сенсорном экране становилось значительно проще. Отпало требование постоянно держать нажатой клавишу огня, нужно было просто отклонять реактивный выброс частиц. Таким образом баг поспособствовал идее создать такую часть на экране, нажатие на которую включало бы автоматический огонь. Поэтому я решил разделить экран на четыре части (см. изображение выше) и закрепить за каждой из них следующие действия:
- Отклонение реактивной струи влево;
- Автоматический огонь;
- Огонь;
- Отклонение реактивной струи вправо.
Отловить события нажатий на сенсорный экран можно с помощью перегрузки метода onTouchEvent() в классе SpoutNativeSurface. Я исправил баги первой реализации и получил следующий код:
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 |
private boolean left_key_pressed = false; private boolean right_key_pressed = false; private boolean fire_key_pressed = false; private boolean fire_hold_key_pressed = false; @Override public boolean onTouchEvent(MotionEvent event) { if (SpoutLauncher.SpoutSettings.s_DisableButtons) { float x = event.getX(); float y = event.getY(); float half = getHeight() / 2.0f; boolean firstHalf = (y <= half); float chunk = getWidth() / 4.0f; boolean first = (x <= chunk); boolean second = ((x > chunk) && (x <= chunk * 3)); boolean third = ((x > chunk * 3) && (x <= chunk * 4)); if (event.getAction() == MotionEvent.ACTION_DOWN) { if (first) { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_LEFT); left_key_pressed = true; if (SpoutLauncher.SpoutSettings.s_Sound) { if (!SpoutLauncher.SpoutSettings.s_Sensor) { SpoutActivity.playSound(SpoutSounds.s_button); } } } else if (second) { if (!firstHalf) { // EMULATE FIRE BUTTON if (fire_hold_key_pressed) { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_FIRE); try { long sleepfor = 50; SpoutActivity.toDebug("Sleep now hack: " + sleepfor); Thread.sleep(sleepfor); SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_FIRE); } catch (InterruptedException ex) { } fire_hold_key_pressed = false; } else { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_FIRE); fire_key_pressed = true; } if (SpoutLauncher.SpoutSettings.s_Sound) { SpoutActivity.playSound(SpoutSounds.s_fire); } } else { // EMULATE HOLD FIRE BUTTON fire_hold_key_pressed = !fire_hold_key_pressed; if (fire_hold_key_pressed) { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_FIRE); } else { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_FIRE); } if (SpoutLauncher.SpoutSettings.s_Sound) { SpoutActivity.playSound(SpoutSounds.s_hold); } } } else if (third) { SpoutNativeLibProxy.SpoutNativeKeyDown(KEY_RIGHT); right_key_pressed = true; if (SpoutLauncher.SpoutSettings.s_Sound) { if (!SpoutLauncher.SpoutSettings.s_Sensor) { SpoutActivity.playSound(SpoutSounds.s_button); } } } } else if (event.getAction() == MotionEvent.ACTION_UP) { if (left_key_pressed) { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_LEFT); left_key_pressed = false; } else if (fire_key_pressed) { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_FIRE); fire_key_pressed = false; fire_hold_key_pressed = false; } else if (right_key_pressed) { SpoutNativeLibProxy.SpoutNativeKeyUp(KEY_RIGHT); right_key_pressed = false; } } } return true; } |
К сожалению, не всё оказалось гладко и из-за автоматического огня я был вынужден подпереть код костылём-задержкой, чтобы после Game Over не приходилось дважды нажимать на часть экрана, отвечающую за клавишу огня. Поскольку состояние этой кнопки в массиве так и остаётся нажатым.
Управление, реализованное выше, доступно при включенной опции Disable Buttons в Launcher’е. На таком управлении можно было бы и остановиться, но я заинтересовался возможностью отображения наэкранных кнопок. В основном все Android-игры, исходники которых я просматривал, реализуют отображение сенсорных элементов управления внутри своей нативной библиотеки. Это мне не слишком подходило, поскольку требовалось отображать текстуры и делать прочие сложные вещи в обвязке движка piece.c; хотелось какого-нибудь стандартного средства. Просмотрев примеры работы с Android API в эмуляторе (приложение API Demos) я обнаружил возможность отрисовки стандартных виджетов поверх видимого контекста (пример Graphics/SurfaceView Overlay). Это отлично подходило для реализации наэкранного управления для такой маленькой игры, как Spout.
Сенсорное управление в игре Spout, реализация кнопок. Используются стандартные виджеты Android OS 4.1.2.
Класс SpoutActivity идеально подходил для этой работы. В его перегруженный метод onCreate() я добавил следующий код:
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 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
private static boolean holdPushed = false; protected void onCreate(Bundle savedInstanceState) { ... // ALL ONSCREEN BUTTONS if (!SpoutSettings.s_DisableButtons) { float densityPixels = getResources().getDisplayMetrics().density; toDebug("PixelDensity: " + densityPixels); int padding = (int)(50 * densityPixels); toDebug("Padding: " + padding); // LAYOUTS LinearLayout.LayoutParams parametersBf = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); int leftF = (int)(20 * densityPixels); int topF = 0; int rightF = (int)(20 * densityPixels); int bottomF = (int)(10 * densityPixels); parametersBf.setMargins(leftF, topF, rightF, bottomF); LinearLayout.LayoutParams parametersbLR = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); int leftLR = 0; int topLR = 0; int rightLR = 0; int bottomLR = (int)(10 * densityPixels); parametersbLR.setMargins(leftLR, topLR, rightLR, bottomLR); // HOLD FIRE BUTTON final Button buttonFireHold = new Button(this); if (SpoutSettings.s_ShowButtons) { buttonFireHold.setBackgroundColor(Color.argb(100, 229, 82, 90)); } buttonFireHold.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: holdPushed = !holdPushed; if (holdPushed) { SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_FIRE); if (SpoutSettings.s_ShowButtons) { v.setBackgroundColor(Color.argb(100, 142, 207, 106)); } } else { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_FIRE); if (SpoutSettings.s_ShowButtons) { v.setBackgroundColor(Color.argb(100, 229, 82, 90)); } } v.setPressed(holdPushed); if (SpoutSettings.s_Vibro) { doVibrate(15); } if (SpoutSettings.s_Sound) { playSound(SpoutSounds.s_hold); } break; case MotionEvent.ACTION_UP: //v.performClick(); break; default: break; } return false; } }); buttonFireHold.setText(getString(R.string.HoldText)); if (!SpoutSettings.s_ShowButtons) { buttonFireHold.setBackgroundColor(Color.argb(0, 255, 255, 255)); buttonFireHold.setTextColor(Color.argb(75, 212, 207, 199)); } buttonFireHold.setPadding(padding, padding, padding, padding); buttonFireHold.setLayoutParams(parametersBf); // FIRE BUTTON Button buttonFire = new Button(this); buttonFire.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: if (holdPushed) { if (SpoutSettings.s_ShowButtons) { buttonFireHold.setBackgroundColor(Color.argb(100, 229, 82, 90)); } SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_FIRE); try { long sleepfor = 50; SpoutActivity.toDebug("Sleep now hack: " + sleepfor); Thread.sleep(sleepfor); SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_FIRE); } catch (InterruptedException ex) { } holdPushed = false; } SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_FIRE); if (SpoutSettings.s_Vibro) { doVibrate(15); } if (SpoutSettings.s_Sound) { playSound(SpoutSounds.s_fire); } break; case MotionEvent.ACTION_UP: SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_FIRE); //v.performClick(); break; default: break; } return false; } }); buttonFire.setText(getText(R.string.FireText)); if (!SpoutSettings.s_ShowButtons) { buttonFire.setBackgroundColor(Color.argb(0, 255, 255, 255)); buttonFire.setTextColor(Color.argb(75, 212, 207, 199)); } buttonFire.setPadding(padding, padding, padding, padding); buttonFire.setLayoutParams(parametersBf); // LEFT BUTTON Button buttonLeft = new Button(this); buttonLeft.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_LEFT); if (SpoutSettings.s_Vibro) { doVibrate(15); } if (SpoutSettings.s_Sound) { playSound(SpoutSounds.s_button); } break; case MotionEvent.ACTION_UP: SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_LEFT); //v.performClick(); break; default: break; } return false; } }); buttonLeft.setText(getString(R.string.LeftText)); if (!SpoutSettings.s_ShowButtons) { buttonLeft.setBackgroundColor(Color.argb(0, 255, 255, 255)); buttonLeft.setTextColor(Color.argb(75, 212, 207, 199)); } buttonLeft.setPadding(padding, padding, padding, padding); buttonLeft.setLayoutParams(parametersbLR); // RIGHT BUTTON Button buttonRight = new Button(this); buttonRight.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_RIGHT); if (SpoutSettings.s_Vibro) { doVibrate(15); } if (SpoutSettings.s_Sound) { playSound(SpoutSounds.s_button); } break; case MotionEvent.ACTION_UP: SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_RIGHT); //v.performClick(); break; default: break; } return false; } }); buttonRight.setText(getString(R.string.RightText)); if (!SpoutSettings.s_ShowButtons) { buttonRight.setBackgroundColor(Color.argb(0, 255, 255, 255)); buttonRight.setTextColor(Color.argb(75, 212, 207, 199)); } buttonRight.setPadding(padding, padding, padding, padding); buttonRight.setLayoutParams(parametersbLR); // LAYOUTS SETTINGS LinearLayout ll0 = new LinearLayout(this); ll0.addView(buttonFireHold); ll0.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.TOP); addContentView(ll0, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); LinearLayout ll = new LinearLayout(this); // Add buttons to layer if (!SpoutSettings.s_Sensor) { ll.addView(buttonLeft); } ll.addView(buttonFire); if (!SpoutSettings.s_Sensor) { ll.addView(buttonRight); } // End ll.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM); addContentView(ll, new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } } |
Здесь происходит следующее: создаются четыре стандартных для 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():
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 |
private boolean nowLeft = false; protected void onCreate(Bundle savedInstanceState) { ... // SENSORS if (SpoutSettings.s_Sensor) { SensorManager manager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); Sensor accelerometer = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).get(0); toDebug("== Using accelerometer: " + accelerometer.getName()); manager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME); } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { // Nothing to do here } private void toLeft(final boolean left) { if (!nowLeft) { new Thread(new Runnable() { @Override public void run() { nowLeft = true; try { if (left) { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_RIGHT); SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_LEFT); } else { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_LEFT); SpoutNativeLibProxy.SpoutNativeKeyDown(SpoutNativeSurface.KEY_RIGHT); } Thread.sleep(50); if (left) { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_LEFT); } else { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_RIGHT); } nowLeft = false; } catch (InterruptedException ex) { } } }).start(); } } @Override public void onSensorChanged(SensorEvent event) { float y = event.values[1]; // Y-axis if (y > (-3.0f) && y < (3.0f)) { SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_LEFT); SpoutNativeLibProxy.SpoutNativeKeyUp(SpoutNativeSurface.KEY_RIGHT); } else { if (y < 0.0f) { toLeft(true); } if (y > 0.0f) { toLeft(false); } } } |
В перегруженном методе 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 и привести их к одному зарезервированному оттенку серого цвета:
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 |
void pceAppProc (/*int cnt*/) { ... // Patch grain color to 0x1 on display if (color_on) { int rn; for (rn = 0; rn < 128 * 128; ++rn) { switch (vbuff2[rn]) { case 199: case 198: case 135: case 134: case 71: case 70: case 7: case 6: { vbuff2[rn] = 197; // 197 & 0x03030303 = 0x1 break; } } } } ... } |
Затем в файле piece.c в функции pceLCDTrans(), заменить эти пиксели другими, со случайным цветом из таблицы colorTable:
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 |
// RGB to RGB565 macro #define RGB565(r, g, b) (((r) << (5+6)) | ((g) << 6) | (b)) int colorTable[19]; void initilizeColorTable() { colorTable[0] = RGB565(139, 0, 0); // DarkRed colorTable[1] = RGB565(255, 160, 122); // LightSalmon colorTable[2] = RGB565(220, 20, 60); // Crimson colorTable[3] = RGB565(178, 34, 34); // FireBrick colorTable[4] = RGB565(255, 0, 0); // Red colorTable[5] = RGB565(255, 69, 0); // OrangeRed colorTable[6] = RGB565(255, 140, 0); // DarkOrange colorTable[7] = RGB565(255, 165, 0); // Orange colorTable[8] = RGB565(255, 255, 0); // Yellow colorTable[9] = RGB565(255, 215, 0); // Gold colorTable[10] = RGB565(128, 0, 0); // Maroon colorTable[11] = RGB565(210, 105, 30); // Chocolate colorTable[12] = RGB565(135, 206, 250); // LightSkyBlue colorTable[13] = RGB565(30, 144, 255); // DodgerBlue colorTable[14] = RGB565(0, 255, 0); // Green /***********************************/ colorTable[15] = 0xAD55; // LightGray #1 colorTable[16] = 0x52AA; // DarkGray #2 colorTable[17] = 0x0000; // Black colorTable[18] = 0xFFFF; // White } void pceLCDTrans () { ... // Convert buffer to RGB565 int rz; for (rz = 0; rz < IN_SCREEN_HEIGHT * IN_SCREEN_WIDTH; ++rz) { switch (pixelData[rz]) { case 0x1: pixelDataRGB565[rz] = (color_on) ? colorTable[rand() % 15] : colorTable[15]; break; case 0x2: pixelDataRGB565[rz] = colorTable[16]; // DarkGray #2 break; case 0x3: pixelDataRGB565[rz] = colorTable[17]; // Black break; case 0x0: pixelDataRGB565[rz] = colorTable[18]; // White break; default: break; } } ... } |
Spout, активированная опция Colorize.
Это сработало: частички стали цветными и начали случайным образом менять свой цвет. Изменился так же и логотип игры Spout в начальной заставке, но это лишь пошло ему на пользу, на мой взгляд.
9.2. Длинный хвост-прицел
С прицелом в оригинальном движке игры связана интересная особенность: если приблизиться к какой-либо грани экрана и резко повернуть хвост в сторону стены, то он может вылезти на противоположной стороне игрового поля. В файле piece.c в функции pceLCDTrans() я нашёл цикл, в котором реализована отрисовка хвоста, и попробовал его удлинить. Это сработало, но из-за особенности, которую я упомянул выше, в некоторых местах за границей игрового поля длинный хвост оставлял за собой вереницу пикселей, а при пересечении верхней границы приложение так и вообще падало с ошибкой Segmentation fault. Проблема оказалась в определениях границ, которые изначально были расставлены немного неправильно, возможно в целях оптимизации. Я поправил эту ошибку исправлением значений границ и всё отлично заработало. Цикл рисования хвоста получился таким:
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 |
// x = sqrt(128^2 + 88^2) / 4 // x = 39 int N_con = (tail_on) ? 39 : 3; for (i = 0; i < N_con; i++) { if (y < 0 || y >= 77 * 256) { break; } if (x < 4 * 256 || x > 124 * 256) { break; } *(vbuff + x / 256 + (y / 256 + 2) * 128) = 3; x += sintable[(256 + mR) & 1023] / 16; y -= sintable[mR] / 16; if (y < 0 || y >= 77 * 256) { break; } if (x < 4 * 256 || x > 124 * 256) { break; } *(vbuff + x / 256 + (y / 256 + 2) * 128) = 3; x += sintable[(256 + mR) & 1023] / 16; y -= sintable[mR] / 16; if (y < 0 || y >= 77 * 256) { break; } if (x < 4 * 256 || x > 124 * 256) { break; } *(vbuff + x / 256 + (y / 256 + 2) * 128) = 3; x += sintable[(256 + mR) & 1023] * 2 / 16; y -= sintable[mR] * 2 / 16; } |
Spout, активированная опция Long Tail.
Максимально возможная длина хвоста определена гипотенузой, поэтому по теореме Пифагора зная размеры сторон экрана расчитать её не составило труда. Она уменьшена в четыре раза, поскольку длина одного сегмента хвоста занимает четыре пикселя.
9.3. Звук
В качестве звуков нажатия на сенсорные элементы управления я решил использовать звуки клавиатуры от мобильных телефонов Motorola E398 и Siemens CX65, которые я заблаговременно скачал с тематического форума MotoFan.Ru. Один из этих звуков я использовал также для индикации Game Over. Рекомендуется использовать WAV-файлы с частотой дискретизации 44100 Гц. Чтобы звуки были доступны в процессе работы приложения, их требуется положить в директорию с ресурсами res или assets, тогда они попадут в APK-пакет; я выбрал последнее. Для проигрывания коротких звуковых файлов в Android OS рекомендуется использовать системный класс SoundPool. Весь код работы со звуком я разместил в классе SpoutActivity:
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 |
public static SoundPool soundPool = null; public static class SpoutSounds { public static int s_gameover; public static int s_fire; public static int s_button; public static int s_hold; } @Override protected void onCreate(Bundle savedInstanceState) { ... // SOUNDS if (SpoutSettings.s_Sound) { soundPool = new SoundPool(5, AudioManager.STREAM_MUSIC, 0); try { SpoutSounds.s_button = soundPool.load(getAssets().openFd("s_button.wav"), 1); SpoutSounds.s_fire = soundPool.load(getAssets().openFd("s_fire.wav"), 1); SpoutSounds.s_gameover = soundPool.load(getAssets().openFd("s_gameover.wav"), 1); SpoutSounds.s_hold = soundPool.load(getAssets().openFd("s_hold.wav"), 1); } catch (IOException e) { e.printStackTrace(); } } ... } // JNI-method public static void playSound(int soundID) { if (SpoutSettings.s_Sound && (soundID != 0)) { soundPool.play(soundID, 1.0f, 1.0f, 0, 0, 1.0f); } } @Override public void onBackPressed() { if (SpoutSettings.s_Sound) { soundPool.release(); } ... } |
Вложенный статический класс SpoutSounds содержит специальные идентификаторы звуковых файлов, которые устанавливаются в методе onCreate(). Вызывая playSound() с аргументом-идентификатором, можно воспроизвести нужный звук. Метод playSound() публичный и статический, поскольку вызывается не только из Java-обёртки, но и из движка игры. Для этого в piece.c реализована функция playGameOverSoundFromJNI(), которую дёргает движок spout.c. Выглядит она следующим образом:
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 playGameOverSoundFromJNI() { if (sound_on) { if (javaEnviron != NULL) { // Get SpoutSounds class from SpoutActivity class jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/spout/SpoutActivity$SpoutSounds"); if (clazz == 0) { LOGI("Error JNI: Class ru/exlmoto/spout/SpoutActivity$SpoutSounds not found!"); } // Get static SoundID field ID from static SpoutSounds class jfieldID fieldID = (*javaEnviron)->GetStaticFieldID(javaEnviron, clazz, "s_gameover", "I"); if (fieldID == 0) { LOGI("Error JNI: fieldID is 0, field s_gameover I not found!"); } // Get jint value from static class field jint soundID = (*javaEnviron)->GetStaticIntField(javaEnviron, clazz, fieldID); LOGI("JNI: soundID is: %d", (int)soundID); // Now return to SpoutActivity clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/spout/SpoutActivity"); if (clazz == 0) { LOGI("Error JNI: Class ru/exlmoto/spout/SpoutActivity not found!"); } // Get static playSound method from SpoutActivity class jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "playSound", "(I)V"); if (methodId == 0) { LOGI("Error JNI: methodId is 0, method playSound (I)V not found!"); } if (soundID != 0) { // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, soundID); } } } } |
С помощью проброшенного указателя на Java-окружение javaEnviron мы получаем идентификатор звукового файла, отвечающий за Game Over и передаём его в метод playSound(), тем самым проигрывая необходимый звук.
9.4. Виброотдача
Для управления вибромоторчиком устройства из игры, необходимо получить специальное разрешение, добавив в файл AndroidManifest.xml следующее:
1 |
<uses-permission android:name="android.permission.VIBRATE"/> |
Эту строку необходимо вставить внутрь корневого тега manifest. В классе SpoutActivity в перегруженном методе onCreate() я добавил инициализацию объекта класса Vibrator и публичный статический метод doVibrate(), аргументом которого является задержка в миллисекундах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private static Vibrator m_vibrator; @Override protected void onCreate(Bundle savedInstanceState) { ... m_vibrator = (Vibrator)getSystemService(Context.VIBRATOR_SERVICE); } // JNI-method public static void doVibrate(int duration) { if (SpoutSettings.s_Vibro) { final int delay = duration; new Thread(new Runnable() { @Override public void run() { m_vibrator.vibrate(delay); } }).start(); } } |
Для того, чтобы вибрировать при Game Over этот метод необходимо вызывать из нативной библиотеки Spout. Первоначально я не вызывал doVibrate() из движка игры, а использовал переменную-флажок, которая принимала значение единички, когда игра была проиграна. Далее в методе onDrawFrame() класса SpoutNativeSurface я проверял значение этой переменной и вызывал вибрацию, если это было необходимо. К сожалению, проверки в onDrawFrame() снизили FPS и мне пришлось сделать так, чтобы doVibrate() мог быть вызван из нативной обвязки движка, расположенной в файле piece.c. Для этого была реализована функция vibrateFromJNI(), похожая на ту, что использовалась для проигрывания звуков:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
void vibrateFromJNI(int duration) { if (vibrate_now) { if (javaEnviron != NULL) { jclass clazz = (*javaEnviron)->FindClass(javaEnviron, "ru/exlmoto/spout/SpoutActivity"); if (clazz == 0) { LOGI("Error JNI: Class ru/exlmoto/spout/SpoutActivity not found!"); } jmethodID methodId = (*javaEnviron)->GetStaticMethodID(javaEnviron, clazz, "doVibrate", "(I)V"); if (methodId == 0) { LOGI("Error JNI: methodId is 0, method doVibrate (I)V not found!"); } // Call JAVA-method (*javaEnviron)->CallStaticVoidMethod(javaEnviron, clazz, methodId, duration); } } } |
Таким образом vibrateFromJNI() мог вызываться из самого кода движка в spout.c:
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 |
int vibrate_now = 0; int vibFlag = 0; void pceAppProc (/*int cnt*/) { ... vibrate_now = 0; if (!gameover) { vibFlag = 0; } if (gamePhase == 3 && gameover != 0) { ... if (vibrate_now == 0 && vibFlag == 0) { vibrate_now = 1; vibFlag = 1; // Vibrate from JNI vibrateFromJNI(50); // And play game_over sound playGameOverSoundFromJNI(); } } ... } |
Вспомогательные флажки используются для того, чтобы функции vibrateFromJNI() и playGameOverSoundFromJNI() вызывались единожды.
10. Реализация 3D-куба с изменяющимися текстурами
Ради интереса и более углубленного изучения OpenGL ES, я реализовал в игре специальный демо-режим, который демонстрирует вращающийся куб. Поскольку для отображения игрового контекста используется текстура, её можно легко натянуть на объект и обновлять. Тем самым у меня получился кубик, сторонами которого служат «игровые экраны». Опция 3D Cube в Launcher’е как раз и активирует этот демо-режим.
Spout, активированная опция 3D Cube.
Необходимые для этого изменения были внесены в нативную обвязку движка piece.c:
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 |
// Cube settings int cube_on = 0; GLfloat angleCube = 0.0f; GLfloat speedCube = -0.5f; float vertices[] = { // Vertices for a face -1.0f, -1.0f, 0.0f, // 0. left-bottom-front 1.0f, -1.0f, 0.0f, // 1. right-bottom-front -1.0f, 1.0f, 0.0f, // 2. left-top-front 1.0f, 1.0f, 0.0f // 3. right-top-front }; // Scales multipliers const GLfloat x_scale_m = (float)IN_SCREEN_WIDTH / TEXTURE_WIDTH; const GLfloat y_scale_m = (float)IN_SCREEN_HEIGHT / TEXTURE_HEIGHT; // Texture offsets float texCoords[8]; void initCubeTexturesCoords() { // A. left-bottom texCoords[0] = 0.0f; texCoords[1] = y_scale_m; // B. right-bottom texCoords[2] = x_scale_m; texCoords[3] = y_scale_m; // C. left-top texCoords[4] = 0.0f; texCoords[5] = 0.0f; // D. right-top texCoords[6] = x_scale_m; texCoords[7] = 0.0f; } void emulateGLUperspective(GLfloat fovY, GLfloat aspect, GLfloat zNear, GLfloat zFar) { GLfloat fW, fH; fH = tan(fovY / 180 * M_PI) * zNear / 2; fW = fH * aspect; glFrustumf(-fW, fW, -fH, fH, zNear, zFar); } void draw_cube(void) { glFrontFace(GL_CCW); glEnable(GL_CULL_FACE); glCullFace(GL_BACK); glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(3, GL_FLOAT, 0, vertices); glEnableClientState(GL_TEXTURE_COORD_ARRAY); glTexCoordPointer(2, GL_FLOAT, 0, texCoords); // front glPushMatrix(); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); // left glPushMatrix(); glRotatef(270.0f, 0.0f, 1.0f, 0.0f); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); // back glPushMatrix(); glRotatef(180.0f, 0.0f, 1.0f, 0.0f); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); // right glPushMatrix(); glRotatef(90.0f, 0.0f, 1.0f, 0.0f); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); // top glPushMatrix(); glRotatef(270.0f, 1.0f, 0.0f, 0.0f); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); // bottom glPushMatrix(); glRotatef(90.0f, 1.0f, 0.0f, 0.0f); glTranslatef(0.0f, 0.0f, 1.0f); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glPopMatrix(); glDisableClientState(GL_TEXTURE_COORD_ARRAY); glDisableClientState(GL_VERTEX_ARRAY); glDisable(GL_CULL_FACE); } void resizeSpoutGLES(int w, int h) { ... if (cube_on == 1) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST); glShadeModel(GL_SMOOTH); GLclampf gray = 96.0f / 255.0f; glClearColor(gray, gray, gray, 0.0f); // To prevent division by 0 if (display_h == 0) { display_h = 1; } float aspect = (float)display_w / display_h; glViewport(0, 0, display_w, display_h); // Setup perspective projection, with aspect ratio matches viewport glMatrixMode(GL_PROJECTION); // Select projection matrix glLoadIdentity(); // Reset projection matrix // Use perspective projection // GLU library is not present in the Android NDK // Emulate this function // gluPerspective(gl, 45, aspect, 0.1f, 100.f); emulateGLUperspective(45.0f, aspect, 0.1f, 100.0f); glMatrixMode(GL_MODELVIEW); // Select model-view matrix glLoadIdentity(); // Reset projection matrix } } void pceLCDTrans () { ... if (cube_on == 1) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); glTranslatef(0.0f, 0.0f, -3.5f); glRotatef(angleCube, 0.1f, 1.0f, 0.2f); draw_cube(); angleCube += speedCube; } ... } |
При запуске игры текстурные координаты куба инициализируются функцией 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. Файлы оригинального разработчика игры были выложены без указания лицензии. Ссылка на репозиторий:
В этой работе я использовал огромное количество материалов, основные из них я выделю в полезных ссылках ниже. Огромное спасибо ресурсам stackoverflow.com и google.com за то, что они есть.
- Персональный блог kuni, разработчика игры Spout — осторожно, по ссылке блог на японском языке;
- Руководство по портированию с OpenGL на OpenGL ES от сообщества обладателей портативной игровой консоли Pandora;
- Туториалы от NeHe, портированные для Android OS — одни из самых известных уроков по OpenGL в сети;
- Отличная статья, которая рассказывает как рисовать свой контекст на текстуре, автор статьи и блога Richard Quirk;
- Коммерческая реализация игры 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, скачать которую можно со странички проектов.
Дружище, ты маньяк.
В хорошем смысле этого слова.
Хех, спасибо! Стараюсь кого-нибудь увлечь!