Недавно мне захотелось вернуться к проекту игровой консоли. Правда, он забуксовал, но ничего страшного — это долгоиграющий проект, и буду его разбирать по чуть-чуть. А вернулся к нему я потому, что захотелось программировать. Потом стал размышлять: необязательно делать свою консоль, можно достать готовую. И везде слышал упоминания про ретро-консоли, а у меня такой не было! Выбрал для себя Anbernic RG35XX Pro и заказал.

В этой статье будет небольшой обзор и впечатления от этой приставки. Но больше всего меня интересовал вопрос: допустим, я разрабатываю игры, могу ли я использовать ретро-консоль для моих нужд? Как легко запустить мою игру на ней? Может быть, игровые движки поддерживают опцию экспорта для таких консолей? И похожие вопросы — в основном из-за непонимания, как это происходит.

Все будет рассмотрено на примере PortMaster и написания Hello World игр на С/С++ (SDL2, SDL3, raylib), но и поговорим про Unity, Godot, HTML5 (Phaser).


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

❯ Мысли о DIY консоли

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

В последнее время я увлекаюсь электроникой и DIY проектами, но изначальная идея была создать свою консоль. Сразу решил, что хочу повторить Arduboy в основном из-за сообщества и первая версия у меня получилась не очень. Основная причина в том, что я использовал ATmega328P (точнее LGT8f328P), а в оригинальной приставке стоит ATmega32U4, поэтому оригинальные прошивки не запускались. После чего я расстроился и сосредоточился на других проектах с электроникой.

Про эти проекты вы можете почитать в моих статьях на Хабре — с какого-то момента я стал оформлять это всё в статьи. Но на данный момент мне от этих проектов стало немного скучно: возможно, потому что многие интересные я уже сделал, а возможно, из-за того, что много электроники и мало программирования. В итоге я вернулся к основной идее и продолжил свой забег за приставку.

Ещё была одна интересная мысль: после занятий электроникой я понял, что небольшие устройства — это хорошо и весело, но мне не хватает полноценного и большого устройства, которым можно было бы заниматься долго и постоянно развивать. Поэтому я и подумал, что игровая приставка — это то, что нужно: в ней есть кнопки, экран, пищалка, батарейка, USB-отладка, флеш-память. Под это можно вечно что-то программировать.

И тут я понял, что моё желание — это программирование, а точнее программирование для такого небольшого устройства, и начал рассуждать: может быть, просто купить Arduboy или ESPBoy? Но это не тот вариант — их я всё равно буду сам собирать. А что насчёт ретро-консолей? И так я пришёл к мысли, что, может быть, на время переключиться на изучение ретро-консолей.

❯ Мысли об играх для эмуляторов

На тот момент я не представлял, что такое ретро-консоли. Знал, что они работают на Linux, и у них уже не микроконтроллер, а микропроцессор на 4–8 ядер, то есть они мощные и способны эмулировать игры — обычно вплоть до Sony PlayStation 1. Другими словами, они запускают эмулятор какой-нибудь приставки и загружают игру.

И можете угадать, какая была первая моя мысль? Она интересна тем, что очень легко уговорила меня на покупку этой консоли. Только вдумайтесь: на ней можно запустить эмулятор Arduboy! То есть создаёшь игру для Arduboy в Arduino IDE и запускаешь на ретро-консоли. На самом деле не думаю, что я так буду делать — конечно, попробую, но постоянно не буду, а сама мысль мне очень понравилась.

Другие эмуляторы, которые мне были интересны, — это PICO-8 и Gameboy. Можно потом как-нибудь разобрать, как для них писать игры. Но сейчас я решил, что не нужно вставлять это в статью, чтобы всё не смешивать и не раздувать её до предела. На данный момент лучше всего сосредоточиться на понимании того, как работает приставка, и на написании нативных игр, а это C/C++.

❯ Мысли об игровых движках

На самом деле мне не сильно важно C/C++, а был вопрос о том, что я разрабатываю игры под Unity и уже есть несколько небольших, то как мне перенести всё это на ретро-консоль? Вскоре я нашёл решение, и это был PortMaster. PortMaster — это целая система, которая упрощает портирование игр. Так как я уже делал несколько игр (прототипов) под Unity, Godot, GameMaker, Phaser, то меня интересовал вопрос: как можно их запустить? Ну хотя бы понять, можно ли использовать эти движки для ретро-консоли, потому что я вообще не понимал, насколько всё плохо или нет. Потом я и решил: «А давай ещё и C/C++ возьму — это поможет глубже разобраться в нюансах».

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

❯ Обзор и впечатления от Anbernic RG35XX Pro

Сразу скажу — я не планировал много в неё играть, меня больше интересовало, как она работает и как под неё писать игры. Но есть нюанс — это PICO-8. Меня больше всего интересовало как играются и выглядят игры для этой платформы и мои ожидания оправдались. Всё отлично, чувствуется, что приставка идеально подходит под эти игры. Другими словами, если вы думали о приставке для PICO-8 игр, то вот это именно оно. Пиксели у игр ровные и все помещается на экран, а управление просто идеально ощущается. Я люблю платформеры и приставка для них очень хорошо подходит.

Пример PICO-8 игры на ретро-консоли — Celeste Classic:

А теперь играю в обычную Celeste. Работает плавно, без нареканий, но обратите внимание на расположение кнопок — видно, что я немного запутался в игре, т.к. они перепутаны и подсказки сбиты. Т.е. физически A и B, X и Y переставлены местами от стандартного расположения на контроллерах XBox. Еще можете обратить внимание на разрешение игры — оно 16:9, тогда как у приставки экран 4:3, из-за чего видны черные полоски. Не сильно критично, но игра получается не адаптированной.

Выбирал приставку очень просто — смотрел подборки, и на самом деле, если ищешь что-то недорогое, то варианты сами напрашиваются. Хотя вспомнил: где-то год назад посмотрел обзор про Miyoo Mini Plus, которую очень хвалили, из-за чего добавил её в желаемое. Проходит год, выбираю теперь приставку и понимаю, что Miyoo Mini Plus устарела и в ней нет графического процессора — всё рисуется программно. Удивился и начал искать приставку с графическим процессором. Нашёл RG 35XX от Anbernic и было стал её заказывать, но как-то смутило то, что в ней нет Wi-Fi. А оказалось, что в последней RG 35XX Pro всё то же самое, но плюс ещё Bluetooth и Wi-Fi. Так звёзды и сошлись.

Привезли мне приставку. Всё хорошо: пластик приятный, не чувствуется, что она дешёвая, и когда её запустил, то в приставке уже были установлены игры — что суперкруто, потому что ей можно сразу пользоваться.

А дальше следовало первое разочарование: установил SD-карту с играми и попытался сделать их бэкап, так флешка на полпути отвалилась и весь раздел стёрся. У меня не получилось сделать просто копию флешки и сохранить её — всё сразу отвалилось!

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

В одном видеообзоре я наслушался, что стоковая прошивка, то есть ОС, с которой шла приставка, немного подтормаживает на PS1-играх или других тяжёлых для приставки играх. Поэтому я сразу установил себе muOS. Всё хорошо работает, много настроек, но, если говорить честно, если нет необходимости, то прошивку приставки можно не менять — разницы большой я не заметил, если говорить с точки зрения игр.

Но для разработки — да, вещь оказалась полезной: в ней можно включить доступ по SSH и раздать по сети SD-карту. Не знаю, можно ли это сделать на стоковой прошивке — может, и можно, но не успел это проверить до того, как её снёс.

На следующий день меня ждало второе разочарование: когда стал включать приставку, она не включалась. Оказалось, батарейка села. Но причина была глубже — приставка продолжала расходовать электричество, когда входила в сонный режим. Немного изучив эту проблему в сети, понял, что это обычное явление. Решение — выключать приставку, а не вводить её в сон. Странно, но ладно, возможно, это особенности muOS. В KNULLI есть режим, когда через 15 минут бездействия она может выключаться.

Позже произошло третье и самое глубокое разочарование. Поставил я приставку заряжаться на ночь, прихожу днём, включаю, а по краю экрана полоска. В сети пишут, что это от нагрева. Ещё пишут, что приставку стоит заряжать от 5В, а не от быстрой зарядки, а я как раз заряжал быстрой зарядкой — возможно, она из-за этого нагрелась. В итоге причину я так и не понял, решил, что буду заменять экран. Хотел было вернуть обратно приставку, но в какой-то момент решил починить сам, открыл и так усердно старался, что порвал шлейф от дисплея. В итоге заказал дисплей, буду вставлять его сам — по гарантии уже не получится вернуть. Но это я сам виноват.

Дефект дисплея, появилась полоска
Дефект дисплея, появилась полоска

Вожусь с этой приставкой пару недель и больших проблем не встретил. Хотя была одна — это я начал ставить на неё KNULLI. Ставлю, а приставка не запускается, много времени убил, в итоге решил почитать, может кто с этой проблемой встречался. Зашел на Github и вижу тему. Оказалось, что прошивка на сайте KNULLI не работает, надо скачать из этой темы. После KNULLI запустилось.

Меню KNULLI
Меню KNULLI

Вот такое первое впечатление от этой приставки. Очень ей доволен, но и проблем хватает. Если будете думать о такой приставке, то заглядывайте на 4PDA, там много информации о прошивках и всего такого, для первого знакомства почерпнете много полезной информации, но для разработки её не достаточно. Дальше перейдем к основному разделу статьи, про разработку.

❯ Разработка

Для разработки потребуется приставка с WiFi и компьютер с Linux. В принципе, если вы собираетесь делать игры на Godot, то можно их класть на SD карту и компьютер с Linux для компиляции не потребуется, но это крайне не удобно.

В статье я это не рассматривал, но WiFi еще нужен, чтобы запустить игру из командной строки через SSH, чтобы посмотреть отладочную информацию. Без этого бывает никуда не деться. Например, в PortMaster это файлы с расширением .sh, достаточно их запустить в консоли и посмотреть, что выдает.

❯ Подготовка

Выбор прошивки

К данному моменту у меня нет другой приставки, поэтому всё, что здесь будет рассказано, это про Anbernic RG35XX Pro. Это приставка новая, 2025-го года, но уже существуют для неё сторонние прошивки, в основном есть muOS и KNULLI.

Лого muOS
Лого muOS

Сначала я разрабатывал под muOS, а потом решил проверить, смогу ли запустить игру на SDL3 на KNULLI, т.к. на muOS её мне не удалось запустить. Говорю сразу, SDL3 игра просто так не запускается и у меня не получилось сделать это и на KNULLI. Но когда переходил на KNULLI заметил интересную особенность, что нигде в сети нет информации как собирать свою прошивку muOS самому. Нет никакой инструкции. А вот для KNULLI есть.

Лого KNULLI
Лого KNULLI

И я задался вопросом как собираются прошивки под данную систему. Сначала я нашел ветку обсуждений по muOS и репозиторий с утилитами, которые навели меня на мысль, что они не собирают Linux под неё, а берут образ официальной прошивки и вытаскивают все внутренности из неё.

Потом я посмотрел вики и репозиторий KNULLI. Там я увидел информацию как собрать свою прошивку. Например, для её сборки необходимо 180 Гб на жестком диске. Еще в репозитории KNULLI есть файлы buildroot, а это очень хороший знак, значит можно скомпилировать и собрать любую другую программу. В конце концов мои догадки оправдались, когда увидел эту сноску в вики — получается действительно Linux берется из официального образа, но там и есть сноска, что можно скомпилировать своё, но будут проблемы. Пока компилировать своё не планирую, но возьму на заметку.

Поэтому дальше я буду использовать KNULLI, т.к. это система проще поддается сборке и у неё больше документации, хотя почти все можно проделать и под muOS.

Замечание: с точки зрения игр muOS более дружелюбна и там у меня без проблем запускались игры, а вот на KNULLI пришлось скачивать BIOS файлы. Имейте это ввиду.

О PortMaster

Лого PortMaster
Лого PortMaster

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

Руководств по установке PortMaster много, есть он в вики KNULLI.

Установив и запустив PortMaster у вас появляется доступ ко множествам портам, которые можно тут же скачать и запустить. Другими словами, это еще и как “магазин” с играми. (В кавычках, потому что все бесплатно.)

О содержании примеров

Перед тем, как приступить к примерам с кодом и инструкциями, стоит поговорить, что они будут содержать: окно 640x480, поле с выводом информации о нажатой клавиши, по центру отображается 3д модель и её можно вращать с помощью левого стика, по кнопкам Select и Start происходит закрытие программы. Этого для теста должно хватить. Звук не стал тестировать.

Как использовать файлы из примеров

Исходные файлы примеров находятся в репозитории portmaster-playground. Заходите в какой-нибудь пример и там есть папка Export, в ней уже лежат собранные и готовые к запуску файлы. Содержимое этой папки нужно положить на флеш карту ретро-консоли, на KNULLI это в SHARE/roms/ports. Для наглядности ниже приведены примеры как можно положить файлы демки на Godot на флеш карту ретро-консоли, но для этого нужно будет включить сетевой диск, т.е. раздать содержимое флешки по сети. (Делается это в настройках: Main Menu -> System Settings -> Services -> Samba. Пароль по умолчанию: linux)

Можно и без сетевого диска, и без гита, как вам удобно. Главное поймите принцип, просто на muOS папки расположены по-другому и файлы надо будет раскидать в разные места (папка Godot3Test идет в SHARE/ports, а файл Godot3Test.sh идет в SHARE/ROMS/Ports), в других ОС может быть что-то по-другому, но про это ничего пока не скажу.

sudo apt update
sudo apt install -y make git cifs-utils

# Из-за зависимостей может долго скачиваться!
git clone --recursive https://github.com/m039/portmaster-playground.git ~/portmaster-playground

mkdir mnt

sudo mount.cifs //knulli.local/share mnt -o uid=$(id -u),gid=$(id -g)

cp -R ~/portmaster-playground/godot3-test/Export/* mnt/roms/ports/

sudo umount mnt
rmdir mnt

После надо просканировать консоль на наличие новых игр и Godot3Test должен появится в разделе Ports.

❯ Принцип работы игр под ретро-консоль

Рассказываю конкретно, что понял про свою консоль, но рассуждения могут быть применены и к другим. Операционная система для ретро-консоли — это Linux. Он 64-битный и под архитектуру ARM (как, например, у Raspberry Pi). Поэтому на выходе должна быть скомпилирована такая программа, но есть один нюанс: под мою приставку (про другие не знаю точно, но думаю, то же самое) нет никакого стандартного графического окружения или подсистемы. Т. е. нет X11, Wayland, DRM/KMS, fbdev (фреймбуфера) — ничего такого, но есть mali (по типу фреймбуфера). Т. е. к драйверу приходится обращаться напрямую, без посредников. А библиотеки типа SDL, не говоря уже о других, не любят этого — у них нет поддержки mali, разве что её добавят с помощью патчей.

Такую поддержку добавили в KNULLI, как видно здесь. Думаю, тоже самое есть в muOS и стоковой прошивке. Другими словами, скачиваете вы какую-нибудь прошивку ОС под ретро-консоль и в ней уже лежит пропатченная SDL2.

Вот правда нюанс, что SDL3 не лежит и поэтому у меня не получилось её запустить без Westonpack. Он работает просто: все вызовы переадресовывает SDL2.

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

В любом случае для нас принцип прост: компилируем программу под SDL2 с динамической линковкой и дальше запускаем её.

❯ raylib

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

Raylib — это библиотека для программирования игр. Используется язык С (можно в связке с C++), и у неё как раз есть возможность использовать SDL2. Это значительно упрощает программирование, и оно становится приятным. Я ей практически не пользовался, просто слышал хвалебные отзывы, но хотел и сейчас попробовал.

Скажу сразу, что завелось все, как у всех примеров тут, не сразу, сделал даже несколько пулл реквестов и теперь все должно собираться без проблем под SDL2.

❯ Компилируем и запускаем программу под десктоп Linux

Самая простая часть: попробуем запустить программу на стационарном компьютере с Linux (наверно, можно всё делать и без Linux, но мне видится это очень неудобным).

Вам потребуется компьютер с операционной системой Linux (можно на виртуальной машине). Я буду все шаги проходить на виртуальной машине с Xubuntu 25.10, чтобы быть уверенным, что всё соберется. Установка Linux выходит за рамки данной статьи.

sudo apt update
sudo apt install -y build-essential cmake git

# Если уже скачали репозиторий, то эта операция не нужна.
git clone --recursive https://github.com/m039/portmaster-playground.git ~/portmaster-playground

# Собираем SDL2 из исходников, т.к. в Ubuntu пакет с ошибками в файлах cmake.
## Возможно в каком-то случае можно установить SDL2 с помощью apt, но я это не проверял.
cd ~/portmaster-playground/sdl2-test/deps/SDL2
mkdir -p build-x86
cd build-x86
cmake ..
make
sudo make install

# Собираем raylib
cd ~/portmaster-playground/raylib-test/deps/raylib
mkdir -p build-x86
cd build-x86
cmake -DPLATFORM=SDL -DOPENGL_VERSION="ES 2.0" ..
make
sudo make install

# Собираем пример
cd ~/portmaster-playground/raylib-test/
mkdir -p build-x86
cd build-x86
cmake ..
make

# Запускаем игру. 
## Возможно стоит прописать LD_LIBRARY_PATH в ~/.profile
LD_LIBRARY_PATH="/usr/local/lib" ./raylib-test

После выполнения этих команд в консоли должна запуститься демка.

Вы могли заметить, что я устанавливаю зависимости вручную, мне так показалось удобнее, но при желании все можно объединить в одни большой CMake. Особенно, когда процессы будут налажены.

❯ Компилируем и запускаем программу на ретро-консоли

Теперь начинаются сложности. Нужно проделать все тоже самое, но скомпилировать программу под ARM64 и положить это все на флешку, чтобы её увидел PortMaster. В вике PortMaster как раз есть инструкции на этот счет. Мне показался пример с chroot наиболее удобным.

sudo apt update
sudo apt install -y build-essential debootstrap binfmt-support qemu-user-static

# Создаем окружение с файлами на ARM64, которые будем запускать через эмулятор qemu.
sudo debootstrap --arch arm64 bookworm /mnt/data/arm64 http://deb.debian.org/debian/

sudo mkdir -p /mnt/data/arm64/home/test
# Впишите своего пользователя вместо m039. Это для того, чтобы основная система не ругалась.
sudo chown m039:m039 /mnt/data/arm64/home/test
# Выходим из директории
cd
# Перемещаем всю папку с исходниками, чтобы все было в одном месте.
mv ~/portmaster-playground /mnt/data/arm64/home/test/

# Запускаем оболочку shell в окружении ARM64.
sudo chroot /mnt/data/arm64/

# Можете выполнить эту команду, чтобы удостовериться, что теперь мы в системе с архитектурой ARM64.
uname -m

# Т.к. система голая, нужно поставить инструменты разработчика.
apt install -y build-essential cmake git

# Собираем SDL2
## Возможно в каком-то случае можно установить SDL2 с помощью apt, но я это не проверял.
cd /home/test/portmaster-playground/sdl2-test/deps/SDL2/
mkdir build-arm
cd build-arm
cmake ..
make
make install

# Собираем raylib
cd /home/test/portmaster-playground/raylib-test/deps/raylib
mkdir -p build-arm
cd build-arm
cmake -DPLATFORM=SDL -DOPENGL_VERSION="ES 2.0" ..
make
make install

# Собираем пример
cd /home/test/portmaster-playground/raylib-test/
mkdir -p build-arm
cd build-arm
cmake ..
make

При компиляции у меня возникла проблема, что raylib не может найти SDL2, потому что находит SDL3. Пока лучшего решение не нашел, чем вручную поправить файл raylib/cmake/LibraryConfigurations.cmake и закомментировать find_package(SDL3 QUIET).

Теперь в директории /mnt/data/arm64/home/test/portmaster-playground/raylib-test/build-arm должен лежать файл raylib-test. Дальше его нужно скопировать на флешку. Что именно копировать давайте я покажу тоже через команды, а вы уже поймете конкретно что куда положить. Буду использовать сетевой диск на консоли, но вы можете вытащить флешку, вставить в компьютер, все записать и вставить обратно в консоль.

# Откройте новое окно с терминалом и вводите команды.
sudo apt install -y cifs-utils

mkdir mnt

# Включаем в Knulli сетевой диск в настройках: Main Menu -> System Settings -> Services -> Samba.
# Пароль по-умолчанию: linux
sudo mount.cifs //knulli.local/share mnt -o uid=$(id -u),gid=$(id -g)

mkdir -p mnt/roms/ports/raylib-test
test_directory="/mnt/data/arm64/home/test/portmaster-playground/raylib-test"
# Файлы ресурсов для приложения.
cp -r $test_directory/assets mnt/roms/ports/raylib-test/assets
# Это файл для PortMaster, его рассмотрим ниже.
cp $test_directory/export/raylib-test.sh mnt/roms/ports
# Сам исполняемый файл.
cp $test_directory/build-arm/raylib-test mnt/roms/ports/raylib-test

sudo umount mnt
rmdir mnt
Файл raylib-test.sh
#!/bin/bash

XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}

if [ -d "/opt/system/Tools/PortMaster/" ]; then
  controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
  controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
  controlfolder="$XDG_DATA_HOME/PortMaster"
else
  controlfolder="/roms/ports/PortMaster"
fi

source $controlfolder/control.txt
if [ -z ${TASKSET+x} ]; then
  source $controlfolder/tasksetter
fi

[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
get_controls

export gamedir="/$directory/ports/raylib-test"

echo $gamedir
cd $gamedir

$TASKSET ./raylib-test 

pm_finish

Файл raylib-test.sh нарочито не содержит ничего лишнего и в таком виде его не стоит использовать, но для простоты изложения можно оставить. Например, нет поддержки gptokeyb. Просто я еще не публиковал готовую игру в PortMaster, поэтому могу что-то упустить. В любом случае смотрите уже опубликованные игры в PortMaster и используйте их настройки.

raylib-test.sh по сути подключает скрипты PortMaster и запускает игру. Все файлы в папке ports с расширением .sh будут восприниматься как игры или rom-ы, если так хотите называть.

Исходный код примера

Файл main.cpp
#include "raylib.h"
#include "rlgl.h"
#include "raymath.h"

#include <stdio.h>

Model model;

char message[128];

const char * ModelFilename = "assets/planet.glb";

const char * GamepadKeyNames[] = {
    "Unkown",
    "Up",
    "Right",
    "Down",
    "Left",
    "Y",
    "B",
    "A",
    "X",
    "L1",
    "L2",
    "R1",
    "R2",
    "Select",
    "Guide",
    "Start",
    "Left Thumb",
    "Right Thumb"
};

int main()
{
    const int screenWidth = 640;
    const int screenHeight = 480;

    if (!FileExists(ModelFilename)) {
        TraceLog(LOG_ERROR, "Can't find '%s'\n", ModelFilename);
        return 1;
    }
    
    InitWindow(screenWidth, screenHeight, "raylib-test");

    model = LoadModel(ModelFilename);

    SetTargetFPS(60);

    sprintf(message, "Press a Key");

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 0.0f, 20.0f, 0.0f };   // Позиция камеры
    camera.target = (Vector3){ 0.0f, 0.0f, 0.0f };      // Точка, на которую смотрит камера
    camera.up = (Vector3){ 1.0f, 0.0f, 0.0f };          // Вектор «вверх»
    camera.fovy = 20.0f;                                // Ширина (Или высота?) экрана для ортографической проекции
    camera.projection = CAMERA_ORTHOGRAPHIC;            // Ортографическая проекция

    float angleX, angleZ;

    int gamepad = 0;

    while (!WindowShouldClose())
    {
        int key = GetKeyPressed();
        if (key > 0) {
            sprintf(message, "Key Pressed: %s", GetKeyName(key));
        }
        key = GetGamepadButtonPressed();
        if (key > 0) {
            sprintf(message, "Gamepad's Key Pressed: %s", GamepadKeyNames[key]);
        }

        angleX = - 180 * GetGamepadAxisMovement(gamepad, GAMEPAD_AXIS_LEFT_X);
        angleZ = - 180 * GetGamepadAxisMovement(gamepad, GAMEPAD_AXIS_LEFT_Y);

        if (IsGamepadButtonDown(gamepad, GAMEPAD_BUTTON_MIDDLE_LEFT) && IsGamepadButtonDown(0, GAMEPAD_BUTTON_MIDDLE_RIGHT)) {
            break;
        }

        BeginDrawing();

            ClearBackground(RAYWHITE);

            BeginMode3D(camera);

                rlPushMatrix();
                    rlRotatef(angleX, 1.0, 0.0, 0.0);
                    rlRotatef(angleZ, 0.0, 0.0, 1.0);
                    DrawModel(model, (Vector3){0.0f, 0.0f, 0.0f}, 1.0f, BLACK);
                rlPopMatrix();

            EndMode3D();

            DrawText(message, 40, 40, 20, BLUE);

        EndDrawing();
    }

    UnloadModel(model);
    CloseWindow();

    return 0;
}

Кода здесь на самом деле значительно меньше, чем если то же самое написать без raylib, но на SDL, о чём я напишу дальше. Но в несколько раз больше, чем если то же самое написать на Godot. Godot, к слову, запускается без проблем, но не так интересно, конечно, — о нём поговорим в следующем разделе.

Исходный код содержит следующий алгоритм: создаётся окно, загружается модель, запускается бесконечный цикл с обработкой событий; по нажатию на клавиши меняется текст, по перемещению левого джойстика вертится модель, по нажатию на кнопки Start и Select закрывается программа.

Из непонятных конструкций есть массив, который словарь, GamepadKeyNames. Он нужен для того, что я не нашел как узнать имена кнопок геймпада в raylib.

На этом в принципе все. raylib очень приятная в использовании библиотека, код читается легко и должно быть понятно что ожидать.

Примерно так выглядит работа примера:

Перейдем к Godot.

❯ Godot

Лого Godot
Лого Godot

Чтобы не делать статью нечитаемой, я постараюсь коротко рассказать с какими трудностями столкнулся и как собирается проект, без подробной инструкции. Если, что обращайтесь к инструкции raylib.

Исходный код примера на Godot можете взять здесь. Он проще, чем raylib, т.к. все настраивается через редактор, а кода совсем немного.

Но прежде, чем его рассматривать, стоит рассказать как происходит запуск программы на Godot у ретро-консолей. А на самом деле просто, игры на Godot запускаются через посредника под названием FRTFRT это исполняемый файл, которому указываются ресурсы игры и он её запускает, и как вы могли догадаться, т.к. на ретро-консолях нет никаких графических оболочек (X11, Wayland), а есть, в моем случае, драйвер mali, то этот FRT его использует. Но, правда, он немного упростил себе задачу и добирается до этого драйвера, через SDL2. Вот так все просто, все на ретро-консолях работает через SDL2, получается.

Поэтому, чтобы запустить Godot игру на ретро-консоли нужно её экспортировать под ARM64, это делается в окне экспорта, найдете без проблем. Но есть одна проблема, что FRT есть не под все версии Godot, какая версия смотрите на странице PortMaster. Я выбрал frt_3.6, другими словами под Godot 3.6.

Godot3Test.sh
XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}

if [ -d "/opt/system/Tools/PortMaster/" ]; then
  controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
  controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
  controlfolder="$XDG_DATA_HOME/PortMaster"
else
  controlfolder="/roms/ports/PortMaster"
fi

source $controlfolder/control.txt
if [ -z ${TASKSET+x} ]; then
  source $controlfolder/tasksetter
fi

[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
get_controls

export gamedir="/$directory/ports/Godot3Test"

echo $gamedir
cd $gamedir

$GPTOKEYB "frt_3.6" &

./frt_3.6 --main-pack "Godot 3 Test.pck"

$ESUDO kill -9 $(pidof gptokeyb)

pm_finish

В файле Godot3Test.sh вы можете понять как все работает, а точнее, что файлы Godot (Godot 3 Test.pck) запускаются через FRT. Там еще есть запуск команды gptokeyb. Она работает очень просто: запускается в фоновом режиме и отслеживает события, которые происходят в системе и переадресовывает основной игре. С помощью нее, например, сделана обработка нажатия Start и Select кнопок, чтобы выйти из игры. Например, в игре такой комбинации нет, а с помощью gptokeyb появляется (в raylib-test я самостоятельно добавил выход по Start и Select, поэтому не стал использовать gpttokeyb). В целом, она упрощает портирование игр — можно указать, чтобы клавиши с геймпада переадресовывались клавишам клавиатуры, если происходит портирование игры с персонального компьютера.

Исходный код примера на Godot
extends Node

onready var label : RichTextLabel = $Label

onready var planet : Spatial = $planet

const SPEED = 0.10

func _ready():
	pass

var last_key = ""

func _input(event):
	if event is InputEventKey and event.pressed and not event.echo:
		label.text = "Key pressed: " + OS.get_scancode_string(event.scancode)
	elif event is InputEventJoypadButton and event.is_pressed():
		var button_name = Input.get_joy_button_string(event.button_index)
		label.text = "Gamepad pressed: " + button_name
	elif event is InputEventJoypadMotion:
		if event.axis == JOY_AXIS_0:
			planet.rotation.x = lerp(PI, -PI, (event.axis_value + 1) / 2)
		if event.axis == JOY_AXIS_1:

Вот пример кода этой программы на Godot. Занимает 23 строк.

Подводя итог, из того, что успел попробовать Godot проще всего в портировании, даже не нужны средства компиляции, но моя цель была попробовать игры на С/С++, поэтому сильно на Godot не останавливался.

❯ SDL2

А вот когда я писал пример для чистогоSDL2, то у меня раскрылись глаза. Итоговая программа заняла 654 строк кода! Давно я отвык от такого, движки знатно избаловали меня. Но ничего, возможно и продолжу это приключение с чистым SDL2. Хотя, на самом деле я не совсем честно написал, потому что использовал библиотеки glm, SDL2_ttf, tinygltf и если их не использовать, то там наверно еще больше будет.

Инструкция точь один в один как у raylib, но нужно будет скомпилировать все из папки deps. Поэтому я не буду заострять на ней внимание, при необходимости можете посмотреть в репозитории.

Но возможно кто-то захочет попробовать посмотреть исходник примера, то приведу его здесь.

Исходный код примера на SDL2
#include <SDL2/SDL.h>
#include <SDL2/SDL_main.h>
#include <SDL2/SDL_ttf.h>

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <tiny_gltf.h>

#include <iostream>
#include <vector>
#include <string>
#include <GLES3/gl3.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480

GLuint compileShaders(const char *vshader, const char *fshader);

class Label {
    private:
    TTF_Font* _font;

    SDL_Surface* _textSurface;

    SDL_Renderer* _renderer;

    GLuint _textureId = 0;

    float _x;
    
    float _y;

    float _width;

    float _height;

    float _scale = 1.0;

    GLuint _vao, _vbo, _ebo;

    GLuint _shader;

    inline static const float textQuadVertices[] = {
        0.0f, 0.0f, 0.0f,  0.0f, 1.0f,
        1.0f, 0.0f, 0.0f,  1.0f, 1.0f,
        1.0f, 1.0f, 0.0f,  1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,  0.0f, 0.0f
    };

    inline static const unsigned int textQuadIndices[] = {0, 1, 2, 2, 3, 0};

    inline static const char * vshader = R"(#version 300 es
    precision mediump float;
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec2 aTexCoord;

    out vec2 TexCoord;

    uniform mat4 projection;
    uniform mat4 view;
    uniform mat4 model;

    void main() {
        gl_Position = projection * view * model * vec4(aPos, 1.0);
        TexCoord = aTexCoord;
    }

    )";

    inline static const char * fshader = R"(#version 300 es
    precision mediump float;
    out vec4 FragColor;
    in vec2 TexCoord;

    uniform sampler2D textTexture;
    uniform vec4 textColor;

    void main() {
        FragColor = texture(textTexture, TexCoord) * textColor;
    }
    )";

    public:
    bool init(SDL_Renderer *renderer) {
        _renderer = renderer;

        _font = TTF_OpenFont("assets/arial.ttf", 12);
        if (_font == NULL) {
            SDL_Log("Can't open font: %s", SDL_GetError());
            SDL_Quit();
            return false;
        }

        glGenBuffers(1, &_vbo);
        glGenBuffers(1, &_ebo);

        glBindBuffer(GL_ARRAY_BUFFER, _vbo);
        glBufferData(GL_ARRAY_BUFFER, sizeof(textQuadVertices), textQuadVertices, GL_STATIC_DRAW);

        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, _ebo);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(textQuadIndices), textQuadIndices, GL_STATIC_DRAW);

        _shader = compileShaders(vshader, fshader);

        return true;
    }

    void setScale(const float scale) {
        _scale = scale;
    }

    void setPosition(const float x, const float y) {
        _x = x;
        _y = y;
    }

    void setText(const std::string &s) {
        SDL_Color foregroundColor = { 255, 255, 255, 255};

        SDL_Surface *tmp = TTF_RenderText_Blended(_font, s.c_str(), foregroundColor);
        if (!tmp)
        {
            SDL_Log("Can't create text surface: %s", SDL_GetError());
            return;
        }

        GLenum mode = GL_RGBA;
        SDL_PixelFormat * target = SDL_AllocFormat(SDL_PIXELFORMAT_RGBA32);
        SDL_Surface * textSurface = SDL_ConvertSurface(tmp, target, 0);
        SDL_FreeSurface(tmp);
        SDL_FreeFormat(target);

        _width = textSurface->w;
        _height = textSurface->h;

        if (!_textureId) {
            glGenTextures(1, &_textureId);
        }
        glBindTexture(GL_TEXTURE_2D, _textureId);

        // Загрузка данных текстуры
        glTexImage2D(GL_TEXTURE_2D, 0, mode, textSurface->w, textSurface->h, 0,
                    mode, GL_UNSIGNED_BYTE, textSurface->pixels);

        // Настройки фильтрации
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

        glBindTexture(GL_TEXTURE_2D, 0);

        SDL_FreeSurface(textSurface);
    }

    void render() {
        if (!_textureId) {
            return;
        }

        float x = _x;
        float y = _y;
        float width = _width * _scale;
        float height = _height * _scale;

        glDisable(GL_DEPTH_TEST); // Отключаем тест глубины для 2D-элементов
        glEnable(GL_BLEND);
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

        // Активируем шейдер текста
        glUseProgram(_shader);

        glBindBuffer(GL_ARRAY_BUFFER, _vbo);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, _ebo);

        // Позиции вершин
        GLuint aPosLoc = glGetAttribLocation(_shader, "aPos");
        glEnableVertexAttribArray(aPosLoc);
        glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);

        // Текстурные координаты
        GLuint aTexCoordLoc = glGetAttribLocation(_shader, "aTexCoord");
        glEnableVertexAttribArray(aTexCoordLoc);
        glVertexAttribPointer(aTexCoordLoc, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));

        glm::mat4 viewMatrix = glm::mat4(1.0);
        GLint viewLoc = glGetUniformLocation(_shader, "view");
        glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(viewMatrix));

        // Устанавливаем ортогональную проекцию для 2D
        glm::mat4 textProjection = glm::ortho(0.0f, (float)WINDOW_WIDTH, 0.0f, (float)WINDOW_HEIGHT);
        GLint textProjLoc = glGetUniformLocation(_shader, "projection");
        glUniformMatrix4fv(textProjLoc, 1, GL_FALSE, glm::value_ptr(textProjection));

        // Позиция текста (верхний левый угол + отступ)
        glm::mat4 textScale = glm::scale(glm::mat4(1.0f), glm::vec3(width, height, 1.0));
        glm::mat4 textModel = glm::translate(glm::mat4(1.0f), glm::vec3(10.0f, 10.0f, 0.0f)) * textScale;
        GLint textModelLoc = glGetUniformLocation(_shader, "model");
        glUniformMatrix4fv(textModelLoc, 1, GL_FALSE, glm::value_ptr(textModel));

        // Цвет текста (белый)
        GLint colorLoc = glGetUniformLocation(_shader, "textColor");
        glUniform4f(colorLoc, 0.0f, 0.0f, 1.0f, 1.0f); // RGBA

        // Активируем текстуру текста
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, _textureId);
        GLint texLoc = glGetUniformLocation(_shader, "textTexture");
        glUniform1i(texLoc, 0);

        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glDisableVertexAttribArray(aPosLoc);
        glDisableVertexAttribArray(aTexCoordLoc);

        glBindBuffer(GL_ARRAY_BUFFER, 0);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
    }

    void destroy() {
        glDeleteBuffers(1, &_vbo);
        glDeleteBuffers(1, &_ebo);
        glDeleteProgram(_shader);
        glDeleteTextures(1, &_textureId);
    }
};

Label label;

class Planet {
    struct MeshData {
        GLuint vao;
        GLuint vbo;
        GLuint ebo;
        int indexCount;
        int indexComponentType;
        int vertexComponentType;
    };

    struct Model {
        std::vector<MeshData> meshes;
        // Здесь можно добавить материалы, текстуры и т. д.
    };

    Model _model;

    GLuint _shader;

    glm::vec3 _rotation;

    public:
    bool init() {
        // Загрузка модели
        if (!loadGLBModel("assets/planet.glb", _model)) {
            std::cerr << "Failed to load model" << std::endl;
            return false;
        }

        // Компиляция шейдеров
        _shader = compileShaders(vertexShaderSource, fragmentShaderSource);
        if (_shader == 0) {
            std::cerr << "Failed to compile shaders" << std::endl;
            return false;
        }

        return true;
    }

    void setXRotation(float value) {
        _rotation.x = -180 * value;
    }

    void setYRotation(float value) {
        _rotation.z = -180 * value;
    }

    void render() {
        float aspect = (float) WINDOW_WIDTH / (float) WINDOW_HEIGHT;

        // Включение глубины для 3D-рендеринга
        glEnable(GL_DEPTH_TEST);

        // Настройка матриц
        glm::mat4 modelMatrix = glm::mat4(1.0f);

        modelMatrix = glm::rotate(modelMatrix, glm::radians(_rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));
        modelMatrix = glm::rotate(modelMatrix, glm::radians(_rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));
        modelMatrix = glm::rotate(modelMatrix, glm::radians(_rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));

        glm::mat4 view = glm::lookAt(
            glm::vec3(0.0f, 20.0f, 0.0f),  // камера
            glm::vec3(0.0f, 0.0f, 0.0f),  // цель
            glm::vec3(1.0f, 0.0f, 0.0f)   // вверх
        );

        float size = 20.0;

        glm::mat4 projection = glm::ortho(-size / 2.0, +size / 2.0, -(size / aspect) / 2.0, +(size / aspect) / 2.0, -200.0, +200.0);

        // Передача матриц в шейдер
        glUseProgram(_shader);

        GLint modelLoc = glGetUniformLocation(_shader, "model");
        GLint viewLoc = glGetUniformLocation(_shader, "view");
        GLint projLoc = glGetUniformLocation(_shader, "projection");

        glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(modelMatrix));
        glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
        glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection));

        // Рендеринг модели
        for (const auto& mesh : _model.meshes) {
            GLuint aPosLoc = glGetAttribLocation(_shader, "aPos");

            if (mesh.vbo) {
                glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
                glVertexAttribPointer(aPosLoc, 3, mesh.vertexComponentType, GL_FALSE, 0, 0);
                glEnableVertexAttribArray(aPosLoc);
            }

            if (mesh.ebo) {
                glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);
                glDrawElements(GL_TRIANGLES, mesh.indexCount, mesh.indexComponentType, 0);
            } else {
                // Если нет индексов, используем glDrawArrays
            }

            glDisableVertexAttribArray(aPosLoc);
            glBindBuffer(GL_ARRAY_BUFFER, 0);
            glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
        }
    }

    void destroy() {
        for (const auto& mesh : _model.meshes) {
            glDeleteBuffers(1, &mesh.ebo);
            glDeleteBuffers(1, &mesh.vbo);
        }
        glDeleteProgram(_shader);
    }
private:

    void loadVertexBuffer(const tinygltf::Model& model, int accessorIdx, GLuint& vbo, int& vertexComponentType) {
        const auto& accessor = model.accessors[accessorIdx];
        const auto& bufferView = model.bufferViews[accessor.bufferView];
        const auto& buffer = model.buffers[bufferView.buffer];

        glGenBuffers(1, &vbo);
        glBindBuffer(GL_ARRAY_BUFFER, vbo);

        // Копируем данные из GLTF в OpenGL-буфер
        glBufferData(
            GL_ARRAY_BUFFER,
            bufferView.byteLength,
            buffer.data.data() + bufferView.byteOffset,
            GL_STATIC_DRAW
        );

        vertexComponentType = accessor.componentType;

        // Настройка атрибутов вершин (пример для позиций)
        GLuint index = glGetAttribLocation(_shader, "aPos");
        glVertexAttribPointer(index, 3, vertexComponentType, GL_FALSE, 0, 0);
        glEnableVertexAttribArray(index);

        glBindBuffer(GL_ARRAY_BUFFER, 0);
    }

    void loadIndexBuffer(const tinygltf::Model& model, int accessorIdx, GLuint& ebo, int& indexCount, int& indexComponentType) {
        const auto& accessor = model.accessors[accessorIdx];
        const auto& bufferView = model.bufferViews[accessor.bufferView];
        const auto& buffer = model.buffers[bufferView.buffer];

        glGenBuffers(1, &ebo);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);

        glBufferData(
            GL_ELEMENT_ARRAY_BUFFER,
            bufferView.byteLength,
            buffer.data.data() + bufferView.byteOffset,
            GL_STATIC_DRAW
        );

        indexCount = accessor.count;
        indexComponentType = accessor.componentType;
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
    }


    bool loadGLBModel(const std::string& filename, Model& model) {
        tinygltf::Model gltfModel;
        tinygltf::TinyGLTF loader;
        std::string err;
        std::string warn;

        // Загрузка GLB-файла
        bool success = loader.LoadBinaryFromFile(&gltfModel, &err, &warn, filename);

        if (!warn.empty()) {
            std::cout << "GLTF Warning: " << warn << std::endl;
        }
        if (!err.empty()) {
            std::cerr << "GLTF Error: " << err << std::endl;
        }
        if (!success) {
            std::cerr << "Failed to load GLB file: " << filename << std::endl;
            return false;
        }

        // Обработка всех сеток (meshes) в модели
        for (const auto& mesh : gltfModel.meshes) {
            for (const auto& primitive : mesh.primitives) {
                MeshData meshData;

                // Загрузка вершинных данных
                if (primitive.attributes.count("POSITION") > 0) {
                    int posAccessor = primitive.attributes.at("POSITION");
                    loadVertexBuffer(gltfModel, posAccessor, meshData.vbo, meshData.vertexComponentType);
                } else {
                    std::cerr << "No position in model" << std::endl;
                }

                // Загрузка индексных данных (если есть)
                if (primitive.indices >= 0) {
                    loadIndexBuffer(gltfModel, primitive.indices, meshData.ebo, meshData.indexCount, meshData.indexComponentType);
                } else {
                    std::cerr << "No indeces in model" << std::endl;
                }

                model.meshes.push_back(meshData);
            }
        }

        return true;
    }
    // Простой вершинный шейдер
    const char* vertexShaderSource = R"(#version 300 es
    precision mediump float;
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec2 aTexCoord;

    uniform mat4 projection;
    uniform mat4 view;
    uniform mat4 model;

    void main() {
        gl_Position = projection * view * model * vec4(aPos, 1.0);
    }
    )";

    // Простой фрагментный шейдер
    const char* fragmentShaderSource = R"(#version 300 es
    precision mediump float;

    out vec4 FragColor;

    void main() {
        FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    }
    )";

};

Planet planet;

GLuint compileShaders(const char *vshader, const char *fshader) {
    // Компиляция вершинного шейдера
    GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vshader, nullptr);
    glCompileShader(vertexShader);

    // Проверка ошибок компиляции
    GLint success;
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success) {
        GLchar infoLog[512];
        glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog);
        std::cerr << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
    }

    // Компиляция фрагментного шейдера
    GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fshader, nullptr);
    glCompileShader(fragmentShader);

    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success) {
        GLchar infoLog[512];
        glGetShaderInfoLog(fragmentShader, 512, nullptr, infoLog);
        std::cerr << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }

    // Создание шейдерной программы
    GLuint shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);

    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
    if (!success) {
        GLchar infoLog[512];
        glGetProgramInfoLog(shaderProgram, 512, nullptr, infoLog);
        std::cerr << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
    }

    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    return shaderProgram;
}

int main(int argc, char* argv[]) {
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
    
    SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengles2");

    if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) < 0) {
        SDL_Log("Unable to initialize SDL: %s", SDL_GetError());
        return 1;
    }

    SDL_RendererInfo info;

    if (SDL_GetRenderDriverInfo(0, &info)) {
        SDL_Log("Can't get driver info: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    printf(
        "Program info:\n" 
        " driver-name: %s\n"
        " driver-flags: %x\n"
        " video-driver: %s\n",
        info.name,
        info.flags,
        SDL_GetCurrentVideoDriver()
    );

    if (TTF_Init() == -1) {
        SDL_Log("Can't init ttf library: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    int screenWidth = WINDOW_WIDTH;
    int screenHeight = WINDOW_HEIGHT;
    float aspect = (float) screenWidth / screenHeight;

    SDL_Window* window = SDL_CreateWindow("SDL2 Test", 0, 0, screenWidth, screenHeight, SDL_WINDOW_OPENGL);
    if (!window) {
        SDL_Log("Unable to create window: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }
    
    SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
    if (!renderer) {
        SDL_Log("Unable to init renderer: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    // Создание контекста OpenGL
    SDL_GLContext context = SDL_GL_CreateContext(window);
    if (!context) {
        SDL_Log("Unable to create context: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    printf("OpenGL Version: %s\n", glGetString(GL_VERSION));

    if (!planet.init()) {
        SDL_GL_DeleteContext(context);
        SDL_DestroyWindow(window);
        SDL_Quit();
        return 1;
    }

    if (!label.init(renderer)) {
        SDL_GL_DeleteContext(context);
        SDL_DestroyWindow(window);
        SDL_Quit();
        return 1;
    }

    label.setPosition(10, 10);
    label.setScale(2.0);
    label.setText("Press a key");

    SDL_Event event;
    SDL_GameController *gamepad = NULL;
    Uint32 lastTime = SDL_GetTicks();

    int running = 1;
    while (running) {
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) {
                running = 0;
            } else if (event.type == SDL_CONTROLLERDEVICEADDED) {
                gamepad = SDL_GameControllerOpen(event.cdevice.which);
            } else if (event.type == SDL_KEYDOWN) {
                label.setText("Key pressed: " + std::string(SDL_GetKeyName(event.key.keysym.sym)));
             } else if (event.type == SDL_CONTROLLERAXISMOTION) {
                const Sint16 MAX_STICK_VALUE = 32768;

                if (event.caxis.axis == SDL_CONTROLLER_AXIS_LEFTX) {
                    planet.setXRotation(static_cast<float>(SDL_GameControllerGetAxis(gamepad, SDL_CONTROLLER_AXIS_LEFTX)) / MAX_STICK_VALUE);
                }

                if (event.caxis.axis == SDL_CONTROLLER_AXIS_LEFTY) {
                    planet.setYRotation(static_cast<float>(SDL_GameControllerGetAxis(gamepad, SDL_CONTROLLER_AXIS_LEFTY)) / MAX_STICK_VALUE);
                }
            } else if (event.type == SDL_CONTROLLERBUTTONDOWN) {
                int buttonCode = event.cbutton.button; 
                const char* buttonName = SDL_GameControllerGetStringForButton((SDL_GameControllerButton)event.cbutton.button);
                label.setText("Gamepad's button down: " + std::string(buttonName));
            }
        }

        if (SDL_GameControllerGetButton(gamepad, SDL_CONTROLLER_BUTTON_BACK) &&
            SDL_GameControllerGetButton(gamepad, SDL_CONTROLLER_BUTTON_START)) {
            running = 0;
        }       

        // Очистка буферов
        glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        planet.render();
        label.render();

        // Обмен буферами
        SDL_GL_SwapWindow(window);

        // Ограничение FPS (60 FPS)
        SDL_Delay(16);
    }

    // Очистка ресурсов
    label.destroy();
    planet.destroy();
    SDL_GameControllerClose(gamepad);
    
    SDL_GL_DeleteContext(context);
    SDL_DestroyWindow(window);
    SDL_Quit();

    return 0;
}

❯ SDL3

Самое забавное, что начал писать эти примеры именно с SDL3 — думал, что ничего особенного, должно запуститься без проблем. Но у меня так и не получилось этого сделать обычным способом. Т.е. для того, чтобы работал SDL3 нужно в него добавить поддержку mali, т.е. видеодрайвера. Но решения есть, это использовать WestonPack.

Он работает по схожему принципу, как и FRT, т.е. переадресовывает все вызовы SDL2, но эмулирует графическое окружение по типу X11.

Пример моей работы можете посмотреть в репозитории, только я его не довел до конца (нельзя только вертеть модель), потому что это решение мне показалось самым не очевидным и запутанным. Но с другой стороны оно универсальное и помогает портировать игры, которые в противном случае тяжело портировать.

❯ Unity

Хотел бы начать статью с Unity, но не получилось, так как для того, чтобы экспортировать проект Unity под ARM64, нужно купить лицензию, — другими словами, это платная опция. Про это можете почитать на форуме. Поэтому я не стал рассматривать Unity для своих целей.

Но не всё потеряно: в сети много успешных запусков Unity через эмуляторы x86, а точнее — Box64. Сначала я думал попробовать и включить в статью, но решил, что это совсем не то, что хотелось. Хотелось нативного решения, а тут Box64 может добавить свои нюансы. В статью я это не включил, но возможно, в будущем попробую, раз основной движок у меня — Unity, да и можно что-то портировать.

В любом случае, больше всего хотелось разобраться во внутренностях работы системы, это получилось сделать, поэтому легко могу предположить, что Box64 под капотом будет использовать что-то типа SDL2, через PortMaster с gptokeyb.

❯ HTML5

Какое-то время я пробовал делать игры под «Яндекс Игры». Ничего серьезного не получилось, но появился закономерный интерес: а как обстоит дело с HTML5 на ретро-консолях? Может быть, взять готовую свою игру и запустить на ней? А оказалось, что всё очень плохо и никаких решений нет. Я правда не знаю, можно ли скомпилировать Electron под SDL2; если что-то такое было, то, возможно, всё бы и работало. Про браузеры, кстати, на ретро-консолях тоже мало информации.

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

Первое, что бросается в глаза, что для его работы нужно установить на ретро-консоль node.js! Самое забавное, что все разделы на моей ретро-консоли были в exFAT, а на ней нельзя использовать символьные ссылки, из-за чего node.js не устанавливается. Пришлось делать хитрую манипуляции: взять вторую флеш-карту, отформатировать её в ext3 (ext4, выяснилось экспериментально, не монтируется в KNULLI), скачать архив с файлами node.js под ARM64 и распаковать все это на флеш-карту.

Второе, это оно запустилось, но оказалось, что никто не тестировал основную ветку проекта и он падал на ошибке. Ошибку пришлось поправить и мне удалось запустить пример, он заработал.

И как я понял (правда, не до конца), что все игры запускаются через команду npm run dev или что-то похожее, т. е. через npm. Т. е. это какое-то странное поведение, странный эмулятор. Возможно, я не до конца понял, как он работает, но это не важно, так как моя игра не запустилась и выдала ошибку, что каких-то классов нет.

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

❯ Выводы

Если подводить итоги, то ретро-консоли — это очень интересное устройство. Например, есть Nintendo Switch или другие популярные консоли — они тоже интересные устройства, но у них есть своя экосистема: это не просто устройства, а платформа, окружение, среда со своим магазином и своими инструментами разработчика. Производители ретро-консолей сумели создать хорошее железо, найти нишу, но, очевидно, не всесильны и решили упростить себе задачу, используя эмуляторы. И понадеялись на энтузиастов, которые создают свои прошивки и сами портируют игры под PortMaster — систему, которая создана тоже сообществом и заменила в привычном понимании магазин для ретро-консолей.

Мне очень понравилось это устройство, это как какой-нибудь DIY-набор для сборки, только здесь больше приключений. Но даже не так: здесь очень много чего нет, это как когда-то раньше были Linux, Android, лет десять-пятнадцать назад. Всё держится на честном слове. Поэтому если кто-то хочет ворваться, то здесь много чего можно попробовать. Например, добавить поддержку Mali в SDL3 или написать свой браузер.

Если рассматривать движки из статьи, то самый простой будет Godot, затем raylib, а уже потом все остальное. При этом у PortMaster есть документация, краткая, но содержательная, поэтому можете посмотреть есть ли там что-нибудь еще. Например, кажется GameMaker хорошо поддерживается, может что-то еще есть.

И наверно еще можно добавить, что не стоит ожидать хорошего качества от дешевых ретро-консолей, в них может в любой момент что-то отвалиться, но благо это, надеюсь, чинится, как в моем случае экран, WiFI и флеш карта.

Заходите в гости в мою группу ВК, Планета M039. Буду очень рад, может быть что-то там найдете интересное.

Все исходники к статье можете посмотреть в репозитории portmaster-playground.


Разрабатывайте и развивайте свою игру (и не только) с помощью облачного хостинга для GameDev 

Перейти ↩
Может быть интересно: