На помойку? Никак нет! Пишем нативные приложения для дешевых китайских телефонов
Средний 23 мин Блог компании Timeweb Cloud Ненормальное программирование *C *Смартфоны DIY или Сделай сам Туториал
Если сейчас приехать в пункт приема металлолома, то можно обнаружить просто огромные кучи различных телефонов и прочих электронных «отходов», которые стоят под открытым небом и ждут, когда придёт их черёд окончательного разложения. Однако при ближайшем рассмотрении выясняется, что многие девайсы оказываются полностью рабочими даже после недельного лежания под палящим солнцем и проливными дождями, а сдали их в чермет по причинам «не нужен, надоел, купил новый» и т. п. Я не считаю это правильным, ведь даже в простые кнопочные звонилки имеется возможность вдохнуть новую жизнь, если знать один интересный, но малоизвестный факт: для них можно писать нативные приложения на C и использовать железо телефона в своих целях. А это, на минуточку, как минимум: дисплей с подсветкой, вибромотор, динамик, клавиатура и GSM-радиомодуль с возможностью выхода в сеть. Сегодня мы с вами: узнаем, на каких аппаратных платформах работают китайские телефоны, какие существуют программные платформы и где взять для них SDK, а в практической части мы напишем 2D-игру с нуля, которая будет работать на многих китайских кнопочниках. Интересно? Тогда жду вас под катом!
Содержание:
- Не J2ME едины
- Аппаратные ресурсы
- Кроссплатформенный рантайм
- Кроссплатформенный рантайм: Win32
- Кроссплатформенный рантайм: MRE
- Кроссплатформенный рантайм: VXP
- Наконец-то пишем игру
- Тестируем на реальных девайсах
- Заключение
Содержание статьи:
❯ Не 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. Летаем по космосу, и стреляем по враждебным корабликам, стараясь не попасть под вражеские лазеры. Всё просто и понятно 🙂
❯ Практическая часть: Кроссплатформенный рантайм
Итак, что нам необходимо от абстракции для такой простой игры? Давайте посмотрим:
- Графика: очистка экрана, отрисовка спрайтов с прозрачностью (без альфа-блендинга, только колоркей), отрисовка текста. При возможности, желательно использовать нативное API системы для рисования графики, а не городить собственный блиттер. Формат пикселя фиксирован: RGB565 (65к цветов).
- Ресурсы: хранятся в одном образе с основной игрой. Фактически, все ресурсы упакованы в виде обычных массивов байт в заголовочных файлах. Я пользуюсь вот этой тулзой для конвертации спрайтов в массивы байтов.
- Звук: воспроизведение хотя-бы одного WAV-потока. Почему одного? Потому что далеко не на всех платформах есть доступ к аппаратному микшеру… да и вообще не везде есть прямой доступ к PCM (привет MRP), иногда разработчики ограничиваются лишь одним каналом для WAV-звука без возможности воспроизведения нескольких аудиофайлов одновременно.
- Ввод: абстракция от клавиатуры классического моноблока: стрелки, OK, левый и правые софткеи.
- Стандартная библиотека: не на всех платформах можно вызывать функции напрямую из stdlib. Как минимум в MRP и, например, «эльфах» для Motorola, нет возможности вызывать аллокатор, rand и некоторые другие функции из обычных заголовочников стандартной библиотеки. На таких платформах, системные инклуды дефайнами подменяют стандартные функции на своих реализации:
#define malloc system_alloc #define free system_free
Но если у нас игра кроссплатформенная, то и платформозависимые инклуды мы использовать не будем.
Выглядит всё достаточно просто, верно? Примерно такого набора функций хватит для нашей игры:
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 failedn»); exit(-1); } hGL = wglCreateContext(hPrimaryDC); wglMakeCurrent(hPrimaryDC, hGL); sysLogf(«Renderer: %sn», glGetString(GL_RENDERER)); sysLogf(«Vendor: %sn», glGetString(GL_VENDOR)); sysLogf(«Version: %sn», 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 frameworkn»); 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%dn», 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=bingame.mrp
description=ScrollShooter game by monobogdan
include=D:/SKYSDK/include/,D:/SKYSDK/include/plugins/
config=mtk240
[config_mtk240]
output=bingame.mrp
bmp_mode=normal
plat=mtk
[files]
file42 = platformplat_mrc.c
file44 = game.c
file45 = resourcesgamedata.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 поста в день, никакого мусора!
Возможно, захочется почитать и это:
- ➤ Суровый моддинг из нулевых: как энтузиасты увеличивали объём ОЗУ в коммуникаторах?
- ➤ Практическое руководство по Rust. 1/4
- ➤ Универсальная плата для E-Ink экранов. Ч1. Разработка системы питания
- ➤ Как проклятие невидимой стены ждало меня 20 лет
- ➤ 35 лет SimCity
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста. Полезный материал? 71.43% Да. Не знал о том, что под китайцев можно писать свои приложения, да ещё и нативные! 40 23.21% Да, но без примера реальных проектов, людей к платформе не привлечь. Ждём примера использования такого девайса в качестве МК! 13 5.36% Нет. Гамно, аффтар мудак. 3 Проголосовали 56 пользователей. Воздержались 10 пользователей. Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста. Были ли у вас такие китайчики? 33.33% Угу, были. 19 66.67% Нет, не было. 38 Проголосовали 57 пользователей. Воздержались 4 пользователя. Теги:
- timeweb_статьи
- bodyawm_ништячки
- девайсы
- гаджеты
- ненормальное программирование
- китайские телефоны
- китайцы
- дешевые телефоны
- mediatek
- mtk
- spreadtrum
- mrp
- mre
- vxp
- телефоны
- кнопочники
- кнопочные телефоны
- моддинг
- программирование
- c
- c++
- игры
- геймдев
- 2D
- ретро
- железо
Хабы:
- Блог компании Timeweb Cloud
- Ненормальное программирование
- C
- Смартфоны
- DIY или Сделай сам
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку Задонатить