Два порта Godot-игры за неделю: сборка под Linux и запуск на карманной консоли R36S через PortMaster

Я делаю «Балконный сад» — пиксельный симулятор растений в реальном времени на Godot 4.3. Игра задумана как фоновая: сад растёт, пока вы заняты своими делами. Сначала она вышла только под Windows, а игроки сразу попросили Linux. Потом я задумался, где ещё может спокойно жить такая игра, и ответ нашёлся быстро: в кармане, на карманной консоли.
В статье расскажу про оба порта. Сначала про Linux-сборку: что нужно настроить в Godot, почему я отдаю архив .tar.gz и какие баги нашёл первые же игроки. Потом про R36S и PortMaster: как игра понимает, что запущена на консоли, как кнопки превращаются в клавиши, как сделать интерфейс, рассчитанный на мышь, управляемым крестовиной, и как упаковать всё для PortMaster. Кода немного, но проблем хватает.
Часть 1. Порт на Linux
Экспорт
В Godot порт на Linux делается пресетом экспорта «Linux». Мне хватило нескольких настроек:
name="Linux"
platform="Linux"
export_path="build/linux/BalconyGarden.x86_64"
exclude_filter="tools/*, _test/*, README.md, build/*"
include_filter="plants_data.csv"
binary_format/embed_pck=true
binary_format/architecture="x86_64"
embed_pck=true— игра вшита в один исполняемый файл. Игрок получает один файл вместо двух, ничего не потеряется и не надо думать о путях.exclude_filter— тестовые сцены, скрипты генерации спрайтов и сборки не должны попадать в игру.include_filter="plants_data.csv"— таблица с параметрами растений лежит в обычном csv, и Godot по умолчанию не кладёт такие файлы в сборку, пока их не добавишь вручную. Без этой строки игра на Linux просто не нашла бы растения.
Экспорт идёт с одного компьютера: для сборки под Linux не нужен Linux. Нужны только шаблоны экспорта Godot 4.3.
Архив: tar.gz, а не zip
Готовый файл BalconyGarden.x86_64 весит около 66 МБ, в архиве около 25 МБ. Я упаковываю его в .tar.gz, а не в zip. Причина простая: в tar сохраняется флаг «исполняемый». Из zip файл часто распаковывается без этого флага и игроку приходится вручную делать chmod +x.
Инструкция для игрока получилась из двух строк:
tar xzf BalconyGarden_linux.tar.gz
./BalconyGarden.x86_64Что нашлось при проверке
При проверке на Linux в режиме «Подоконник» (игра сворачивается в маленькое прозрачное окно поверх рабочего стола) нашлись глитчи с землёй в горшке. Причина была в самом спрайте: в нём остался зазор в 22 пикселя. Я закрасил его цветом земли и добавил видимую полоску почвы внутри обода, чтобы пустой горшок выглядел как горшок. Исправление вошло в версию 0.3, а в углу экрана теперь показывается номер версии, чтобы игроки сразу видели, свежая ли у них сборка.
Прозрачное окно и «клики сквозь пустые места» в Godot идут через DisplayServer.window_set_mouse_passthrough. На Linux это работает в зависимости от оконного менеджера и композитора, поэтому важно просить игроков с разных систем показать, как выглядит режим у них.
Часть 2. Порт на консоль R36S
Что такое R36S и PortMaster
R36S — недорогая карманная консоль на Linux (у меня на даркос) с экраном 640×480, крестовиной, четырьмя кнопками, плечевыми кнопками и двумя стиками. Обычно на ней запускают эмуляторы, но есть и проект PortMaster: коллекция портов игр под такие устройства. Он ставит игры, подтягивает нужные «рантаймы» и запускает их одним пунктом меню.
Для Godot у PortMaster есть готовый рантайм godot_4.3: сам движок лежит на устройстве, а игра приезжает отдельно в виде одного файла .pck. Это очень удобно: не нужно собирать Godot под каждую прошивку.
1. Как игра понимает, что она на консоли
Один и тот же проект должен работать и на ПК, и на консоли. Поэтому режим включается переменной окружения или аргументом запуска:
static func enabled() -> bool:
return OS.get_environment("BG_HANDHELD") == "1" or OS.has_feature("handheld") \
or "--handheld" in OS.get_cmdline_user_args() or "--handheld" in OS.get_cmdline_args()
Переменную BG_HANDHELD=1 выставляет скрипт запуска из пакета PortMaster. Если она есть, в сцену добавляется отдельный узел Handheld. Он ничего не знает про логику игры, только перестраивает управление:
прячет всё, что нужно мыши (кнопку «Подоконник», верхнюю панель с кнопками);
делает поля заметок только для чтения, потому что клавиатуры на консоли нет;
прячет курсор мыши;
рисует подсказку по кнопкам вместо кнопок.
Такой подход хорош тем, что основной код игры почти не менялся: консольный режим живёт в одном файле и включается одной строкой.
2. Кнопки консоли → клавиши
У PortMaster есть утилита gptokeyb: она слушает геймпад и превращает кнопки в нажатия клавиш. Поведение описывается в маленьком конфиге .gptk:
a = enter
b = esc
x = p
y = k
start = m
l1 = q
r1 = e
up = up
down = down
left = left
right = right
left_analog_up = up
left_analog_down = down
left_analog_left = left
left_analog_right = rightЛевый стик я тоже сделал стрелками: так игрок может выбирать и стиком, и крестовиной.
Важное решение, о которое я споткнулся: в Godot я полностью убрал события геймпада из стандартных действий (ui_left, ui_accept и других) и оставил только клавиатуру:
func _setup_actions() -> void:
# кнопки работают только через клавиши от gptokeyb:
# так раскладка одинакова на всех прошивках
for a in ["ui_left", "ui_right", "ui_up", "ui_down", "ui_accept", "ui_cancel"]:
for ev in InputMap.action_get_events(a):
if ev is InputEventJoypadButton or ev is InputEventJoypadMotion:
InputMap.action_erase_event(a, ev)
Почему так. Если оставить и геймпад, и клавиши, одно нажатие может прийти дважды: один раз как кнопка геймпада, второй как клавиша от gptokeyb. А на разных прошивках кнопки геймпада нумеруются по-разному. Клавиши от gptokeyb одинаковы везде, поэтому надёжнее опираться только на них.
Раскладка получилась такая:
Кнопка | Действие |
|---|---|
A | Полить / выбрать в окне |
X | Аптека |
Y | Книга садовника |
Start | Меню |
L1 / R1, крестовина | Предыдущий / следующий горшок |
Крестовина | Двигаться по списку |
B | Закрыть окно |
Select + Start | Выход (хоткей PortMaster) |

3. Интерфейс, рассчитанный на мышь, но без мыши
Игра изначально делалась под мышь: кнопки сверху, окна семян, аптеки, склада, книги. На консоли мыши нет, поэтому каждое окно нужно было сделать управляемым с кнопок.
Идея простая: Godot уже умеет «фокус» у элементов Control. Нужно лишь включить его у кнопок и сделать фокус видимым:
func _make_focusable(b: Control) -> void:
b.focus_mode = Control.FOCUS_ALL
b.add_theme_stylebox_override("focus", _focus_box) # жёлтая рамка
Рамка фокуса — жёлтая, 2 пикселя, чтобы её было видно на маленьком экране и на любом фоне.
Дальше пришлось разобраться с мелочами:
Фокус должен куда-то попадать. Когда окно открывается, первой выбирается первая кнопка. Но списки семян и склада пересобираются, поэтому фокус ставится не сразу, а через кадр (
await get_tree().process_frame).Прокрутка списка должна идти за фокусом. Для всех
ScrollContainerвключеноfollow_focus = true, иначе выбранная кнопка уходит за край экрана.Фокус нельзя терять. Если после выбора семени окно закрылось, а фокус остался на невидимой кнопке, управление «умирает». После выбора фокус сбрасывается и возвращается к подоконнику.
Книга садовника устроена иначе. В ней нет кнопок, а страницы листаются крестовиной < > , поэтому фокус там не нужен.
У лекарств нет названий на кнопках, только иконки. Когда фокус попадает на иконку, название всплывает подсказкой над растением.
Обработка нажатий вынесена в unhandledinput: сюда попадает всё, что не забрала выбранная кнопка. Так «A на подоконнике» поливает растение, а «A в окне» выбирает пункт:
elif open.is_empty() and event.is_action("ui_accept"):
GameCore.apply_item(GameCore.WATER_ID) # A на подоконнике — полить
4. Меню по Start
На ПК сверху есть ряд кнопок: «Семена», «Выкопать», «Склад», «Пауза», «Сохранить» и другие. На консоли для них не хватило бы кнопок, поэтому всё собрано в одно меню по Start:
const MENU := [
["seeds", "Семена", "Seeds"], ["dig", "Выкопать", "Dig up"], ["storage", "Склад", "Storage"],
["freeze", "Заморозить / разморозить", "Freeze / unfreeze"], ["pause", "Пауза", "Pause"],
["save", "Сохранить", "Save"], ["music", "Музыка вкл/выкл", "Music on/off"],
["lang", "Язык: English", "Language: Русский"], ["exit", "Выход", "Quit"]]
Меню строится кодом, а русский и английский тексты лежат рядом. Это упростило жизнь: язык переключается прямо из меню, без отдельного экрана.
5. Упаковка для PortMaster
Пакет для PortMaster — это zip с понятной структурой:
balconygarden.zip
├── Balcony Garden.sh # скрипт запуска
└── balconygarden/
├── balconygarden.pck # игра
├── balconygarden.gptk # раскладка кнопок
├── cover.png, screenshot.png
└── licenses/
Рядом лежат port.json (название, описание, жанр, какие рантаймы нужны) и gameinfo.xml для списка игр.
Игру я экспортирую не как исполняемый файл, а как .pck:
godot --headless --export-pack "<имя пресета>" build/pm/balconygarden.pck
Главная часть — скрипт запуска. Он находит PortMaster, проверяет и скачивает рантаймы, монтирует их и запускает движок:
godot_runtime="godot_4.3"
weston_runtime="weston_pkg_0.2"
...
$GPTOKEYB "$godot_executable" -c "$GAMEDIR/$gptk_filename" &
$ESUDO env $weston_dir/westonwrap.sh headless noop kiosk crusty_x11egl \
XDG_DATA_HOME="$CONFDIR" BG_HANDHELD=1 \
"$godot_dir/$godot_executable" --resolution "${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}" -f \
--rendering-driver opengl3_es --audio-driver ALSA --main-pack "$GAMEDIR/$pck_filename"
Что здесь важно:
weston— это маленький оконный сервер из рантайма PortMaster: Godot 4.3 рисует своё окно внутри него в режиме киоска (так сделано во всех Godot-портах PortMaster).--rendering-driver opengl3_es— Godot 4 на таких слабых GPU работает через OpenGL ES.BG_HANDHELD=1— та самая переменная, которая включает консольный режим.XDG_DATA_HOME="$CONFDIR"— сохранения лежат рядом с игрой, а не где-то в системе. Так их проще найти и скопировать.
Шрифт и всё остальное внутри .pck, поэтому дополнительные файлы не нужны.
6. Тест на настоящей консоли
Автоматические тесты в редакторе проверяют, что фокус ходит по спискам, что окна открываются и закрываются, что полив и лекарства применяются. Но настоящее управление проверяется только на устройстве.
При первом запуске заметил что кнопки верх/вниз на правой кристовине перепутаны местами.
Одна проблема уже решена еще на версии для Windows: без bios батарейки часов время сбрасывается при выключении. Для игры, где растения растут в реальном времени, это неприятно: пока консоль выключена, часы не идут, и растения просто не растут. Один игровой час равен 10 минутам настоящего времени, а предел оффлайна — 400 суток:
func save_game() -> void:
cfg.set_value("game", "saved_at", Time.get_unix_time_from_system())
...
func load_game() -> bool:
...
var elapsed := Time.get_unix_time_from_system() - float(cfg.get_value("game", "saved_at", 0.0))
if elapsed > 0.0:
_accum += elapsed
var hours := int(_accum / REAL_SECONDS_PER_GAME_HOUR)
_accum -= hours * REAL_SECONDS_PER_GAME_HOUR
for i in mini(hours, MAX_OFFLINE_HOURS):
tick_hour()Если время ушло в прошлое, elapsed получается отрицательным, проверка elapsed > 0.0 не пропускает его, и сад просто не растёт за время, пока консоль была выключена.
Итог
Что получилось:
Linux-сборка: один файл, один архив, две строки в инструкции;
один проект работает и на ПК, и на консоли; консольный режим включается одной переменной;
всё управление свелось к 8 кнопкам и одному меню по Start;
основной код игры почти не пришлось менять, консольный режим живёт в одном файле
Handheld.gd.
Что я бы посоветовал, если собираетесь делать то же:
Решите заранее, что на консоли «лишнее» (мышь, клавиатура, окна), и уберите это в одном месте.
Не смешивайте события геймпада и клавиши от
gptokeyb.Следите за фокусом: большая часть багов управления сводится к «фокус потерялся».
Тестируйте на живом устройстве как можно раньше.
Версии для Windows, Linux и для PortMaste лежат на itch.io, а также — в релизах на GitHub. Если у вас есть R36S или другая консоль с PortMaster, буду рад отзывам: как идёт, не ломается ли управление и какие баги имеются.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.