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

Вот такое первое впечатление от этой приставки. Очень ей доволен, но и проблем хватает. Если будете думать о такой приставке, то заглядывайте на 4PDA, там много информации о прошивках и всего такого, для первого знакомства почерпнете много полезной информации, но для разработки её не достаточно. Дальше перейдем к основному разделу статьи, про разработку.
❯ Разработка
Для разработки потребуется приставка с WiFi и компьютер с Linux. В принципе, если вы собираетесь делать игры на Godot, то можно их класть на SD карту и компьютер с Linux для компиляции не потребуется, но это крайне не удобно.
В статье я это не рассматривал, но WiFi еще нужен, чтобы запустить игру из командной строки через SSH, чтобы посмотреть отладочную информацию. Без этого бывает никуда не деться. Например, в PortMaster это файлы с расширением .sh, достаточно их запустить в консоли и посмотреть, что выдает.
❯ Подготовка
Выбор прошивки
К данному моменту у меня нет другой приставки, поэтому всё, что здесь будет рассказано, это про Anbernic RG35XX Pro. Это приставка новая, 2025-го года, но уже существуют для неё сторонние прошивки, в основном есть muOS и KNULLI.

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

И я задался вопросом как собираются прошивки под данную систему. Сначала я нашел ветку обсуждений по muOS и репозиторий с утилитами, которые навели меня на мысль, что они не собирают Linux под неё, а берут образ официальной прошивки и вытаскивают все внутренности из неё.
Потом я посмотрел вики и репозиторий KNULLI. Там я увидел информацию как собрать свою прошивку. Например, для её сборки необходимо 180 Гб на жестком диске. Еще в репозитории KNULLI есть файлы buildroot, а это очень хороший знак, значит можно скомпилировать и собрать любую другую программу. В конце концов мои догадки оправдались, когда увидел эту сноску в вики — получается действительно Linux берется из официального образа, но там и есть сноска, что можно скомпилировать своё, но будут проблемы. Пока компилировать своё не планирую, но возьму на заметку.
Поэтому дальше я буду использовать KNULLI, т.к. это система проще поддается сборке и у неё больше документации, хотя почти все можно проделать и под muOS.
Замечание: с точки зрения игр muOS более дружелюбна и там у меня без проблем запускались игры, а вот на KNULLI пришлось скачивать BIOS файлы. Имейте это ввиду.
О 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

Чтобы не делать статью нечитаемой, я постараюсь коротко рассказать с какими трудностями столкнулся и как собирается проект, без подробной инструкции. Если, что обращайтесь к инструкции raylib.
Исходный код примера на Godot можете взять здесь. Он проще, чем raylib, т.к. все настраивается через редактор, а кода совсем немного.
Но прежде, чем его рассматривать, стоит рассказать как происходит запуск программы на Godot у ретро-консолей. А на самом деле просто, игры на Godot запускаются через посредника под названием FRT. FRT это исполняемый файл, которому указываются ресурсы игры и он её запускает, и как вы могли догадаться, т.к. на ретро-консолях нет никаких графических оболочек (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 ↩

