Архитектура игр на Godot: Создание движка внутри движка

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

1. Фундамент архитектуры: отделение игрового цикла (Game Loop) от жизненного цикла Godot

Фундамент архитектуры: отделение игрового цикла (Game Loop) от жизненного цикла Godot

Добро пожаловать в курс «Архитектура игр на Godot: Создание движка внутри движка». Мы начинаем путешествие не просто по изучению инструментов Godot, а по созданию надежной, переносимой и масштабируемой архитектуры.

Многие новички начинают свой путь с создания сцены, добавления спрайта и написания скрипта, который наследуется от Node2D и использует _process(delta) для движения. Это отлично работает для прототипов. Но когда проект разрастается, такой подход превращается в «спагетти-код», где логика игры намертво привязана к визуальному представлению и конкретному движку.

Наша цель — научиться писать игру так, чтобы Godot был лишь «оболочкой», предоставляющей рендеринг, ввод и звук, в то время как «мозг» игры (ваш микродвижок) оставался чистым и независимым.

Проблема «Godot Way» в больших проектах

Godot навязывает удобную, но опасную для архитектуры парадигму: Дерево Сцены (Scene Tree). Когда вы пишете логику внутри методов _process или _physics_process конкретных узлов, вы создаете жесткую связность (coupling).

Почему это плохо для сложной архитектуры:

  • Зависимость от движка: Если вы захотите перенести логику на Unity или собственный C++ движок, вам придется переписывать всё, так как код зависит от классов Godot (Node, Vector2, Input).
  • Сложность тестирования: Чтобы протестировать поведение персонажа, вам нужно инициализировать сцену, движок и дерево узлов. Вы не можете просто запустить юнит-тест для класса «Персонаж».
  • Недетерминированность: Порядок выполнения _process в разных узлах может варьироваться, что усложняет создание предсказуемой симуляции (например, для сетевого кода или реплеев).
  • Концепция «Микродвижок»

    Идея заключается в создании чистой модели симуляции, которая ничего не знает о том, что она отображается в Godot. Эта модель живет в своем собственном цикле обновления.

    !Диаграмма, показывающая разделение ответственности между движком Godot и вашим внутренним микродвижком.

    В этой архитектуре Godot выполняет роль View (Представления) в паттерне MVC, а ваш микродвижок — это Model (Модель) и Controller (Контроллер).

    Игровой цикл (Game Loop): Анатомия времени

    Сердце любой игры — это бесконечный цикл. В простейшем виде он выглядит так:

  • Обработка ввода (Input)
  • Обновление состояния (Update)
  • Отрисовка (Render)
  • Godot делает это за нас. Однако, чтобы владеть ситуацией, мы должны взять контроль над шагом 2 — Обновлением состояния.

    Дельта-время и проблема плавающего шага

    Метод _process(delta) вызывается каждый кадр. Время между кадрами (delta) может меняться. Если FPS падает, delta растет. Для простой аркады формула перемещения выглядит так:

    Где: * — новая позиция объекта. * — текущая позиция объекта. * — вектор скорости. * — время, прошедшее с прошлого кадра (delta).

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

    Паттерн «Аккумулятор» (Fixed Time Step)

    Чтобы наш микродвижок работал стабильно и предсказуемо, мы должны отвязать время симуляции от времени рендеринга. Мы будем использовать фиксированный шаг времени (например, 60 раз в секунду), независимо от того, сколько кадров выдает монитор (30, 144 или 60).

    Алгоритм работы нашего собственного цикла внутри Godot:

  • Godot вызывает _process(delta).
  • Мы добавляем delta в переменную-аккумулятор.
  • Пока в аккумуляторе достаточно времени для одного «тика» симуляции, мы обновляем мир.
  • Математически условие цикла выглядит так:

    Где: * — накопленное время (аккумулятор). * — фиксированный шаг времени (например, сек для 60 Гц).

    Реализация на GDScript

    Давайте создадим структуру, где логика отделена от узлов.

    1. Чистая логика (Микродвижок)

    Создадим класс, который не наследуется от Node, а, например, от RefCounted (чтобы управлять памятью, но не висеть в дереве сцены). Это будет наш «Мир».

    2. Связующее звено (Godot Loop)

    Теперь создадим узел, который будет жить в сцене Godot и управлять нашим микродвижком.

    ``gdscript

    game_runner.gd

    extends Node

    var _simulation: SimulationWorld var _accumulator: float = 0.0 var _fixed_dt: float = 1.0 / 60.0

    Ссылка на визуальное представление (например, спрайт)

    @onready var _player_sprite: Sprite2D = P_{render}P_{prev}P_{curr}\alpha\frac{accumulator}{time\_step}$ (значение от 0.0 до 1.0).

    Этот метод позволяет получить «масляную» плавность картинки даже при низкой частоте обновления логики.

    Преимущества подхода

  • Полный контроль: Вы точно знаете, в каком порядке обновляются ваши системы (AI, физика, кулдауны).
  • Портативность: Класс SimulationWorld практически не использует API Godot (кроме базовых типов вроде Vector2). Его легко перенести или использовать на сервере.
  • Отладка: Вы можете поставить игру на паузу, просто перестав вызывать _simulation.tick(), при этом интерфейс и анимации меню продолжат работать, так как _process Godot не остановлен.
  • Заключение

    Мы заложили первый камень в фундамент: отделили время симуляции от времени рендеринга и вынесли логику из узлов сцены в чистый класс. Теперь Godot для нас — это мощный инструмент ввода-вывода, а не диктатор архитектуры.

    В следующей статье мы разберем, как наполнить наш SimulationWorld` сущностями и компонентами, не превращая код в хаос.

    2. Данные и сущности: реализация ECS и управление состоянием мира без жесткой привязки к Node

    Данные и сущности: реализация ECS и управление состоянием мира без жесткой привязки к Node

    В предыдущей статье мы совершили революцию в нашем подходе к Godot: мы отобрали у движка контроль над временем, создав собственный игровой цикл (Game Loop). Теперь у нас есть сердцебиение игры, но само тело пока отсутствует. Наш SimulationWorld пуст.

    Традиционный подход Godot предлагает нам заполнить мир узлами (Node). Персонаж — это CharacterBody2D, пуля — это Area2D, инвентарь — это Node. Это интуитивно понятно, но, как мы обсуждали ранее, ведет к жесткой связности и проблемам с производительностью при масштабировании.

    Сегодня мы научимся конструировать игровые сущности так, как это делают в больших движках и высокопроизводительных играх. Мы перейдем от Объектно-Ориентированного Программирования (ООП) к Компонентно-Ориентированному (ECS - Entity Component System) подходу, адаптированному для GDScript.

    Проблема наследования и «Божественные объекты»

    Представьте, что вы создаете RPG. У вас есть класс Unit. От него наследуются Player и Enemy. Вы хотите добавить NPC, который может торговать, но не может сражаться. Вы создаете NPC. А теперь вам нужен Merchant, который может сражаться, если его ударить. От кого ему наследоваться? От Enemy? Но он торгует. От NPC? Но он сражается.

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

    Кроме того, узлы (Node) в Godot — это тяжелые объекты. Они имеют координаты, сигналы, методы отрисовки, физические тела. Если вам нужно симулировать 10 000 летящих стрел, создание 10 000 узлов Area2D убьет производительность процессора (CPU).

    Философия ECS: Данные превыше всего

    Архитектура ECS (Entity Component System) предлагает радикально другой подход. Мы разделяем объект на три части:

  • Entity (Сущность): Это просто уникальный идентификатор (ID). Сущность ничего не знает о себе. Это просто «бирка» с номером.
  • Component (Компонент): Это чистые данные без логики. Например, PositionComponent хранит только x и y. HealthComponent хранит hp.
  • System (Система): Это логика, которая обрабатывает данные. Система Движения берет все сущности, у которых есть Position и Velocity, и меняет их координаты.
  • !Слева: Иерархия классов (ООП). Справа: Плоская структура данных (ECS).

    В нашем микродвижке Godot будет отвечать только за отображение, а ECS — за состояние мира.

    Реализация ECS на GDScript

    Чистый ECS (как в Unity DOTS или Bevy Rust) требует работы с памятью на низком уровне для кэш-локальности. В GDScript мы не можем управлять памятью напрямую, поэтому мы реализуем прагматичный ECS. Мы будем использовать классы RefCounted для компонентов, так как они легче, чем Node, и автоматически удаляются, когда на них нет ссылок.

    1. Компоненты: Контейнеры данных

    Компоненты должны быть максимально глупыми. Никаких функций update(), только переменные.

    2. Реестр (World Registry)

    Нам нужен менеджер, который хранит все сущности и их компоненты. В классическом ECS это часто называют «Миром» или «Реестром».

    3. Системы: Логика симуляции

    Система — это функция или класс, который выполняет работу над данными. Математически, система обрабатывает подмножество сущностей , которые удовлетворяют условию наличия определенных компонентов.

    Формально это можно записать так:

    Где: * — подмножество сущностей, которые будет обрабатывать система. * — множество всех сущностей в мире. * — конкретная сущность (ID). * — множество типов компонентов, требуемых данной системой (например, Позиция и Скорость). * — множество компонентов, которыми обладает сущность .

    Реализуем простую систему движения (MovementSystem). Она будет обновлять позицию на основе скорости.

    Интеграция в SimulationWorld

    Теперь вернемся к нашему классу SimulationWorld из прошлой статьи и внедрим туда ECS.

    Связь с Godot: View System

    Самый важный вопрос: как увидеть эти данные? Ведь ComponentPosition — это просто цифры в памяти, а не спрайт на экране.

    Нам нужен слой представления (View). Это скрипт в Godot (наследуемый от Node), который смотрит на данные ECS и обновляет сцену.

    !Однонаправленный поток данных от симуляции к визуализации.

    В GameRunner (наш главный узел в сцене) мы добавим метод синхронизации:

    Почему это мощно?

  • Разделение ответственности: Логика движения (SystemMovement) ничего не знает о спрайтах, текстурах или узлах. Вы можете запустить эту логику на сервере без графического интерфейса.
  • Гибкость: Хотите, чтобы враг перестал двигаться? Просто удалите у него ComponentVelocity. Не нужно писать if is_frozen: в коде движения. Система просто проигнорирует сущность, так как она перестанет попадать в выборку query.
  • Сериализация: Чтобы сохранить игру, вам нужно просто сохранить содержимое ECSRegistry (список ID и их компонентов) в JSON. Вам не нужно обходить дерево сцены и думать, какие свойства узлов важны, а какие нет.
  • Заключение

    Мы заменили жесткую иерархию узлов на гибкую базу данных компонентов. Наш мир теперь состоит из чистого состояния (Data) и правил его изменения (Systems).

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

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

    3. Игровая логика и симуляция: написание собственных контроллеров, правил и систем ограничений

    Игровая логика и симуляция: написание собственных контроллеров, правил и систем ограничений

    Мы продолжаем наш курс по созданию архитектуры «движок внутри движка» на Godot. В прошлых статьях мы отделили время симуляции от времени рендеринга (Game Loop) и заменили иерархию узлов на плоскую структуру данных (ECS). У нас есть сердце, которое бьется, и тело, состоящее из данных. Но у этого тела пока нет мозга.

    Сегодня мы займемся игровой логикой. Мы не будем использовать стандартные CharacterBody2D или Area2D для расчета физики и правил, так как они привязывают нас к API Godot. Вместо этого мы напишем свои контроллеры, машину состояний и систему коллизий. Это позволит нашей игре быть полностью детерминированной и независимой от капризов движка.

    1. Абстракция ввода: Контроллер как данные

    В стандартном подходе Godot мы пишем Input.is_action_pressed("jump") прямо внутри кода движения персонажа. Это плохая практика для масштабируемой архитектуры по двум причинам:

  • Зависимость: Логика движения знает о клавиатуре/геймпаде.
  • Тестируемость: Вы не можете заставить персонажа прыгнуть в юнит-тесте без эмуляции нажатия клавиш.
  • AI: Искусственному интеллекту придется эмулировать ввод, чтобы управлять тем же персонажем.
  • Решение: InputFrame

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

    !Диаграмма показывает, как разные источники команд преобразуются в единую структуру данных перед попаданием в симуляцию.

    В GDScript это может выглядеть как простой класс или словарь:

    Теперь наша система движения (SystemMovement) не будет опрашивать Input. Она будет читать компонент ввода.

    Компонент ввода

    Добавим компонент к сущности игрока:

    Теперь мы можем написать простую систему-мост, которая работает в _process (или в начале нашего кастомного цикла) и заполняет этот компонент:

    Почему это круто? Если вы захотите сделать бота, вы просто напишете SystemAI, которая будет заполнять ComponentInput своими значениями. Система движения даже не узнает, кто управляет персонажем — человек или скрипт.

    2. Машина состояний (FSM) в ECS

    Персонаж не может прыгать, если он уже в воздухе (обычно). Он не может бежать, если он мертв. Чтобы управлять этими правилами, нам нужен Конечный Автомат (Finite State Machine).

    В ООП мы бы делали это через паттерн State. В ECS состояние — это просто enum или тег в компоненте.

    Система правил переходов

    Логика переходов выносится в отдельную систему SystemPlayerState. Она читает ComponentInput, ComponentVelocity и ComponentState, чтобы решить, что делать.

    Пример логики:

  • Если State == IDLE и Input.move_axis.x != 0 переход в RUN.
  • Если State == RUN и Input.move_axis.x == 0 переход в IDLE.
  • Если Input.jump_pressed и мы на земле переход в JUMP.
  • Это позволяет четко разделить намерение (Input) и фактическое состояние (State).

    3. Физика и ограничения: Свои правила

    Самая сложная часть отказа от CharacterBody2D — это потеря встроенной физики (move_and_slide). Но для многих жанров (платформеры, стратегии, JRPG) физика Godot избыточна или недетерминирована. Напишем свою простую систему физики.

    Интеграция скорости

    Базовая формула движения, которую мы применяем каждый тик:

    Где: * — новая позиция объекта. * — текущая позиция. * — вектор скорости. * — фиксированный шаг времени (наш TIME_STEP).

    Также нам нужна гравитация:

    Где: * — новая вертикальная скорость. * — текущая вертикальная скорость. * — ускорение свободного падения (константа, например, 980 пикселей/сек²).

    Система ограничений (Constraints)

    Ограничения — это правила, которые говорят: «Ты не можешь пройти сюда». Самое простое ограничение — пол и стены.

    Для реализации коллизий без физического движка мы будем использовать AABB (Axis-Aligned Bounding Box) — прямоугольники, выровненные по осям.

    Создадим ComponentAABB:

    Проверка пересечений

    Два прямоугольника и пересекаются тогда и только тогда, когда выполняются все 4 условия:

    Где: * — левая координата прямоугольника A. * — правая координата прямоугольника B. * — логическое И.

    !Иллюстрация принципа AABB коллизии: если хотя бы по одной оси есть разрыв, пересечения нет.

    Разрешение коллизий (Collision Resolution)

    Когда SystemPhysics обнаруживает пересечение игрока со стеной, она должна «вытолкнуть» его обратно. Это и есть Constraint.

    Алгоритм:

  • Двигаем объект по оси X.
  • Проверяем коллизии. Если есть — возвращаем объект назад по X до границы препятствия и обнуляем .
  • Двигаем объект по оси Y.
  • Проверяем коллизии. Если есть — возвращаем объект назад по Y и обнуляем (если это пол, то мы «приземлились»).
  • Этот подход называется «движение по осям» и используется в классических играх (Mario, Celeste) для максимальной точности управления.

    4. Игровая логика как Система

    Теперь, когда у нас есть физика и ввод, добавим правило игры: Сбор монет.

    В Godot мы бы использовали сигнал area_entered. В ECS мы пишем SystemPickup.

    Обратите внимание: логика полностью отделена от представления. Монета исчезнет из данных. Система рендеринга (GameRunner из прошлых статей) увидит, что сущности больше нет, и удалит соответствующий Sprite2D.

    Преимущества подхода «Свои правила»

  • Прозрачность: Вы точно знаете, почему персонаж прошел сквозь стену — ошибка в вашей формуле AABB, а не баг в движке Godot Physics.
  • Переносимость: Весь код выше — это чистая математика и логика. Его можно скопировать на C# сервер, и он будет работать идентично.
  • Снэппинг (Snapping): В платформерах часто нужны нефизические правила (например, койот-тайм или прилипание к платформам). Реализовать их, борясь с RigidBody, сложно. В своей системе вы просто меняете координаты pos.y вручную.
  • Заключение

    Мы создали «мозг» нашей игры. Теперь наш микродвижок умеет: * Принимать абстрактный ввод (от игрока или AI). * Управлять состояниями через данные. * Симулировать физику и разрешать коллизии с помощью математики. * Обрабатывать игровые правила (подбор предметов) через системы.

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

    4. Слой представления: кастомный рендер-стиль, управление камерой и интерполяция кадров

    Слой представления: кастомный рендер-стиль, управление камерой и интерполяция кадров

    Мы подошли к экватору нашего курса «Архитектура игр на Godot». В предыдущих частях мы создали невидимый, но мощный фундамент: собственный игровой цикл (Game Loop), независимый от частоты кадров, и систему сущностей и компонентов (ECS), хранящую состояние мира в чистых данных.

    Сейчас наш проект похож на «призрак»: в памяти компьютера происходят битвы, перемещения и физические взаимодействия, но на экране — пустота или дергающиеся спрайты. Настало время создать Слой Представления (View Layer).

    В этой статье мы научимся:

  • Визуализировать данные ECS, не смешивая их с логикой.
  • Реализовать интерполяцию для достижения идеальной плавности движения.
  • Создать умную камеру, которая не зависит от иерархии сцены.
  • Проблема рассинхронизации времени

    В первой статье мы разделили время на два потока:

  • Время симуляции (Physics Time): Фиксированный шаг (например, 60 раз в секунду). Здесь живет логика.
  • Время рендеринга (Render Time): Переменный шаг (зависит от монитора и мощности ПК, например, 144 Гц или 30 Гц).
  • Если мы просто будем брать текущую позицию из ECS и передавать её спрайту в методе _process, мы столкнемся с эффектом Jitter (дрожание). Это происходит потому, что кадр рендеринга может наступить между двумя тиками симуляции.

    !Схема рассинхронизации тиков симуляции и кадров рендеринга, требующая вычисления промежуточного состояния.

    Чтобы движение было плавным («масляным»), мы должны показывать не текущее состояние симуляции, а промежуточное между прошлым и текущим.

    Математика интерполяции

    Для реализации этого нам нужно хранить два состояния в компоненте позиции: previous (предыдущее) и current (текущее).

    Обновим наш компонент:

    В конце каждого тика симуляции (перед обновлением физики) мы должны запоминать текущее состояние как предыдущее:

    Теперь, в момент отрисовки (_process), мы вычисляем позицию спрайта, используя линейную интерполяцию (Lerp). Формула выглядит так:

    Где: * — итоговая позиция объекта на экране в текущем кадре. * — позиция объекта в предыдущем тике симуляции. * — позиция объекта в текущем тике симуляции. * — коэффициент интерполяции (от 0.0 до 1.0), показывающий, как далеко по времени мы ушли от к .

    Расчет Альфы (Alpha)

    Значение мы получаем из нашего аккумулятора времени (который мы создали в первой статье):

    Где: * — нормализованное значение прогресса между кадрами. * — время, накопленное в аккумуляторе с момента последнего тика симуляции. * — фиксированный шаг времени симуляции (например, 0.016 сек).

    Архитектура View System

    Теперь, когда у нас есть математика, давайте построим систему, которая управляет узлами Godot. Мы назовем её GameView. Это обычный узел Godot (Node2D), который живет в сцене.

    Его задачи:

  • Следить за появлением новых сущностей в ECS.
  • Создавать для них визуальные объекты (спрайты, меши).
  • Синхронизировать их позицию каждый кадр с учетом интерполяции.
  • Удалять визуальные объекты, если сущность уничтожена.
  • Реестр представлений

    Нам нужен словарь, связывающий ID сущности с узлом сцены.

    Этот подход полностью отделяет логику от графики. Если сервер запустит этот код, он просто не создаст GameView, и игра будет работать в «слепом» режиме, потребляя минимум ресурсов.

    Кастомный рендер-стиль: Компонент View

    Как GameView узнает, какой именно спрайт создать для сущности? Для этого нам нужен компонент данных.

    Теперь, когда мы создаем орка, мы добавляем ему ComponentView("res://orc.png"). Система GameView увидит этот компонент, загрузит текстуру и создаст Sprite2D.

    Это позволяет менять внешний вид на лету. Например, эффект заморозки — это просто изменение поля color в компоненте ComponentView. Система рендеринга сама подхватит изменение и покрасит спрайт в синий цвет.

    Управление камерой: Отвязываемся от игрока

    В Godot новички часто делают камеру дочерним узлом игрока (Player -> Camera2D). Это работает, но имеет недостатки: * Камера жестко привязана к позиции игрока (трясется вместе с ним). * Сложно сделать кат-сцены или переключить фокус на другое событие. * Сложно реализовать «упреждение» камеры (когда камера смотрит чуть дальше в сторону движения).

    В нашей архитектуре Камера — это отдельная сущность или отдельная система в слое представления.

    Реализация Camera Controller

    Мы создадим скрипт CameraController, который управляет настоящей Camera2D в сцене Godot, но получает данные из симуляции.

    Формула сглаживания (Damping):

    Где: * — новая позиция камеры. * — текущая позиция камеры. * — целевая позиция (например, интерполированная позиция игрока). * — математическая константа (число Эйлера). * — коэффициент затухания (damping factor), определяющий скорость сближения. * — время прошедшее с прошлого кадра.

    Эта формула гарантирует, что камера будет вести себя одинаково плавно при 30 FPS и при 144 FPS, в отличие от простой линейной интерполяции lerp(a, b, 0.1), которая зависит от частоты вызовов.

    Стратегии камеры

    Поскольку камера теперь управляется кодом, мы можем легко менять стратегии:

  • Lock-on: Жесткая привязка к координатам.
  • Look-ahead: Сдвиг камеры в сторону вектора скорости игрока.
  • Anchor: Камера, которая держит в кадре и игрока, и босса (средняя точка).
  • Для реализации Look-ahead мы просто берем скорость из ComponentVelocity:

    Разделение эффектов и логики

    Важный принцип архитектуры «движок в движке»: Симуляция не знает о частицах и звуках.

    Когда персонаж прыгает, симуляция просто меняет состояние ComponentState на JUMP и задает вертикальную скорость. Она не проигрывает звук и не создает облачко пыли.

    Слой представления (GameView) должен следить за изменениями данных:

  • Храним previous_state и current_state в компоненте состояния.
  • В _process проверяем: if prev == GROUNDED and curr == JUMP.
  • Если условие истинно создаем GPUParticles2D в ногах персонажа и проигрываем AudioStreamPlayer.
  • Это называется Реактивное Представление. Визуал реагирует на изменение данных, а не инициируется логикой.

    !Схема реактивного представления: логика меняет данные, а системы представления реагируют на эти изменения, создавая эффекты.

    Заключение

    Мы успешно создали «тело» для нашего микродвижка. Теперь: * Движение объектов идеально плавное благодаря интерполяции. * Камера имеет свою физику и не зависит от иерархии сцены. * Визуальная часть полностью отделена от логической.

    Вы можете заменить 2D спрайты на 3D меши (MeshInstance3D), и вам не придется менять ни строчки кода в логике движения или физики. Это и есть мощь архитектуры, которую мы строим.

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

    5. Искусственный интеллект и абстракция: создание модульного AI и подготовка кода к миграции

    Искусственный интеллект и абстракция: создание модульного AI и подготовка кода к миграции

    Мы подошли к финалу нашего курса «Архитектура игр на Godot: Создание движка внутри движка». Мы прошли долгий путь: отделили игровой цикл, построили ECS, написали свою физику и создали слой представления с интерполяцией. У нас есть работающий мир, но он населен марионетками, которые ждут команд.

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

    А в конце мы ответим на главный вопрос курса: «Как перенести эту игру на другой движок, если Godot перестанет нас устраивать?».

    AI как источник ввода

    Вспомните нашу статью про «Игровую логику». Мы создали структуру InputFrame и компонент ComponentInput. Наша система движения (SystemMovement) не знает, кто нажал кнопку «вправо» — человек, кот, прошедший по клавиатуре, или скрипт.

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

    !Иллюстрация того, что для симуляции нет разницы между вводом человека и искусственного интеллекта.

    Реализация SystemAI

    Вместо того чтобы писать логику в _process врага, мы создадим систему SystemAI. Она будет работать в нашем фиксированном цикле обновления.

    «Зрение» без физического движка

    Обычно в Godot для зрения используют RayCast2D или Area2D. Но это привязывает логику AI к физическому движку Godot и узлам сцены. В нашей архитектуре AI должен «смотреть» в данные ECS.

    Простейшее зрение — это проверка дистанции. Нам понадобится формула Евклидова расстояния:

    Где: * — расстояние между объектами. * — координаты наблюдателя (бота). * — координаты цели (игрока).

    В коде это выглядит так:

    Преимущество: Этот код работает в 100 раз быстрее, чем Area2D, так как это чистая математика без накладных расходов движка на обновление трансформаций узлов.

    Utility AI: Принятие решений на основе данных

    Конечные автоматы (FSM), которые мы использовали для анимаций, хороши, но для сложного AI они быстро становятся запутанными. В модульной архитектуре отлично работает подход Utility AI (Полезность).

    Идея: у бота есть набор возможных действий (Атака, Бегство, Патруль). Каждое действие имеет формулу полезности. Бот выбирает действие с наивысшим баллом.

    Формула расчета полезности действия:

    Где: * — итоговая полезность (Utility) конкретного действия. * — количество факторов, влияющих на решение. * — вес -го фактора (важность). * — нормализованная функция оценки фактора (от 0 до 1).

    Пример для действия «Лечиться»:

  • Фактор «Здоровье»: Чем меньше HP, тем выше оценка.
  • Фактор «Наличие зелья»: Если зелья нет, оценка 0.
  • Мы можем создать ComponentUtility, где хранятся кривые (Curves) для оценки факторов. Это позволяет геймдизайнерам настраивать «характер» бота, просто меняя цифры в ресурсе, не трогая код.

    Подготовка к миграции: Архитектура «Луковица»

    Теперь, когда наша игра готова, давайте посмотрим на неё с высоты птичьего полета. Мы построили архитектуру, напоминающую Clean Architecture или «Луковицу».

    !Визуализация архитектуры, показывающая, что ядро игры не зависит от внешних инструментов.

    Что у нас есть:

  • Ядро (Core): SimulationWorld, ECSRegistry, Systems, Components. Этот код написан на GDScript, но он не наследуется от классов Godot (кроме RefCounted, который легко заменить на обычный класс в C# или C++).
  • Адаптеры (Adapters): GameRunner, GameView, InputBridge. Это «клей», который соединяет Godot с нашим ядром.
  • Сценарий: Переезд на Unity или C++

    Представьте, что вам нужно портировать эту игру на C++ (например, для кастомного движка).

    Что придется переписать (Внешний слой): * GameView: Вместо Sprite2D вы будете использовать спрайты вашего нового движка. * InputBridge: Вместо Input.is_action_pressed вы будете использовать SDL_PollEvent или Input.GetKey. * GameRunner: Цикл while с аккумулятором останется тем же, но вызов будет идти не из _process.

    Что останется неизменным (Ядро): * Вся логика движения (SystemMovement). * Вся логика AI (SystemAI). * Все правила игры (SystemPickup, SystemCombat). * Все данные (Components).

    Вам нужно будет лишь перевести синтаксис с GDScript на C++. Поскольку мы использовали простые типы данных и математику, этот перевод может быть выполнен даже автоматически или с помощью LLM за считанные минуты.

    Чек-лист «Чистоты» архитектуры

    Чтобы убедиться, что ваш код готов к масштабированию и миграции, проверьте его по этому списку:

  • Нет Node в логике: В папке systems/ и components/ нет ни одного extends Node или extends Node2D.
  • Нет глобалов движка: Системы не вызывают Input.get..., AudioServer... или GetTree() напрямую. Они работают только с данными в ECSRegistry.
  • Детерминизм: Если записать ввод в файл и проиграть его снова, состояние мира в конце будет идентичным бит-в-бит (благодаря фиксированному шагу времени).
  • Заключение курса

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

    Создав «движок внутри движка», вы получили: * Полный контроль: Вы знаете, как работает каждый байт вашей симуляции. * Производительность: ECS позволяет обрабатывать тысячи объектов там, где ООП захлебнется. * Свободу: Вы больше не боитесь обновлений движка, багов физики или смены технологий. Ваша игра — это ваши правила и ваши данные.

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