На помойку? Никак нет! Пишем нативные приложения для дешевых китайских телефонов
Если сейчас приехать в пункт приема металлолома, то можно обнаружить просто огромные кучи различных телефонов и прочих электронных «отходов», которые стоят под открытым небом и ждут, когда придёт их черёд окончательного разложения. Однако при ближайшем рассмотрении выясняется, что многие девайсы оказываются полностью рабочими даже после недельного лежания под палящим солнцем и проливными дождями, а сдали их в чермет по причинам «не нужен, надоел, купил новый» и т. п. Я не считаю это правильным, ведь даже в простые кнопочные звонилки имеется возможность вдохнуть новую жизнь, если знать один интересный, но малоизвестный факт: для них можно писать нативные приложения на C и использовать железо телефона в своих целях. А это, на минуточку, как минимум: дисплей с подсветкой, вибромотор, динамик, клавиатура и GSM-радиомодуль с возможностью выхода в сеть. Сегодня мы с вами: узнаем, на каких аппаратных платформах работают китайские телефоны, какие существуют программные платформы и где взять для них SDK, а в практической части мы напишем 2D-игру с нуля, которая будет работать на многих китайских кнопочниках. Интересно? Тогда жду вас под катом!
Содержание:
❯ Не J2ME едины
Думаю, многие мои читатели помнят о такой платформе, как J2ME. Java-приложения стали фактически основной возможностью расширения функционала телефонов в 2000-х годах. API для них был достаточно хорошо стандартизировано, программы не зависели от архитектуры процессора и ОС устройства, а порог вхождения для написания собственных приложений был довольно низкий и даже новички могли за пару дней написать свою игрушку или какое-нибудь GUI-приложение!
Однако не одним J2ME мы были едины: существовало множество платформ, которые так или иначе пытались занять нишу Java на рынке. Некоторые из них я упоминал в своей прошлой статье о написании 3D-игры под Sony Ericsson с нуля: например, была такая платформа на телефонах Sony Ericsson серии T, как Mophun, а CDMA-телефонами с чипсетами Qualcomm использовалась нативная платформа BREW. Пожалуй, я не буду упоминать о .sis и .cab — поскольку это форматы нативных приложений для смартфонов, а не простых «фичефонов».
Игра для Mophun3D
В какой-то момент, ближе к 2006–2007 году, прилавки российских официальных ритейлеров (по большей части это были телефоны Fly) и неофициальных продавцов на рынках заполонили различные китайские телефоны, которые предлагали какой-то немыслимый функционал для тех лет за копейки, да ещё и визуально напоминали флагманские модели известных брендов. Пожалуй, одним из самых популярных таких телефонов была Nokla TV E71/E72 (да, именно «нокла»), вышедшая примерно в 2008 году и производившаяся аж до 2011 года! За 2–3 тысячи рублей (это менее 100 баксов), пользователь получал здоровый 2.4» дисплей с разрешением 240×320 весьма неплохого качества (когда в те годы многие продолжали ходить с 176×220), да ещё и с тачскрином, гироскоп, огромный громкий динамик (пусть и не очень качественный), поддержку SD-карточек до 32Гб, нередко фронтальную камеру, а также премиальный дизайн с вставками из алюминия. Частенько китайцы заботливо клали в коробку ещё чехольчик и дополнительный аккумулятор :)
Были даже полные копии существующих устройств от Nokia. Особенно китайцы любили подделывать массовые модели на S40: они были очень популярными и китайцы хотели откусить свой кусок рынка у Nokia. Пусть и рынка серого импорта — очевидно, в салонах связи подделки никто не продавал:
Но была и ложка дёгтя в этой бочке меда: китайские телефоны очень часто не имели поддержки Java, из-за чего многие пользователи разочаровывались в них из-за отсутствия возможности установить необходимые им приложения. Никакой тебе оперы, аськи, игр… Скорее всего, это связано с необходимостью отчислений Sun, а также разработчикам реализации J2ME-машины (JBed/JBlend) и установки чипа флэш-памяти чуть большего объёма.
Но многие пользователи не знали, что такие девайсы не просто поддерживали сторонние приложения, но и умели выполнять настоящие нативные программы, написанные на полноценном C! Всему помешала китайская костыльность и тотальная закрытость. Платформа предполагалась для работы на внутреннем рынке. Для вызова менеджера нативных приложений необходимо было вводить специальный инженерный код в номеронабирателе, предварительно скопировав приложение в нужную папку, а SDK долгое время было платным и доступно только для компаний из Китая. Кроме того, далеко не все приложения могли запустить на конкретном девайсе — были серьезные проблемы с совместимостью.
В ранних китайских телефонах использовалась платформа Mythroad (MRP, MiniJ) от китайской компании SkyWorks, которая лицензировала свою технологию производителям чипсетов. Поддержку MRP можно было встретить на телефонах с чипсетами MediaTek, Spreadtrum, а также MStar (и возможно Coolsand). Mythroad предоставлял некоторое API для работы с железом телефона и разработки как UI-приложений, так и игр, кроме того, Mythroad позволял хранить ресурсы в одном бинарнике с основной программой и даже имел какой-то интерпретируемый язык помимо возможности запуска нативного кода. Для работы таких приложений необходимо было скопировать менеджер приложений dsm_gm.mrp и игру в папку mythroad во внутренней памяти устройства или на флэшке, а затем набрать в номеронабирателе код *#220807#, иногда при отключенной первой SIM-карте. Костыльно? Костыльно! Откуда об этом знать среднестатистическому пользователю? Не откуда! Но работало!
Эта платформа поддерживалась на большинстве подделок под брендовые устройства Nokia, Sony Ericsson и Samsung, а также iPhone и на многих китайских кнопочных телефонах 2008–2010 годов.
Ближе к 2010 году MediaTek разработала свою собственную платформу, которая должна была заменить MRP — WRE (VXP). Эта платформа была гораздо шире с точки зрения функционала (например, был доступ к UART) и её API был вполне удобно читаем для программиста, а SDK свободно доступен для всех. Один нюанс всё портил — приложения без подписи привязывались к IMSI (даже не IMEI) симки в девайсе и на некоторых девайсах требовали переподписания под каждую конкретную SIM или патчинг дампа оригинальной прошивки телефона на отключение проверки подписи. Эта платформа поддерживалась на многих кнопочниках и смарт-часиках 2010–2020 годов: к ним относятся новодельные телефоны Nokia, телефоны DNS и DEXP, Explay и т. п. Для запуска приложений достаточно было выбрать файл с разрешением VXP в проводнике и просто запустить его. Но с совместимостью всё равно имелись проблемы: если запустить VXP для версии 2.0 и выше, мы получим лишь белый экран. Ну хоть не софтресет, и на том спасибо!
Далеко не все такие часы поддерживают MRE, смотреть нужно от устройства к устройству
❯ Аппаратные ресурсы<
Большинство китайских кнопочных телефонов работает на базе одних и тех же чипсетов. В конце нулевых чаще всего использовались чипсеты MT6225, SC6520 и некоторые чипы от Coolsand. Средние хар-ки девайса были следующими:
- Процессор: ARMv5 ядро на частоте ~104МГц, ARM926EJ-S. Нет FPU, есть Thumb. Большую часть процессорного времени программа могла забрать себе.
- ОЗУ: ~4Мб SDRAM. Программам было доступно 512Кб-1Мб Heap’а. Это, в целом, довольно немало для большинства применений.
- Флэш-память: ~32Мб, пользователю доступно пару сотен килобайт. Да, вы не ослышались, килобайт! Однако можно без проблем использовать MicroSD-флэшки до 32Гб.
- Дисплей: от 128×128 до 320×480, почти всегда есть 18-битный цвет (262.000 цветов), в случае TV E71/E72 используется очень неплохая TN-матрица с хорошими углами обзора и яркой подсветкой. Иногда есть тачскрин.
- Звук: громкий динамик, наушники.
- Аккумулятор: ~800 мАч, на некоторых девайсах может быть и 2.000 мАч, а то и больше!
- Ввод: клавиатура, иногда была поддержка QWERTY.
- Внешние шины: почти всегда был доступен UART, причём его можно было свободно взять прямо с платы — он был явно подмечен! Взять GPIO с проца не выйдет (кроме, возможно, вибромотора), SPI и I2C также напрямую недоступны. Внешние шины можно реализовать с помощью UART через GPIO-мост из микроконтроллера.
В итоге мы получаем очень неплохие характеристики для устройства, которое сочетает в себе сразу всё. На базе такого девайса можно сделать и сигнализацию, и HMI-дисплей с интерфейсом для управления каким-нибудь устройством, и игровую консоль с эмуляторами… да на что фантазии хватает! И это за какие-то 200–300 рублей, если мы говорим о б/у устройстве или 600 рублей, если говорим о новом. Это дешевле, чем собирать девайс с подобным функционалом самому из готового МК (например, RP2040) и отдельных модулей. Кстати, дешевые 2.4» дисплеи на алике — это ни что иное, как невостребованные остатки дисплеев для подобных китайских телефонов на складах! А вы думали, откуда там значки на тачскрине снизу?
Однако в рамках данной статьи мы не будем ограничиваться лишь теорией и на практике напишем примитивную 2D-игрушку, которая будет работать сразу на трех платформах без каких-либо изменений в коде самой игры: Windows, MRP (Mythroad) и VXP. Но для того, чтобы достигнуть такого уровня абстракции от платформы, нам необходимо написать рантайм, который оборачивает все необходимые платформозависимые функции для нашей игры.
Игрушка будет простой: 2D скролл-шутер с видом сверху, а-ля Asteroids. Летаем по космосу, и стреляем по враждебным корабликам, стараясь не попасть под вражеские лазеры. Всё просто и понятно :)
❯ Практическая часть: Кроссплатформенный рантайм
Итак, что нам необходимо от абстракции для такой простой игры? Давайте посмотрим:
Выглядит всё достаточно просто, верно? Примерно такого набора функций хватит для нашей игры:
void sysLogf(char* fmt, ...);
void* sysAlloc(int len);
void sysFree(void* ptr);
int sysRand();
int gGetScreenWidth();
int gGetScreenHeight();
int gGetScreenColorDepth(); // Almost always 16
void gClearScreen(CColor* color);
void gDrawBitmap(CBitmap* bmp, int x, int y);
void gDrawText(char* text, int x, int y, CColor* color);
bool inHasTouchScreen();
int inGetKeyState();
bool inIsAnyKeyPressed();
int inGetPointerX();
int inGetPointerY();
void gameStart();
void gameUpdate();
void gameDraw();
❯ Win32
Давайте же перейдем к реализации рантайма на каждой платформе по отдельности. Начнём с Win32, поскольку адекватно отлаживать игру можно только на ПК.
На десктопе у нас будет фиксированное окно 240×320, в качестве GAPI будет использоваться аппаратно-ускоренный OpenGL, а для обработки ввода будет использоваться классически GetAsyncKeyState. Реализация точки входа, создания окна и инициализации контекста GL и главного цикла приложения у нас такая:
void gInit()
{
hwnd = CreateWindowA("STATIC", "2D Framework", WS_VISIBLE | WS_SYSMENU, 0, 0, gGetScreenWidth(), gGetScreenHeight(), 0, 0, 0, 0);
hPrimaryDC = GetDC(hwnd);
PIXELFORMATDESCRIPTOR pfd;
ZeroMemory(&pfd, sizeof(pfd));
if (!SetPixelFormat(hPrimaryDC, ChoosePixelFormat(hPrimaryDC, &pfd), &pfd))
{
sysLogf("SetPixelFormat failed\n");
exit(-1);
}
hGL = wglCreateContext(hPrimaryDC);
wglMakeCurrent(hPrimaryDC, hGL);
sysLogf("Renderer: %s\n", glGetString(GL_RENDERER));
sysLogf("Vendor: %s\n", glGetString(GL_VENDOR));
sysLogf("Version: %s\n", glGetString(GL_VERSION));
glEnable(GL_TEXTURE_2D);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glMatrixMode(GL_PROJECTION);
glOrtho(0, gGetScreenWidth(), gGetScreenHeight(), 0, 0, 1);
}
void winMainLoop()
{
while (IsWindow(hwnd))
{
MSG msg;
while (PeekMessageA(&msg, hwnd, 0, 0, PM_REMOVE))
DefWindowProc(hwnd, msg.message, msg.wParam, msg.lParam);
gameUpdate();
gameDraw();
glFinish();
SwapBuffers(hPrimaryDC);
Sleep(1000 / 60);
}
}
int main(int argc, char** argv)
{
sysLogf("Portable 2D framework\n");
sysLogf("Version: " VERSION "\n");
gInit();
gameStart();
winMainLoop();
}
Реализация отрисовки спрайтов очень примитивная — OGL 1.0, полностью FFP, вся отрисовка — это 2 треугольника, формирующие квад. Спрайт заливается при первом использовании в текстуру, последующие кадры реюзается уже готовая текстура. Фактическая реализация всего рендерера — т. е. функций для рисования «просто картинок», без поддержки атласов, блендинга цветов:
void gClearScreen(CColor* color)
{
float r = (float)color->r / 255;
float g = (float)color->g / 255;
float b = (float)color->b / 255;
glClearColor(r, g, b, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
#define GL_UNSIGNED_SHORT_5_6_5 0x8363
#define TEXTURE_COLORKEY 63519
void gPrepareBitmap(CBitmap* bmp)
{
GLuint tex[1];
glGenTextures(1, &tex);
sysLogf("Uploading texture %dx%d\n", bmp->width, bmp->height);
unsigned char* data = (unsigned char*)malloc(bmp->width * bmp->height * 4);
// Quick endian flip & color-space conversion
for (int i = 0; i < bmp->width * bmp->height; i++)
{
unsigned short pixel = *((unsigned short*)&bmp->pixels[i * 2]);
float r = (float)(pixel & 31) / 32;
float g = (float)((pixel >> 5) & 63) / 64;
float b = (float)(pixel >> 11) / 32;
data[i * 4 + 2] = (unsigned char)(r * 255);
data[i * 4 + 1] = (unsigned char)(g * 255);
data[i * 4] = (unsigned char)(b * 255);
data[i * 4 + 3] = pixel == TEXTURE_COLORKEY ? 0 : 255;
}
glBindTexture(GL_TEXTURE_2D, tex[0]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmp->width, bmp->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
free(data);
bmp->_platData = tex[0];
}
void gDrawBitmap(CBitmap* bmp, int x, int y)
{
gDrawBitmapEx(bmp, x, y, 0, 0);
}
void gDrawBitmapEx(CBitmap* bmp, int x, int y, CColor* colorKey, CColor* mulColor)
{
if (!bmp->_platData)
gPrepareBitmap(bmp);
glBindTexture(GL_TEXTURE_2D, (GLuint)bmp->_platData);
glBegin(GL_QUADS);
glTexCoord2f(0, 0);
glVertex2i(x, y);
glTexCoord2f(1, 0);
glVertex2i(x + bmp->width, y);
glTexCoord2f(1, 1);
glVertex2i(x + bmp->width, y + bmp->height);
glTexCoord2f(0, 1);
glVertex2i(x, y + bmp->height);
glEnd();
}
С вводом тоже всё просто. Есть биндинг кнопок клавиатуры к кнопкам на кейпаде телефона. inGetKeyState предполагается вызывать один раз за кадр, поэтому функция опрашивает ОС о состоянии нажатых кнопок на клавиатуре и назначает состояние виртуальных кнопок относительно состояния физических кнопок на клавиатуре.
static int inKeyBinding[] = {
VK_LEFT, KEY_LEFT,
'A', KEY_LEFT,
VK_RIGHT, KEY_RIGHT,
'D', KEY_RIGHT,
VK_UP, KEY_UP,
'W', KEY_UP,
VK_DOWN, KEY_DOWN,
'S', KEY_DOWN,
'Q', KEY_LS,
'E', KEY_RS
};
bool inHasTouchScreen()
{
return false;
}
int inGetKeyState()
{
int result = 0;
for (int i = 0; i < (sizeof(inKeyBinding) / sizeof(int)) / 2; i++)
{
if (GetAsyncKeyState(inKeyBinding[i * 2]) & 0x8000)
result |= inKeyBinding[i * 2 + 1];
}
return result;
}
bool inIsAnyKeyPressed()
{
return inGetKeyState() != 0;
}
int inGetPointerX()
{
return 0;
}
int inGetPointerY()
{
return 0;
}
Результат:
❯ MiniJ
Переходим к реализации рантайма для первой китайской платформы — MRP. Обратите внимание — я использую нативное API платформы для рисования спрайтов. Связано это с тем, что софтварный блиттер работает невероятно медленно даже с прямым доступом к скринбуферу устройства, а в чипсете предусмотрена отдельная графическая подсистема с командбуфером для быстрой отрисовки примитивов и графики:
SDK для MRE можно найти здесь (SKYSDK.zip): оно уже пропатчено от необходимости покупки лицензии. MRP не развивается более 10 лет, поэтому, думаю, его можно считать Abandonware. Компилятор находится в compiler/mrpbuilder.NET1.exe. За китайские SDK в публичном доступе нужно поблагодарить пользователя 4pda AjlekcaHgp MejlbHukoB, который раздобыл их на всяких csdn и выложил в свободный доступ :)
У MRP собственная система сборки, основанная на конфигурациях. Поскольку MRP может работать на устройствах с разными платформами и размерами дисплеев, под каждую можно настроить свой конфиг, который пережмет ресурсы в нужный формат. Дабы ничего не ломать, я заюзал абсолютные пути:
[information]
projectname=game.mpr
filename=game.mrp
appname=ScrollShooter
appid=30001
version=100
visible=1
cpu=3
vendor=monobogdan
output=bin\game.mrp
description=ScrollShooter game by monobogdan
include=D:/SKYSDK/include/,D:/SKYSDK/include/plugins/
config=mtk240
[config_mtk240]
output=bin\game.mrp
bmp_mode=normal
plat=mtk
[files]
file42 = platform\plat_mrc.c
file44 = game.c
file45 = resources\gamedata.c
file46 = graphics.c
Компиляция приложения:
mrpbuilder.net1.exe game.mpr
Начинаем с функций обработки событий и инициализации, которые вызывает рантайм при старте приложения: mrc_init вызывается при старте приложения, а mrc_event при возникновении события. Вся инициализация очень простая: создаём таймер для обновления и перерисовки состояния игры и вызываем инициализацию игры:
void mrc_draw(int32 data)
{
mrc_clearScreen(0, 0, 128);
gameUpdate();
gameDraw();
mrc_refreshScreen(0, 0, 240, 320);
}
int32 mrc_init(void)
{
mrc_getScreenInfo(&screenInfo);
gameStart();
// Allocate timer
globalTimer = mrc_timerCreate();
mrc_timerStart(globalTimer, 1000 / 30, 0, mrc_draw, 1);
return MR_SUCCESS;
}
int32 mrc_extRecvAppEvent(int32 app, int32 code, int32 param0, int32 param1)
{
return MR_SUCCESS;
}
int32 mrc_extRecvAppEventEx(int32 code, int32 p0, int32 p1, int32 p2, int32 p3, int32 p4,int32 p5)
{
return MR_SUCCESS;
}
int32 mrc_pause(void)
{
return MR_SUCCESS;
}
int32 mrc_resume(void)
{
return MR_SUCCESS;
}
int32 mrc_exitApp(void)
{
return MR_SUCCESS;
}
С вводом тоже никаких проблем нет, нажатия кнопок прилетают как события в mrc_event. Переводим кейкоды MRE в наши кейкоды и сохраняем их состояние:
int32 mrc_event(int32 ev, int32 p0, int32 p1)
{
int key = p0;
int vKey = 0;
switch(key)
{
case MR_KEY_LEFT:
vKey = KEY_LEFT;
break;
case MR_KEY_RIGHT:
vKey = KEY_RIGHT;
break;
case MR_KEY_UP:
vKey = KEY_UP;
break;
case MR_KEY_DOWN:
vKey = KEY_DOWN;
break;
case MR_KEY_SELECT:
vKey = KEY_OK;
break;
case MR_KEY_SOFTLEFT:
vKey = KEY_LS;
break;
case MR_KEY_SOFTRIGHT:
vKey = KEY_RS;
break;
}
if(ev == MR_KEY_PRESS)
keyState |= vKey;
if(ev == MR_KEY_RELEASE)
keyState &= ~vKey;
return MR_SUCCESS;
}
Опять же, отлаживать MRP-приложение под реальным устройством проблематично, поэтому платформозависимый код должен быть минимальным. Кроме того, обратите внимание, что некоторые функции в MRP зависят от библиотек-плагинов. Линкер слинкует вашу программу, но на реальном устройстве их вызов вывалится в SIGSEGV и софтресет устройства. Также нельзя использовать ничего из стандартной библиотеки именно в стандартных заголовочниках (т. е. stdlib.h, string.h и т. д.), часть стандартной библиотеки реализовывается MRP и дефайнится в mrc_base.h
void* sysAlloc(int len)
{
return mrc_malloc(len);
}
void sysFree(void* ptr)
{
mrc_free(ptr);
}
int sysRand()
{
mrc_sand(mrc_getUptime());
return mrc_rand();
}
Что интересно, защиты памяти толком нет. Если приложение падает в SIGSEGV или портит память — систему, судя по всему, ребутит Watchdog. Защиты памяти никакой, можно напрямую читать и писать в память ядра, а также писать в регистры периферии чипсета. jpegqs, покумекаем над этим? :)
Переходим к рендереру. Тут буквально две функции, gClearScreen очищает экран, а gDrawBitmap рисует произвольный спрайт с форматом пикселя RGB565. В качестве ROP используется BM_TRANSPARENT — таким образом, mrc_bitmapShowEx будет использовать левый верхний пиксель в качестве референсного цвета для реализации прозрачности без альфа-блендинга.
void gClearScreen(CColor* color)
{
mrc_clearScreen(color->r, color->g, color->b);
}
void gDrawBitmap(CBitmap* bmp, int x, int y)
{
mrc_bitmapShowEx((uint16*)bmp->pixels, x, y, bmp->width, bmp->width, bmp->height, BM_TRANSPARENT, 0, 0);
}
Да, всё вот так просто. Рантайм теперь запускается на реальных китайских девайсах и работает стабильно.
❯ VXP
Теперь переходим к VXP — платформе не менее неоднозначной, чем MRP. Пожалуй, начать стоит с того, что VXP существует аж в трёх версиях: MRE 1.0, MRE 2.0 и MRE 3.0. В MRE 2.0 и выше появилась поддержка плюсов (в MRE 1.0 только Plain C) и довольно интересного GUI-фреймворка, MRE 1.0 же предлагает реализовывать гуй самому. Платформа распространена на большинстве кнопочных телефонов и смарт-часиков на чипсетах MediaTek, примерно начиная с 6235 и заканчивания 6261D. SDK можно скачать вот здесь (см MRE_SDK_3.0).
VXP сам по себе более функционален чем MRE, поскольку ориентирован исключительно на телефоны с чипсетами MediaTek. Но что самое приятное — есть доступ к уарту без каких либо костылей! То есть, если сделать GPIO-мост на условной ESP32, то мы можем получить готовый мощный МК с клавиатурой, кнопками, дисплеем, звуком и т. д. Звучит не хило, да? Кроме того, у нас есть доступ и к BT, и к GPRS, и к SMS без каких либо ограничений.
Однако в бочке мёда нашлась и ложка дёгтя: для компиляции MRE-приложений необходимо накатывать и крякать довольно старый компилятор ADS, который сам по себе поддерживает только C89 (например, нет возможности объявить переменную в объявлении цикла или середине функции, только в начале, как в Pascal). ADS уже вроде как Abandonware, так что это вроде не наказуемо…, но всё равно неприятно.
Кроме того, на некоторых девайсах (в основном, фирменных Nokia а-ля 225), прошивка требует подписи у всех бинарников, либо если бинарник отладочный, то должна быть привязка к конкретному IMSI.
К тому же, каждая программа должна фиксированно указывать в заголовке, сколько Heap-памяти ей необходимо выделить. Оптимальный вариант — ~500Кб, тогда приложение запустится вообще на всех MRE-телефонах.
Зато у VXP есть адекватный симулятор под Windows. Но зачем он нам, если у нас порт игры под Win32 есть? :)
Начинаем с инициализации приложения. В процессе вызова точки входа, приложение должно назначить обработчики системных событий, коих бывает несколько. Для обработки ввода и базовых событий хватает всего три: sysevt (события окна), keyboard (физическая клавиатура. Есть полная поддержка QWERTY-клавиатур), pen (тачскрин).
void vm_main(void) {
layers[0] = -1;
gameStart();
vm_reg_sysevt_callback(handle_sysevt);
vm_reg_keyboard_callback(handle_keyevt);
vm_reg_pen_callback(handle_penevt);
}
Переходим к обработчику системных событий. Обратите внимание, что MRE-приложения могут работать в фоне, из-за чего необходимо ответственно подходить к созданию и освобождению объектов. Что важно усвоить с самого начала — в MRE нет понятия процессов и защиты памяти, как на ПК и полноценных смартфонах. Любая программа может попортить память или стек ОС, более того, программа использует аллокатор остальной системы, поэтому если ваша программа не «убирает» после себя, данные останутся в памяти со временем приведут к зависанию. Впрочем, WatchDog делает свою работу быстро и приводит телефон в чувство (софтресетом) за 1–2 секунды. Но как и в случае с MRE, есть приятный бонус: прямой доступ к регистрам чипсета :)
void handle_sysevt(VMINT message, VMINT param) {
switch (message) {
case VM_MSG_CREATE:
case VM_MSG_ACTIVE:
layers[0] = vm_graphic_create_layer(0, 0,
vm_graphic_get_screen_width(),
vm_graphic_get_screen_height(),
-1);
vm_graphic_set_clip(0, 0,
vm_graphic_get_screen_width(),
vm_graphic_get_screen_height());
screenBuf = vm_graphic_get_layer_buffer(layers[0]);
gTimer = vm_create_timer(16, onTimerTick);
break;
case VM_MSG_PAINT:
break;
case VM_MSG_INACTIVE:
case VM_MSG_QUIT:
if( layers[0] != -1 )
vm_graphic_delete_layer(layers[0]);
vm_delete_timer(gTimer);
break;
}
}
Переходим к обработке событий с кнопок. Тут всё абсолютно также, как и на MRE, лишь имена дейфанов поменялись :)
void handle_keyevt(VMINT event, VMINT keycode) {
int vKey = 0;
switch(keycode)
{
case VM_KEY_LEFT:
vKey = KEY_LEFT;
break;
case VM_KEY_RIGHT:
vKey = KEY_RIGHT;
break;
case VM_KEY_UP:
vKey = KEY_UP;
break;
case VM_KEY_DOWN:
vKey = KEY_DOWN;
break;
case VM_KEY_OK:
vKey = KEY_OK;
break;
case VM_KEY_LEFT_SOFTKEY:
vKey = KEY_LS;
break;
case VM_KEY_RIGHT_SOFTKEY:
vKey = KEY_RS;
break;
}
if(event == VM_KEY_EVENT_DOWN)
keyState |= vKey;
if(event == VM_KEY_EVENT_UP)
keyState &= ~vKey;
}
И наконец-то, к графике! Пожалуй, стоит сразу отметить, что более 20–30 FPS на большинстве устройств вы не получите даже с прямым доступом к фреймбуферу. Похоже, это связано с тем, что в MRE довольно замороченная графическая подсистема с поддержкой альфа-канала (только фиксированного во время вызова функции отрисовки картинки/примитивов, сам пиксельформат всегда RGB565) и нескольких слоев. Кроме того, похоже есть ограничения со стороны контроллера дисплея.
Софтварный вывод спрайтов
Изначально, MRE предполагает то, что все картинки в программе хранятся в формате… GIF. Да, весьма необычный выбор. Однако для работы с пользовательской графикой, есть возможность блиттить произвольные картинки напрямую из RAM. Вот только один нюанс — посмотрите внимательно не объявление следующей функции:
void vm_graphic_blt(
VMBYTE * dst_disp_buf,
VMINT x_dest,
VMINT y_dest,
VMBYTE * src_disp_buf,
VMINT x_src,
VMINT y_src,
VMINT width,
VMINT height,
VMINT frame_index
);
dst_disp_buf — это целевой RGB565-буфер. Логично предположить, что и src_disp_buf — тоже обычный RGB565-буфер! Но как бы не так. Документация крайне скудная, пришлось посидеть и покумекать, откуда в обычном 565 буфере возьмется индекс кадра. С подсказкой пришёл пользователь 4pda Ximik_Boda — он скинул структуру-заголовок, которая идёт перед началом каждого кадра. В документации об этом не сказано ровным счетом ничего!
Сначала я реализовал софтовый блиттинг, но он безбожно лагал. Мне стало интересно, почему нативный blt быстрее и… вопросы отпали после того, как я поглядел в ДШ чипсета: тут есть аппаратный блиттинг. И даже с ним девайс не может выдать более 20FPS!
Для реализации более-менее шустрого вывода графики, необходимо сначала создать канвас (фактически, Bitmap в MRE), создать и привязать к нему layer, получить указатель на буфер слоя и только потом скопировать туда нашу картинку. Да, вот так вот замороченно:
void gPrepareBitmap(CBitmap* bmp)
{
VMINT cnvs = vm_graphic_create_canvas(bmp->width, bmp->height);
VMINT layer = vm_graphic_create_layer_ex(0, 0, bmp->width, bmp->height, VM_COLOR_888_TO_565(255, 0, 255), VM_BUF, vm_graphic_get_canvas_buffer(cnvs));
memcpy(vm_graphic_get_layer_buffer(layer), bmp->pixels, bmp->width * bmp->height * 2);
vm_graphic_canvas_set_trans_color(cnvs, VM_COLOR_888_TO_565(255, 0, 255));
bmp->_platData = (void*)cnvs;
}
void gDrawBitmap(CBitmap* bmp, int x, int y)
{
int i, j;
if(!bmp->_platData)
gPrepareBitmap(bmp);
vm_graphic_blt(screenBuf, x, y, vm_graphic_get_canvas_buffer((VMINT)bmp->_platData), 0, 0, bmp->width, bmp->height, 1);
}
И только после этого всё заработало достаточно шустро :)
В остальном же платформа довольно неплохая. Да, без болячек не обошлось, но всё же перспективы вполне себе есть.
На данный момент, этого достаточно для нашей игры.
❯ Пишем геймплей
Рантайм у нас есть, а значит, можно начинать писать игрушку. Хоть пишем мы на Plain-C, я всё равно из проекта в проект использую ± одну и ту же архитектуру относительно системы сущностей, стейтов и т. п. Поэтому центральным объектом у нас станет CWorld, который хранит в себе на пулы с указателями на другие объектами в сцене, а также игрока и его состояние:
typedef struct
{
CPlayer player;
int nextSpawn; // In ticks
CEnemy* enemyPool[ENEMY_POOL_SIZE];
CProjectile* projectilePool[PROJECTILE_POOL_SIZE];
} CWorld;
Система стейтов простая и понятная — фактически, между состояниями передавать ничего не нужно. При нажатии в главном меню на «старт», нам просто необходимо проинициализировать мир заново и начать геймплей, при смерти игрока — закинуть его обратно в состояние меню. Стейты представляют из себя три указателя на функции: переход (инициализация), обновление и отрисовка.
typedef void(CGameStateCallback)();
Поскольку мы хотим некоторой гибкости при создании новых классов противников, то вводим структуру CEnemyClass, которая описывает визуальную составляющую врагов и их флаги — могут ли они стрелять по игроку или просто летят вниз (астероиды), как они передвигаются (зигзагами например) и т. п.
typedef struct
{
CBitmap* sprite;
int speed;
int maxHealth;
int flags;
int projectileDamage;
int contactDamage;
} CEnemyClass;
typedef struct
{
CEnemyClass* _class;
int health;
int nextAttack;
int x, y;
} CEnemy;
// Asteroid
enemyClasses[0].sprite = &sprEnemy1;
enemyClasses[0].flags = ENEMY_FLAG_NONE;
enemyClasses[0].maxHealth = 45;
enemyClasses[0].contactDamage = 15;
enemyClasses[0].speed = 2;
// Regular unit
enemyClasses[1].sprite = 0;
enemyClasses[1].flags = ENEMY_FLAG_CAN_SHOOT;
enemyClasses[1].contactDamage = 20;
enemyClasses[1].projectileDamage = 20;
// ZigZag shooter
enemyClasses[2].sprite = 0;
enemyClasses[2].flags = ENEMY_FLAG_CAN_SHOOT | ENEMY_FLAG_ZIG_ZAG_MOVEMENT;
enemyClasses[2].contactDamage = 20;
enemyClasses[2].projectileDamage = 10;
А также описываем игрока:
typedef struct
{
int health;
int frags;
int score;
int speed;
int nextAttack;
int x, y;
} CPlayer;
Всё! Для текущего уровня реализации игры этого достаточно :)
Переходим к реализации игровой логики. Вообще, динамический аллокатор в играх для китайских платформ лучше использовать как можно меньше. Heap’а довольно мало (~600Кб), да и не совсем понятно, как этот аллокатор реализован, есть вероятность, что используется аллокатор и куча основной ОС.
Начинаем с реализации полёта кораблика. Для этого он должен реагировать на стрелки и не улетать за границы экрана, а ещё для красоты он должен «вылетать» из нижней границы экрана при старте игры:
// Player update
int keys = inGetKeyState();
int horizInput = 0;
if (keys & KEY_LEFT)
horizInput = -1;
if (keys & KEY_RIGHT)
horizInput = 1;
if(world.player.y > gGetScreenHeight() - sprPlayer.height - 16)
world.player.y -= world.player.speed;
world.player.x += horizInput * world.player.speed;
world.player.x = clamp(world.player.x, 0, gGetScreenWidth() - sprPlayer.width);
Переходим к динамическим пулам с объектами. Как вы уже заметили, их всего два — враги и летящие снаряды. Реализация спавна врагов/снарядов простая и понятная: мы обходим каждый элемент пула, если указатель на объект не-нулевой, значит объект всё ещё жив и используется на сцене. Если нулевой — значит ячейка свободна и можно заспавнить новый объект:
CEnemy* spawnEnemy(CEnemyClass* _class)
{
int i;
for (i = 0; i < sizeof(world.enemyPool) / sizeof(CEnemy*); i++)
{
CEnemy* enemy;
if (world.enemyPool[i])
continue;
enemy = (CEnemy*)sysAlloc(sizeof(CEnemy));
memset(enemy, 0, sizeof(CEnemy));
enemy->_class = _class;
enemy->health = _class->maxHealth;
enemy->x = randRange(0, gGetScreenWidth() - _class->sprite->width);
enemy->y = randRange(-_class->sprite->height * 4, -_class->sprite->height);
return world.enemyPool[i] = enemy;
}
return 0;
}
При обходе пула во время обновления кадра, мы обновляем состояние каждого объекта и если его функция Think вернула true, значит объект больше не нужен и его нужно удалить:
// Enemy update
for (i = 0; i < sizeof(world.enemyPool) / sizeof(CEnemy*); i++)
{
if (world.enemyPool[i])
{
if (enemyThink(world.enemyPool[i]))
{
sysFree(world.enemyPool[i]);
world.enemyPool[i] = 0;
}
}
}
А вот и реализация Think:
// If returns true, then enemy should be destroyed
bool enemyThink(CEnemy* enemy)
{
enemy->y += enemy->_class->speed;
if (enemy->y > gGetScreenHeight() || enemy->health <= 0)
return true;
return false;
}
Но кораблики должны же откуда-то появляться! Для этого у нас есть переменная nextSpawn, которая позволяет реализовать самый простой тип спавнера — относительно времени (или в нашем случае тиков):
world.nextSpawn--;
if (world.nextSpawn < 0)
{
// randRange(0, 3)
CEnemy* enemy = spawnEnemy(&enemyClasses[0]);
world.nextSpawn = randRange(40, 70);
}
Результат: мы уже можем полетать и поуворачиваться от вражеских корабликов! Но для игры этого пока маловато. Давайте добавим возможность стрелять лазерами! Для этого реализуем обход пула снарядов и проверим на столкновение каждый заспавненный вражеский кораблик: если снаряд столкнулся с корабликом, то мы отнимем у кораблика HP, а снаряд — задеспавним. Если у кораблика осталось меньше или 0 HP, то в следующем кадре он будет убран из сцены.
// Projectile update
for (i = 0; i < sizeof(world.projectilePool) / sizeof(CProjectile*); i++)
{
if (world.projectilePool[i])
{
world.projectilePool[i]->y += world.projectilePool[i]->dir * world.projectilePool[i]->speed;
for (j = 0; j < sizeof(world.enemyPool) / sizeof(CEnemy*); j++)
{
if (world.enemyPool[j])
{
if (aabbTest(world.projectilePool[i]->x, world.projectilePool[i]->y, sprLaser.width, sprLaser.height,
world.enemyPool[j]->x, world.enemyPool[j]->y, world.enemyPool[j]->_class->sprite->width, world.enemyPool[j]->_class->sprite->height))
{
world.enemyPool[j]->health -= world.projectilePool[i]->damage;
sysFree(world.projectilePool[i]);
world.projectilePool[i] = 0;
break;
}
}
}
}
}
Реализация стрельбы тоже совсем простая и также зависит от таймера:
if (keys & KEY_OK && world.player.nextAttack < 0)
{
spawnProjectile(world.player.x + (sprPlayer.width / 2), world.player.y, -1, 15, 35);
world.player.nextAttack = 15;
}
world.player.nextAttack--;
Смотрим на результат: Уже что-то напоминающее игру! Осталось лишь добавить подсчет очков, менюшку, разные виды противников, возможно какие-то бонусы и у нас будет готовая простенькая аркада. В целом, выше приведена достаточно неплохая архитектура для простых 2D-игр на Plain C. Фактически, она может быть хорошей базой и для ваших игр: в теме о китах на 4pda я встречал немало людей, которые банально не знали, с чего начать.
❯ Что у нас получилось?
Но без тестов на реальных устройствах материал не был бы таким интересным! Поэтому давайте протестируем игру на двух реальных телефонах, как вы уже догадались, один — Nokla TV E71, а второй — клон Nokia 6700, который подарил мне мой читатель Никита.
На TV E71 игра идёт не сказать что очень бодро. Кадров 15 точно есть, что, учитывая разрешение 240×320, весьма неплохо для такого девайса.
На 6700,, даже учитывая более низкое разрешение — 176×220, дела примерно также — ~15FPS! Но поиграть всё равно можно. Уже хотите написать «автор наговнокодил, а теперь ноет из-за низкого FPS»? Ан-нет, я попробовал игры сторонних разработчиков — они идут примерно также:(К сожалению, таковы аппаратные ограничения устройства.
Исходный код игры с Makefile’ами и файлами проектов для Visual Studio и MRELauncher доступны на моём GitHub. Свободно изучайте и используйте его в любых целях :)
❯ Заключение
Но в остальном же, демка получилась довольно прикольной, как и сам опыт программирования для китайских телефонов. В общем и целом, китайцы пытались максимально упростить API и привлечь разработчиков к своей платформе. Если ради примера взглянуть на API для Elf’ов на Motorola, можно ужаснуться от state-based архитектуры платформы P2K. А тут тебе init, event, draw — и всё!
Но популярности помешала непонятная закрытость платформы, костыльный запуск программ, отсутствие нормального симулятора. А ведь сколько фишек было: даже возможность писать и читать память ядра!
А вы как считаете? Можно ли вдохнуть в китайские кнопочники новую жизнь, узнав о наличии возможности запуска нативного кода на них?
Крутые девайсы на фоне ковра, который старше автора в два раза. Всё как вы любите:)
P. S.: Друзья! Время от времени я пишу пост о поиске различных китайских девайсов (подделок, реплик, закосов на айфоны, самсунги, сони, HTC и т. п.) для будущих статей. Однако очень часто читатели пишут «где ж ты был месяц назад, мешок таких выбросил!», поэтому я решил в заключение каждой статьи вставлять объявление о поиске девайсов для контента. Есть желание что-то выкинуть или отправить в чермет? Даже нерабочую «невключайку» или полурабочую? А может, у этих девайсов есть шанс на более интересное существование! Смотрите в соответствующем посте, что я делаю с китайскими подделками на айфоны, самсунги, макбуки и айпады! Да и чего уж там говорить: эта статья уже сама по себе весьма наглядный пример!
Понравился материал? У меня есть канал в Телеге, куда я публикую бэкстейдж со статей, всякие мысли и советы касательно ремонта и программирования под различные девайсы, а также вовремя публикую ссылки на свои новые статьи. 1–2 поста в день, никакого мусора!