Пишем игру для игровой ретро-консоли Anbernic на C++

Дисклеймер

Сразу скажу, что почти всё, что описано в этой статье, я делал первый раз в жизни, поэтому я не мог не совершить ошибок. Некоторые мои решения и заявления могут быть некорректными. Воспринимать эту статью как туториал можно только на свой страх и риск. Я просто хочу показать, что и как у меня получилось. Для нормального восприятия этой статьи вы должны обладать базовыми навыками программирования на C++ и хотя бы общим представлением о том, что из себя представляет операционная система Linux.

Как всё начиналось

Не так давно ко мне в руки попала игровая консоль Anbernic RG35XX SP. Это портативная игровая консоль на базе четырёхъядерного процессора Allwinner H700 с ядрами Cortex-A53, которая имеет на борту операционную систему OFW (дистрибутив Linux, основанный на Ubuntu). Консоль предназначена для запуска ретро игр на эмуляторе Retroarch. Этот эмулятор поддерживает эмуляцию множества популярных игровых консолей 90х и 2000х и позволяет запускать ТЫСЯЧИ популярных игр, которые можно устанавливать на SD карточку.

Anbernic RG35XX SP и Anbernic RG40XXV
Anbernic RG35XX SP и Anbernic RG40XXV

Так сложилось, что я не игроман и играть в игрушки меня особо не тянет, зато тянет к созданию интересных и полезных (хотя бы в контексте самообразования) проектов. Для меня, как для формирующегося инженера встраиваемых систем, умение писать игры для какой-то конкретной игровой консоли — это абсолютно бесполезный навык, а вот умение писать программы на C++ с использованием кроссплатформенных библиотек для дистрибутивов Linux, которые предназначены для работы на ARM процессорах с ядрами семейства Cortex-A — это чуть ли не основной навык, которым я должен обладать, поэтому писать игру я буду именно под Linux.

Первые шаги

Для начала надо определиться с инструментарием. Надо разобраться, как вообще подступиться к этой консоли.

В качестве среды разработки я выбрал Visual Studio с расширением Visual C++ for Linux Development. Это расширение позволяет создавать проекты под Linux.

Для сборки проекта потребуется кросс-компилятор или прямое подключение к удалённому устройству через SSH соединение. Я выбрал второй вариант, чтобы избежать сюрпризов. Нужно закинуть на SD карточку приложение, которое будет запускать на игровой консоли SSH сервер. Это приложение я нашёл на GitHub человека, которому хочется сказать огромное спасибо за то, что он опубликовал его специально для Anbernic. Затем нужно подключить свой ПК и консоль к одной точке доступа Wi-Fi. К моему домашнему роутеру Anbernic подключаться не захотел, поэтому пришлось раздать интернет с телефона.

Теперь надо открыть решение и задать адрес удаленного компьютера, этот адрес надо искать в сетевых настройках консоли. Должно получиться что-то вроде этого:

Параметры подключения
Параметры подключения

Теперь надо включить Anbernic, перейти в [Приложения→APPS] и запустить  скрипт запуска EnableSSH. Это приложение включит сервер, потом произойдёт автоматический выход обратно в меню. При этом скрипт запуска EnableSSH должен смениться на DisableSSH. Проверим, установилось ли соединение.

[Вид→Терминал→PowerShell→root@циферки]

Должен открыться терминал, и в нем должно произойти что-нибудь хорошее и похожее на успех.

Терминал открытый через SSH
Терминал открытый через SSH

Теперь мы можем всё. Можно устанавливать на консоль различные библиотеки, файлы и запускать удалённую сборку через удаленный отладчик gdb, который уже должен быть, а если его нет, то установите его через терминал.

Продолжим настройку проекта. Выбираем тип конфигурации Release и платформу ARM64. Заходим в [C++→Общие→Дополнительные каталоги включаемых файлов] и вписываем пути к заголовочным файлам на вашей консоли. Посмотреть их можно через терминал или через проводник на самой консоли.

Дополнительные каталоги включаемых файлов
Дополнительные каталоги включаемых файлов

Ещё надо зайти в [Компоновщик→Общие→Дополнительные каталоги библиотек] и вписать туда пути к динамическим библиотекам. Затем переходим в [Компоновщик→Ввод→Дополнительные зависимости] и вписываем туда какие-нибудь дополнительные зависимости. В моем случае это были зависимости библиотеки SDL2.

Дополнительные каталоги библиотек
Дополнительные каталоги библиотек
Дополнительные зависимости
Дополнительные зависимости

На этом этапе проект уже должен собираться и появляться где-то в папке /root/projects, точный путь надо смотреть в свойствах. Но вы наверняка заметите, что IntelliSense чудит. Решение этой проблемы нашлось на сайте Microsoft:

Решение проблемы с IntelliSense
Решение проблемы с IntelliSense

После установки всех пакетов надо закрыть студию и зайти в неё ещё раз, теперь IntelliSense должен увидеть все библиотеки на удалённой машине.

Теперь, когда у нас доступен терминал, сборка проекта и IntelliSense, можно двигаться дальше.

Выбор библиотек

Вы можете написать консольное приложение, но на экране игровой консоли вы ничего не увидите. Заметить, что программа работает, можно только через терминал из студии. Будем сразу делать графическое приложение.

Для графики лучше всего подойдёт какая-нибудь популярная и кроссплатформенная библиотека. Использование кроссплатформенной библиотеки позволило мне создать зеркальный проект для Windows и тестировать игру прямо на своём рабочем ПК без виртуальных машин и прочих неудобств. Я использовал графическую библиотеку SDL2, она уже установлена на этих консолях, поэтому проект должен собраться сразу.

Организация проекта

Так как я сел писать для этой консоли достаточно приятный клон игры Google Dino, то писать всю игру в main не вариант, нужна какая-то организация проекта.

Мои познания о паттернах проектирования игр оставляют желать лучшего. Я поверхностно изучил самые популярные паттерны и слепил из кусочков понятого свой собственный подход к организации проекта. Не могу сказать, что получилось идеально, но у меня с самого начала была какая-то тактика и я её придерживался)))

В корне проекта лежат папки Resource и Sourse. В папке Resource находятся все ресурсы, например текстуры. В папке Sourse находится файл main.cpp и 3 основные папки — Graphics, Event и Core.

  • Папка Graphics отвечает за графику, в ней есть класс Window и Renderer. Класс Window занимается созданием окна, а класс Renderer занимается графическими примитивами и рисованием в окне

  • Папка Event отвечает за обработку событий, в ней есть класс, который предоставляет удобный API для обработки событий клавиатуры или геймпада

  • Папка Core содержит весь код, который касается конкретной программы

Я организовал логику своей игры на основе множества вложенных друг в друга конечных автоматов. Переход между состояниями этих автоматов может происходить по событию или по таймеру. Может показаться, что я переусложнил то, что можно было сделать проще и красивее, но с ростом проекта становится понятно, что такой подход помогает не запутаться.

Внутри некоторых состояний автоматов происходит вызов методов или функций, которые являются игровыми циклами. Причем их может быть несколько. Например, в одном может отображаться приветственный экран, в другом может работать логика всей игры или логика только одного уровня игры. Другие игровые циклы могут отвечать за другие уровни или вовсе отсутствовать. Разделять программу на несколько игровых циклов следует, если эти циклы являются абсолютно самостоятельными, например, 2 уровня игры могут быть вообще не связаны друг с другом и восприниматься как две совершенно независимые друг от друга программы.

Внутри каждого игрового цикла происходит обработка всей логики текущего состояния. Если это приветственный экран, то происходит анимация приветственного экрана, а если это игра, то происходит отрисовка фона, персонажа, препятствий и т.п.

Чтобы игровой цикл не был раздут на тысячи строк, я повысил уровень абстракции и спрятал весь функционал конкретных сущностей в классы самих сущностей и вывел наружу только API для управления этими сущностями. Например, для того, чтобы динозаврик бежал и топал ножками, достаточно вызвать метод Run (), а чтобы появилась анимация прыжка, достаточно вызвать метод Jump ().

Описание игры

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

Важным моментом я считаю принцип, по которому реализованы все перемещения в игре. Нельзя просто приписать к координатам инкремент и рассчитывать на то, что все будет работать хорошо. На скорость всех процессов в игре будет влиять производительность процессора, такого быть не должно. Все процессы в игре необходимо привязать к главному таймеру. Надо задать переменную, определяющую скорость в каких-то единицах, например, в пикселях в секунду. Затем надо вычислять разницу во времени [dt] между текущей итерацией игрового цикла и предыдущей. Исходя из [dt] надо рассчитывать перемещение [dx], причём переменная[dt] может быть целочисленной, потому что время считается в миллисекундах, а вот переменная [dx] должна быть вещественной, потому что разница в перемещении может оказаться дробной. Переменная [dx] должна прибавляться к текущей координате [x], которая тоже должна быть вещественной переменной. После всех расчётов можно округлить координату до целых пикселей.

Я очень старался написать такой код, который будет работать с одинаковой скоростью и при 1000Гц, и при 10Гц. У меня это получилось. Производительность процессора не влияет на геймплей.

Говоря о частоте кадров, важно учесть ещё один важный момент. Эта программа совсем не нагружает ни центральный процессор, ни видеопроцессор, поэтому частота работы главного цикла долбится в потолок 1000Гц. Такая бешеная скорость работы нам ни к чему. Несмотря на то, что экран работает с частотой 60Гц, центральный процессор и видеопроцессор перерисовывают буфер кадра со своей максимальной скоростью и, судя по показаниям монитора ресурсов, можно подумать, что я запустил «Киберпанк», а не Гугл-динозаврика. Такое расточительство вычислительных и энергетических ресурсов неприемлемо. Самым простым решением этой проблемы будет выставление флага SDL_RENDERER_PRESENTVSYNC. Этот флаг синхронизирует частоту перерисовки буфера кадра с частотой работы дисплея. В моём случае частота получилась в районе 60Гц. Теперь потребление ресурсов находится на нормальном уровне, и вентиляторы на видеокарте больше не сдувают с кресла.

Есть ещё пара моментов, касающихся библиотеки SDL2.

Для отображения .png текстур понадобится библиотека SDL_image и её зависимости. Обратите на это внимание.

Библиотека SDL содержит метод IMG_Load (), который нужен для загрузки поверхности из файла. В этот метод надо передавать путь к текстурам, но, скорее всего, вы столкнётесь с проблемой.  Относительный путь вы наверняка введёте неправильно, и картинка не загрузится. Придётся указывать полный путь. Но указание полного пути к ресурсам из кода — это очень нехорошая практика. Можно было бы забить на это, ведь в конечном итоге приложение все равно должно находиться в строго определённой папке на консоли и путь к ресурсам будет заранее известен, но я решил всё-таки устранить эту неприятность.

В этой библиотеке есть метод SDL_GetBasePath (), который возвращает путь к текущей директории. Немного строковой магии, и мы получаем строку, содержащую правильный путь к ресурсам без жёсткой привязки к директории.

void GameDino::Create()
{
	if (IMG_Init(IMG_INIT_PNG) == 0) { std::cout << "Error Init Image!" << std::endl; }

	char* basePath = SDL_GetBasePath();
	if (!basePath) std::cout << "Error getting base path: " << SDL_GetError() << std::endl;
	std::string baseDir(basePath);
	SDL_free(basePath);
	std::string imagePath_GameTexture = baseDir + "res/img/GameTexture.png";

	surface = IMG_Load(imagePath_GameTexture.c_str());
	if (surface == nullptr)  std::cout << "Error Open Image!" << std::endl;
	else game_texture = SDL_CreateTextureFromSurface(Renderer::renderer, surface);
	SDL_FreeSurface(surface);
}

Щепотка user friendly

Запускать исполняемый файл .out можно из проводника. Но исполняемый файл в проводнике не самостоятельный, его не получится закинуть на SD карточку и запустить. Мне бы хотелось, чтобы можно было просто закинуть папку с файлом на SD карточку, вставить её в аналогичную игровую консоль и запустить игру как обычное нативное приложение стандартным способом. Причём запускаться оно должно не из проводника, а из приложения APPS, которое установлено на приставках Anbernic специально для запуска нативных приложений.

Чтобы исполняемый файл нормально запустился на другой такой же консоли из приложения APPS, все необходимые динамические библиотеки и зависимости должны лежать по стандартному пути. В своей игре я использовал только предустановленные библиотеки, поэтому игра должна запуститься на аналогичной консоли со стандартной прошивкой.

Из приложения APPS нельзя напрямую запустить исполняемый файл .out. Для подгрузки библиотек и запуска исполняемого файла необходимо создать bash скрипт. Именно bash скрипт будет виден из приложения APPS, и именно его надо запускать. Возможно, потребуется выставить скрипту бит исполняемого файла, но по каким-то причинам мне это действие не потребовалось.

#!/bin/bash

SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

EXECUTABLE_LIB="lib"
EXECUTABLE_SUBDIR="Dino"
EXECUTABLE_NAME="Dino.out"

EXECUTABLE_DIR="$SCRIPT_DIR/$EXECUTABLE_SUBDIR"
EXECUTABLE_LIB_DIR="$EXECUTABLE_DIR/$EXECUTABLE_LIB"
EXECUTABLE_PATH="$EXECUTABLE_DIR/$EXECUTABLE_NAME"

# Установка LD_LIBRARY_PATH
export LD_LIBRARY_PATH="$EXECUTABLE_LIB_DIR:$LD_LIBRARY_PATH"

# Запуск исполняемого файла
echo "Запуск программы '$EXECUTABLE_PATH' с библиотеками из '$EXECUTABLE_LIB_DIR'."
"$EXECUTABLE_PATH"

Тема развёртывания программ на операционной системе Linux достойна отдельной статьи. В рамках данной статьи показан самый простой и примитивный способ этого добиться. Приложение, собранное таким способом, не отличается совместимостью с другими системами. Если ваша консоль не Anbernic, то игра может не запуститься. Если на вашей консоли установлен другой дистрибутив или другие версии библиотек, то игра тоже может не запуститься. Я поместил все необходимые библиотеки в папку lib, которая лежит рядом с исполняемым файлом, создал на эти библиотеки символические ссылки, которые ищет исполняемый файл, указал в bash скрипте путь к ним, и, судя по отладочному выводу, динамический линковщик их успешно подхватывает, но, к сожалению, полностью отвязать программу от стандартного пути у меня пока не получилось. С этим мне ещё предстоит разобраться в будущем.

Демонстрация

Наконец-то всё готово, закидываем папку с игрой и скрипт запуска на SD карточку в папку APPS и бежим проверять.

Проверка игры на Anbernic RG40XXV
Проверка игры на Anbernic RG40XXV
Проверка игры на Anbernic RG35XX SP
Проверка игры на Anbernic RG35XX SP
Before you continue to YouTube
consent.youtube.com

Видео YouTube

Отлично, всё работает, и я доволен результатом.

На данный момент игра доступна на Windows и на некоторых моделях консолей Anbernic. Игра точно запускается на Anbernic RG35XX SP с прошивкой V1.0.9 и на Anbernic RG40XXV с прошивкой V1.0.5.

Яндекс Диск с архивом проекта

Внимательно читайте порядок установки в README. Версию для Windows нельзя ставить в системные папки, а версию для Anbernic надо ставить в папку APPS или в папку PORTS.

Заключение

Это был интересный опыт, который был для меня первым шагом в мир встраиваемых систем, работающих на мощных ARM процессорах с операционной системой Linux.

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

Habrahabr.ru прочитано 19924 раза