Пару лет я в соло разрабатывал максимально нишевую игру "для программистов" (NebuLeet) на довольно нишевых технологиях (Go + ebitengine), и вот теперь, после релиза, я хочу рассказать про одну из интересных особенностей этой игры - визуальном программировании логики игровых юнитов.
Визуальный язык в игре прошёл несколько итераций развития, от неявных аргументов команд через стек, до чего-то типа регистровой модели, где у ячеек памяти есть имена, а команды принимают аргументы явно.
Вас ждёт увлекательная околокомпиляторная/языковая статья с игровым применением. Всё-таки, языки программирования для игр - это ведь отдельный жанр.
Задумка игры, референсы
В старших классах школы я поиграл в Carnage Heart. Там нужно было дизайнить роботов и писать им алгоритм для сражения на визуальном языке игры. Мне настолько понравилась концепция этой игры, что я пробовал набросать подобное в знакомом мне тогда Game Maker, но ничего не выходило. Миллионы лет спустя я стал способен создать что-то похожее.

Carnage Heart не единственная игра из жанра, и даже в те ранние дни я успел попробовать Snake Battle. Из других игр, на которые я обращал внимание, я бы назвал Gladiabots, Human Resource Machine (+сиквел), а так же игры Зактроников. Для меня общее между этими играми именно способ решения задачи (визуальный кодинг), а не сама задача.

HRM, Gladiabots, Snake Battle
Human Resource Machine тут самый простой в плане механик - это просто визуальный ассемблер, даже для управления используются всякие JUMP (goto). Gladiabots немного интереснее, там уже деревья решений с нодами, которые мы соединяем связями, как в каких-нибудь Unreal Engine Blueprints. Carnage Heart ещё занимательнее, там пространство для кода двухмерное, и направление исполнение кода может быть хоть простым, хоть зацикленным зиг-загом. Snake Battle в этом списке наиболее уникален, в нём программирование делается через заполнение 9 карточек для распознания паттернов.
Даже Opus Magnum для меня игра из этой коллекции, команды конвейеров - это и есть наш код.
Я когда-нибудь напишу сравнительную статью со всеми этими способами описывать внутриигровые алгоритмы.
Как фанат Star Control 2 (Ur-Quan Masters) и Космических Рейнджеров, я решил делать боёвку в космосе. Корабли должны летать и сражаться, а всё это управлялось бы через их программы, составленные прямо в игре.
На видео ниже небольшая демонстрация сражений кораблей из игры и процесса их программирования. К такому мы и будем двигаться.
Ludum Dare и первый прототип

Astro Heart был сделан за пару дней с целью проверить, насколько быстро я могу создать хотя бы базовую игру с этой механикой. И может ли это хотя бы минимально интересно играться в формате геймджем-прототипа.
В таких играх я жду "ненормального программирования", а не набора текста на каком-нибудь JS, поэтому целевое ощущение от создания алгоритмов должно быть похожим на решение паззла. Язык должен оптимизироваться под игру и интересность изучения, а не под прагматичные критерии, которые обычно регулируют другие ЯП.
Итак, у нас будет некий набор команд. Часть из них отдаёт конкретные приказы юниту, а часть нужна для вычислений аргументов этих команд, а также для условного исполнения.
В качестве твиста, вместо одного "холста" для программы, у нас будут отдельные программы для навигации и каждого оружия. Это немного усложняет синхронизацию юнита как единого целого, но добавляет реюзабельности деталей (это свойство окажется очень полезным в финальной игре).
Стековая реализация интерпретатора здесь выглядела привлекательно - это и легкая в реализации модель исполнения, и что-то необычное для программистов. Всё-таки, мало кто всерьёз писал код на Forth-подобных языках. Но это данные, а что по ветвлениям?
Возьмём вот эту программу:

Этот ранний прототип и его исходники доступны на itch io (игра написана на Go)
Здесь мы сначала бросаем на стек позицию цели, потом вызываем функцию вычисления дистанции между нами и позицией на верхушке стека (позиция цели), и исполняем команду сравнения, которая выполнит прыжок, если условие не будет выполнено. Прыжок выполняется на следующий паттерн (branch 2). В начале бранча, обычно, ставятся условия, а затем уже действия, которые нужно выполнить. Если бранч исполнен до конца, программа сбрасывается и начинается с первого паттерна. В итоге это что-то вроде паттерн-матчинга.
Для простенькой демки с лимитом в 3 бранча и 10 инструкций в каждом, это работало нормально. Первой проблемой стало то, что на более крупном масштабе програм работать со стеком в прямом виде не очень удобно.
Я не буду скрывать, что решение отбросить стековую модель было не очень радостным - регистры/ячейки памяти более скучны и обычны, хоть и более удобны. Здесь было очень трудно измерить плюсы и минусы без более длительных экспериментов.
Ячейки памяти

Этот код вычисляет дистанцию между нами и целью, а результат записывается в ячейку C.
Вместо того, чтобы бросать значение на стек, теперь любая команда стала записываеть её результат в автоматическую ячейку (имена типа A, B, ...).
Для того, чтобы воспользоваться этим значением, нужно применять команду READ. Альтернатива в виде связей через стрелочки не рассматривалась - мне это кажется неудобным как для чтения (когда нить идёт через несколько команд), так и для редактирования.
Простейшие команды, которые не имеют аргументов, можно сразу использовать в качестве аргумента. Пример выше можно переписать в одну строку, если подставить self pos и target pos на место их использования (то есть, вместо READ-команд).
Система ветвления на этом этапе была идентична, разве что веток стало больше. Любые команды, вычисляющие условия, перепрыгивали на следующую страницу. Никаких контролируемых переходов по этим страницам не было - это всё ещё должно было играться как подбор шаблонов, а не как ассемблер с такими неудобными метками.
Это так же означало, что нельзя написать цикл. Единственная форма цикла - это повтор исполнения программы с самого начала (первой ветки).

Минусы такого подхода к условному исполнению стали проявляться довольно быстро, хотя поначалу это казалось любопытным челленджем. Ведь в моём видении кодинг в этих играх - это решение относительно нетипичных задач абсолютно нетипичными инструментами. Но всему есть предел.
Этот подход красиво описывает логику через "И", но крайне неудобен для "ИЛИ":
# branch 1
01 | cond A
02 | cond B
03 | do something X
04 | do something Y
В модели, описанной выше, если cond A будет ложно, исполнение ветки сразу же прерывается. Поэтому между условиями A и B можно представить оператор &&. Для или-условий можно было написать что-то такое:
# branch 1
01 | cond A
02 | do something X
03 | do something Y
# branch 2
01 | cond B
02 | do something X
03 | do something Y
Получаем дублирование всего, что идёт после проверок условий. Конкретно этот недостаток можно было бы обойти вызовом функций, вынеся код для действий в одно место, а потом вызывая его под условиями в одну строчку.
Более фундаментальный недочёт - удобное редактирование таких програм требует аккуратного расположения паттернов. Нужны были бы операции вставки между, перемещение бранчей, swap, и так далее. Но ведь между бранчами в теории возможны побочные эффекты, потому что никто не заставляет начинать паттерн с условий, можно и код какой-то выполнить (движки включить, например). В такой ситуации менять паттерны местами почти всегда будет приводить к ошибкам.
Мне интересно, как далеко можно было бы развить эту идею. Бранчи и паттерны - это весьма уникальный способ структурирования кода. Это был не самый ужасный вариант, но довольно сложный в доработке с точки зрения UX как языка, так и интерфейса для него.
Цвета и условное исполнение команд
Все мы знакомы с goto или похожего рода прыжками (JMP). Тот же if в высокоуровневых языках - это чаще всего прыжок через код под его блоком, если условие не было выполнено. В этом плане команда прыжка JNZ или ей подобная - условная (может прыгнуть, а может и не прыгнуть), а весь код внутри про условия не знает.
На уровне машинного кода, идея условного исполнения команд, которые даже не являются прыжками, не нова. В ARM ISA есть нечто подобное, но свой вариант обобщения я бы всё равно назвал достаточно экзотическим.
В ARM некоторые команды (напр. CMP), выполняя операцию, выставляют флаги процессора. После этого это можно использовать как для прыжка, так и для того самого условного исполнения. Ниже псевдоасм (обратите внимание на EQ суффиксы):
CMP r0, r1
ADDEQ r2, r2, 10
SUBEQ r2, r3, 10
В этом коде если
r0 == r1, то выставлен флагEQ. И, если он выставлен, будут выполненыADDEQиSUBEQ, иначе они пропускаются. Никаких джампов в этом коде нет.
В NebuLeet я воспользовался немного изменённой идеей: любая команда имеет присвоенный ей цвет. Например, серый, синий или бирюзовый. Код выше можно было бы выразить как-то так:

Цвет связывает команду с флагом. Если синий флаг имеет нулевое значение (false), то все команды, отмеченные синим цветом, будут пропускаться интерпретатором.
Значения флагов можно читать и обновлять - выбираем вместо автоячейки нужный флаг и результат операции будет записан туда.

Здесь показана связь флагов и цветов, которые присвоены командам.
Этот метод записи одновременно достаточно необычен, чтобы восприниматься свежо в рамках геймплея, но так же очень гибок - с ним можно удобно выразить и операцию &&, и операцию ||. Используя несколько флагов можно реализовать что-то вроде вложенных if'ов.
Технически, у каждой скомпилированной команде в байт-коде есть пара битов под выбранный цвет, а во время исполнения текущий контекст хранит компактную uint8 маску состояния флагов. По умолчанию эта маска равна 0b111 (так как флага всего три). С точки зрения производительности - это довольно высокая цена за условное исполнение, хотя для игры с относительно мелкими программами вполне годится.
Несколько трюков, связанных с флагами
Чтобы лучше было понятно, где там творческий простор, я покажу несколько способов применения цветов и флагов для составных условий.
Задача первая: стрелять тогда, когда враг достаточно близко и находится в конусе поражения оружия.
Первый способ будет самый фундаментальный и полноценный. С ним можно вкладывать один if в другой почти произвольно. То есть, наше решение какое-то такое:
if dist_to_target() < weapon_range:
if enemy_in_arc(...):
fire_at(target_pos)
На игровом языке это выражается так:

Синим цветом/флагом мы отмечаем внешнее условие, а вторым цветом - внутреннее. Здесь важно внешний цвет выставить в 0, так как все флаги по умолчанию имеют ненулевое значение.
Если после тела вложенного if больше ничего выполнять не нужно, как и в нашем случае, то можно воспользоваться трюком перезаписывания флага того же цвета, под который размечена команда.

А иногда удобно делать аналог early return:

Задача вторая: стрелять тогда, когда рядом есть противник.
Один из вариантов решения - это просканировать сектор окружности на наличие врагов. У этой команды есть лимит в ширину 180 градусов, поэтому для ответа нужно сканировать дважды - вперёд и назад.
В обычной ситуации можно было бы воспользоваться хитростью и просто вычислить оба результата и сравнить их. Если сумма врагов сзади и спереди больше нуля, то нужно стрелять:

Но предположим, что там не одна команда на сканирование, а вызов функции или какие-то блоки кода, которые мы не хотим исполнять в случае прохождения первой проверки (то есть, нам нужно short-circuit поведение). Такое тоже возможно.

Обрастание фичами
Остальные добавления в язык были довольно тривиальными. Например, появилась возможность выносить код в функцию и вызывать её через CALL.
Так как разные детали программируются отдельно, для синхронизации между ними можно использовать переменные. Порядок исполнения в игре довольно предсказуемый, и он всегда однопоточный, поэтому сделать свой небольшой пайплайн вполне можно.
Есть механизмы обмена данными между юнитами. Из множества возможных вариантов я выбрал очень своеобразный - общение через цвета. Каждый юнит может выставить свою подсветку, состоящую из R, G и B компонент. Эти цвета не только для вида, но так же для общения - союзный корабль может сканировать область на выбранную компоненту цвета, используя другие каналы цвета для филтрации. Через это можно реализовать свой протокол обмена данными между юнитами.

Общение юнитов через цвета - это просто.
В инструментарий добавлялось много команд специально для отладки. Команды отрисовки, например. Можно выводить числа, рисовать окружности и линии.

А с помощью некоторых нехитрых манипуляций и программирования дронов можно сделать себе пиксельный дисплей:
Здесь отредактирован игровой спрайт для диода, чтобы он был крупный и без градиента. В остальном это всё сделано внутри самой игры, через команды работы с цветами. Цвета на видео я переключал по нажатию на клавишу (обработка этого видна в коде слева).
Баланс команд и избыточность
В языке довольно много избыточности by design. Одна и та же задача, в идеале, должна иметь разные решения. Зачастую, некоторые команды имеют ограничения для того, чтобы не сделать другие команды слишком бесполезными на их фоне.
Например, операция scan arc (мы применяли её выше) очень полезна, так как позволяет узнать о количестве врагов в выбранном секторе, но она работает только относительно вашего корабля. Нельзя сканировать от точки A к точке B. Другая команда, которая выполняет похожее сканирование, делает это относительно запущенного маяка. Однако она возвращает не количество, а направление к ближайшему противнику.
Эта избыточность и возможность прийти к цели более чем одним путём так же означает, что я могу ограничивать пулы доступных команд на турнирах. Так, чтобы после одной победы было сложнее взять и использовать программу как есть для следующего соревнования. Скорее всего, придётся или пересмотреть алгоритм, или выразить нужные операции через другие.
Она же нужна, чтобы работала одиночная кампания. Вы начинаете игру лишь с базовым набором команд и постепенно открываете новые. Открытие этих наборов не фиксировано, поэтому разные игроки могут идти по немного разным путям эволюции от простого алгоритма к сложному, постепенно добавляя недавно открытые инструкции в свои функции. Это особенно важно в режиме без метапрогрессии программирования, где вы всегда стартуете без ранее открытых наборов команд.

Наборы команд в игре - это вот такие логические группы, которые нужно открывать через прохождение мини-паззлов с их применением. В каждом наборе в среднем 2-4 команды.
В будущем может появиться режим с ценой на каждую использованную операцию (по её типу), а у процессора будут ограничена максимальная ёмкость для вмещения этого кода. Так можно будет добавить дополнительную глубину, особенно поорщяя игроков использовать менее тривиальные способы программирования.
Вычисление языка
Как уже писалось выше, код игрока компилируется в байт-код, а затем исполняется простеньким кастомным интерпретатором внутри игры.
В Carnage Heart было ограничение по скорости процессора - программа могла прервать исполнение в момент, когда был достигнут лимит по тикам. В моей игре нет таких лимитов, отчасти чтобы было проще программировать - ведь пропуск одного тика может означать изменения состояния мира, а ваш код окажется между старым и новым состоянием.
Игра устроена так, что я могу симулировать битвы без графики, запуская код менеджера боя с исполняемым байт-кодом на вход. Несколько десятков-сотен симуляций в секунду выжать удаётся, хотя сильно зависит от програм и количества юнитов. Эти быстрые прогоны доступны и самим игрокам, чтобы можно было легко посчитать win rate одной тактики относительно другой.
Есть даже некоторый дизассемблер из этого байт-кода, но я его использую исключительно для отладки низкоуровневых багов в компиляторе и/или интерпретаторе.

Понравилось ли мне делать игру на Go?
Если кратко, то да. Почти все проблемы, которые у меня возникали, связаны скорее с разработкой игр в соло в целом, чем конкретно с Go.
Недавно мы с @Suvitruf записали выпуск подкаста, где как раз поговорили про разработку этого проекта. А если у вас больше вопросов, то ниже есть ссылка на релевантную группу в телеграме.
Сам опыт solo gamedev довольно своеобразен, но я рекомендую попробовать, если есть возможность. Такой опыт на минималках можно получить, например, сделав в соло игру на игровой джем.
Заключение
На этом базовый разбор языка программирования в NebuLeet окончен - и теперь от успешной победы над космическими культистами вас отделяет только медкомиссия! Впрочем, хочу вас порадовать: был дан негласный приказ признавать годными к управлению космической военизированной организацией с программируемыми андроидами всех.
Чего улыбаешься, рейнджер? Для таких, как ты, у нас есть чатик по разработке игр на Go в телеграме. Курс молодого геймдев-бойца ты уже освоил? Хорошо, а теперь - р-разойдись!
Комментарии (6)

NinaNina89
16.12.2025 21:29Цветовые флаги как условное исполнение - неожиданно годная идея. Похоже на старые ISA-шные трюки, только в игровом интерфейсе. И главное, это реально игровая механика, а не блюпринты ради блюпринтов

quasilyte Автор
16.12.2025 21:29Они ощущаются правда более привычно, чем другие варианты выше (паттерн-матчинг с выходом из бранча условной командой, так и двухмерные программы из Carnage Heart, где у тебя ветвление может идти в одну из 4 сторон и код ограничен внутри такой матрицы условно 40х40). Но если человек на низком уровне не программировал, то может вау-эффект выше.
Исходная идея с обрубанием исполнения по первому false была настолько неудобной в использовании (и без доп идей, которые мне в голову не пришли), что у меня конечно вьетнамские флешбеки от них. Но такая модель исполнения, как будто бы, ещё более необычная.

zarazaexe
это достойно большего, невероятно круто, вау