Как стать автором
Обновить

Game++. Patching patterns

Уровень сложностиПростой
Время на прочтение46 мин
Количество просмотров1.4K

Книга Design Patterns: Elements of Reusable Object-Oriented Software («Приёмы объектно-ориентированного проектирования. Паттерны проектирования»), также известная под названием "синей книги", по цвету обложки первого издания, или книги "банды четырех/GoF" издана почти тридцать лет назад.

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

Я всё еще считаю, что книга актуальна - как базовые знания вроде математики, алгоритмов и примитивов синхронизации, но за прошедшие годы люди создали и обнаружили множество новых, хоть и не таких известных. А некоторые паттерны, настолько стали, затасканными что ли, что превратились скорее в антипаттерны, как например, Singleton и совсем потерялся смысл его использования. И там где разумное применение не приносит больше вреда, но позволяет развязать зависимости, создание архитектуры на таких принципах - ведет только к разбуханию кода, и коду ради кода.

Другие шаблоны, например Command/Flyweight были забыты и мало применяются в общем софтостроении, но прочно обосновались в разработке игр и интерактивных системах. Собственно о таких вещах и хотел рассказать в этой статье, и показать несколько специфичных шаблонов, применяемых в игрострое, о которых вы вряд ли услышите за его пределами, или будете порицаемы за их использование.

Заходите, великов и граблей хватит на всех.


P̷͍͇͖͎̖̔̋̋̑̋̄Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠R̶͇͚͇̠̒̄N̸̩͈̖͎̺̰̥̞͇͗̿̒̌͂̓̕ͅS̶͔̬̬̱͙̃̈́̐̀͒̄͝͝͝

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

Не обязательно разбираться во всей игре от и до, но придётся «загрузить» в свой мозг все ключевые куски логики: какие объекты задействованы, как устроен игровой цикл, где обрабатываются события, и как данные перемещаются между системами. Если это код поведения NPC, значит, нужно понять, как устроен AI: BT, FSM или кастомная логика. Как работает навигация, где переключение состояний и т.д.

Если вы чините баг в боевой системе — как она связана с анимацией, физикой, звуком? Как передаются события попадания? Всё это нужно загрузить в оперативную память… только эта память у нас биологическая, и шина узкая — пара оптических нервов и куча промежуточных больших и малых кешей.

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

А ведь на этом этапе ещё ничего не написано. Вы просто собираете контекст проблемы. Загружаете в «мозговой кеш» информацию о том, какие компоненты и системы участвуют, где границы ответственности, какие зависимости критичны, и, главное, где безопасно код !не трогать!.

Когда этот контекст загружен, дальше всё идёт проще. Решение может требовать размышлений и перебора вариантов, тестирования или отладки, но само изменение часто оказывается простым. Добавить пару строк, перекинуть вызов, изменить условие. Иногда само «программирование» — это последняя и наименее затратная часть работы, венчающая два дня "визуального дебага". Всё остальное время вы тратили не на написание, а на понимание.

И вот здесь хорошая архитектура начинает работать. Хорошо организованный код читается быстрее, требует меньше когнитивной нагрузки. Система, где компоненты разделены, а связи между ними явные — делает своё дело, как в комнате, когда выключатель света находится на уровне поднятой руки слева от входа. А если он спрятан за шкафом, даже простой баг превращается в детектив.

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

Blue book

В пропитанном запахом пиксельной кровищи, после релиза первого DOOM (1993), индустрия в 1994 году вовсю гонялась за тем, чтобы повторить эффект "3D с бешеной скоростью". Все хотели сделать свой "клон DOOM’а", и это породило огромный всплеск творчества и экспериментов с BSP, освещением и триггерами в уровнях.

Разработка игр только-только погружалась в эпоху объектно-ориентированного программирования, а четыре "матерых деда" компьютер сайнс (Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес) создали монументальный труд под названием «Приемы объектно-ориентированного проектирования. Паттерны проектирования». Хотя нет, это щас они матерые деды, а тогда им было от 30 до 38, так что «Банда четырёх» написала Design Patterns в разном возрасте, но все уже были активными исследователями и инженерами.

Название книги, величественное и неподъемное, быстро сократили до более меткого и запоминающегося — «book by the gang of four». А время, безжалостный редактор всего вокруг и того больше — «GoF book» или «синей книги». Не сократился лишь основной вопрос книги - "Как сделать сложное простым?"

Как сделать сложное простым?

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

Показательным примером служит паттерн Strategy, который в большинстве современных языков программирования может быть реализован через лямбды, функторы или делегаты. Например, в С++11 стратегию можно передать просто как функцию, а раньше приходилось городить иерархию классов. Что-то вроде этого, пример из головы:

void executeStrategy(const std::function<void()>& strategy) {
    strategy();
}

void Unit::update() {
    ...
    auto aggressive = [&]() { std::cout << "Attack!\n"; };
    auto defensive  = [&]() { std::cout << "Defend!\n"; };

    executeStrategy(aggressive);
    executeStrategy(defensive);
}

А это уже код из реальной игры (легаси), с раздачей стратегий юнитам, какой он был до изменений:

void ExecuteAgressive(
    uint32_t nUnit,
    uint32_t nEvictables,
    std::vector<EnemyEntry>* entries,
    AI_LAYER layer,
    std::vector<FriendEntry>* pages,
    std::vector<uint16_t>* results)
{
    PROFILE_ONLY(ZoneScoped);
    ....
}


int Unit::update() {
    ...
     _strategyMgr.execute<void(
            uint32_t,
            uint32_t,
            std::vector<EnemyEntry>*,
            AI_LAYER,
            std::vector<FriendEntry>*,
            std::vector<uint16_t>*)> // Should look at cleaner ways to deduce these
            (
             STRATEGY_TYPES::STRATEGY_AGRESSIVE,
             mThreadIDs[i],
             {mClearStates},
             ExecuteAgressive,
             i,
             mMinNumEvictables,
             &mEnemiesToAttack,
             mAiLayer,
             &mResult
            );
    ...
  }

Синдром фломастера

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

Желание сделать правильно, в итоге приводит к усложнению архитектуры, увелечению числа слоев и абстракций. А любая дополнительная нагрузка ведет к снижению производительности, и как правило к поддерживаемости кода. Zero cost правило перестает работать, потому что мы сами втащили в проект ненужный функционал, пусть даже и правильный.

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

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

Уровни паттернов

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

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

Идиомы — это самые простые и низкоуровневые паттерны. Они представляют собой типовые приёмы, которые применимы в рамках одного конкретного проекта, фреймворка или языкам программирования. Например, "двойная проверка на инициализацию" в C++ или использование RAII. Идиомы зависят от синтаксиса и особенностей платформы, поэтому не являются универсальными. Ниже код идиом двойной проверки инициализации синглтона.

class Singleton {
public:
    static Singleton* getInstance() {
        if (_instance == nullptr) {                // Первая проверка (без блокировки)
            lock_guard<mutex> lock(_mutex);
            if (_instance == nullptr) {            // Вторая проверка (после блокировки)
                _instance = new Singleton();
            }
        }
        return _instance;
    }

    void sayHello() const {
        cout << "Hello from Singleton\n";
    }

private:
    Singleton() {}
    static Singleton* _instance;
    static mutex _mutex;
};

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

И вот между этими двумя крайностями находятся классические паттерны проектирования, описанные в книге «банды четырёх». Но статья, не об этом, если вам захочется больше почитать по этой теме, могу посоветовать этот замечательный сайт (refactoring.guru) ну и конечно книги.

  • "Head First Design Patterns" (Eric Freeman, Elisabeth Robson)

  • "Patterns of Enterprise Application Architecture" (Martin Fowler)

  • "Domain-Driven Design" (Eric Evans)

  • "Implementation Patterns" (Kent Beck)

  • "Refactoring to Patterns" (Joshua Kerievsky)

  • "Pattern-Oriented Software Architecture" (серия книг Buschmann, Meunier)

  • "Enterprise Integration Patterns" (Gregor Hohpe, Bobby Woolf)

  • "Clean Architecture" (Robert C. Martin)

  • "Implementing Domain-Driven Design" (Vaughn Vernon)

Далее я опишу самые часто используемые паттерны в играх.

C̵̨̧̜͎̘̙͕̝͙̏̈́̇͛̒̋̀̚O̴͕̟̼͖̜͆̽̓͆̋͌́̒̏M̴̧̤͋̚M̴̧̤͋̚Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠N̸̩͈̖͎̺̰̥̞͇͗̿̒̌͂̓̕ͅD̵͚̩͍̖͈̀̈́̀͐̑͐̇͆̅ͅ

Превращает код в Lego — главное не наступить на него босиком

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

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

Главная идея Command — превратить действие в объект. Представьте кнопку в интерфейсе игры - без этого паттерна кнопка будет вызывать код напрямую, например, player.jump(). И в начале проекта оно, обычно, так и бывает.

Но если использовать Command, кнопке передается объект вроде  button.onclick(CommandJump). Сама кнопка не знает, что именно делает команда — она лишь запускает ее метод button.execute(CommandJump). Так мы развязали три вещи, источник сигнала (теперь не надо знать, что игрок умеет прыгать), потребителя сигнала (возможно мы хотим сделать тесты и уже не нужен контроллер для этого) и логику сигнала (теперь можно сделать несколько видов прыжка).

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

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

Это способ абстрагировать действие, превратив его в независимый набор логики. Такие наборы можно комбинировать, откатывать, передавать между компонентами системы. Это не только упрощает код, но и дает свободу для реализации сложных функций — от простой отмены до синхронизации в распределенных системах.

Еще примеры здесь

struct Command {
    virtual ~Command() = default;
    virtual void execute() = 0;
    virtual void undo() = 0;
};

class PlayerJump : public Command {
public:
    explicit PlayerJump(Player& player) : player_(player) {}

    void execute() override {
        player_.jump();
    }

    void undo() override {
        std::cout << "Отмена прыжка невозможна\n";
    }

private:
    Player& player_;
};

class CommandManager {
public:
    void executeCommand(Command command) {
        command.execute();
        history_.push(std::move(command));
    }

    void undoLastCommand() {
        if(history_.empty()) return;
        
        history_.top()->undo();
        history_.pop();
    }

private:
    std::stack<Command> history_;
};

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

Здесь команды используются в качестве интерфейса между системой AI и юнитами, так можно отделить AI от прямого контроля объектов. AI будет генерировать команды на основе того, что делает юнит и где находится игрок. Например, логика игры может создать команду AttackCommand или RetreatCommand, а юнит получит их в свою очередь команд и обработает.

Разделение между AI (выбор команд) и NPC (их выполнение) открывает широкие возможности для разработки игры. Разные типы NPC могут использовать различные модули AI — одни для боевых действий, другие для диалогов или исследований. Это в конечном итоге приводит нас к Behavior Tree реализации (https://github.com/BehaviorTree/BehaviorTree.CPP) как крайнему случаю команд и позволяет легко делать поведенческие паттерны на основе простых блоков: создать противника, который сочетает агрессивную атаку с тактическим отступлением при низком здоровье.

Чтобы усилить сложность врага, достаточно заменить его AI-модуль на более продвинутый, не меняя код самого персонажа. Интересный кейс — временное подключение AI к персонажу игрока, что полезно для демонстрационных режимов, ботов или сценариев, где игрок теряет управление.

Схема работы
+---------------------+
|     Input System    |   <-- Игрок нажал клавишу, тапнул или UI команда
+---------------------+
           |
           v
+---------------------+        // Формирование команды
|  Input Handler      |        // Преобразует ввод в объект команды
+---------------------+         
           |
           v
+----------------------+
|  Command Dispatcher  |       // Рассылает команды нужным исполнителям
+----------------------+
           |
           v
+----------------------+     +----------------------+     +----------------------+
|     Player Entity    |<----|     Unit Entity      |<----|     UI Handler       |
| (executes commands)  |     |  (executes commands) |     | (executes commands)  |
+----------------------+     +----------------------+     +----------------------+
Влияние на архитектуру

Превращение команд в самостоятельные объекты заменяет прямые вызовы методов на обмен сообщениями. Вместо жесткой связи между AI и персонажем возникает асинхронный поток команд, которые можно накапливать, обрабатывать и модифицировать. AI выступает в роли «режиссера» (Manager), отправляющего указания, а персонажи — «акторы»(Actor), выполняющие их. Это позволяет внедрять промежуточные слои: например, систему приоритетов команд, фильтрацию или логирование.

Команды незаменимы если надо организовать взаимодействия между разнородными системами. Они не только уменьшают связность кода, но и предоставляет инструменты для сложных сценариев, профилирования и отладки разнесенной логики. Изменения в одной части системы (например, в логике AI) минимально затрагивают другие компоненты. Это особенно важно в проектах, где гибкость и возможность итеративной доработки нужны для разделения работы над частями уровня.

Недостатки

Команды похожи на функции первого класса или замыкания. Это может стать причиной подмены реализации. Т.е. команды, вместо своего прямого назначения - развязывания функционально разных частей движка - начинают использовать как свободные функции или замыкания в языках, которые не дают такой возможности.

В языках с полноценными замыканиями (например, Python, JavaScript или C#) паттерн команды часто реализуется именно с их помощью. Но в С++ Command имитирует замыкание, как объект-контекст-логику и представляет самостоятельную сущность, которую можно передавать и управлять ею. Из-за простоты реализации паттерн команд начинает разрастаться в лапше-команды, децентралуя вообще всё. Отлаживать такое становится почти невозможно.

Возможности для расширения

Добавление методов Undo() и Redo() превращает команды в основу системы отката, которую можно использовать для редактора уровней, хранения логики в системах реплея, хранения пользовательских действий.

Обёртывание команд в очередь с таймингом позволяет буферизовать команды для сетевой синхронизации, реализовать последовательные анимации, "ставить в очередь" действия юнитов в RTS.

Позволяет объединить несколько команд в одну для создания сложных действий, состоящих из нескольких шагов, и использовать для "катсцен", квестов, комбо-атак.

Сделав команды сериализуемыми, их можно сохранять в файл пересылать по сети, проигрывать для повторов (replay system).

Перед выполнением команда может проверять допустимость (CanExecute), переставлять себя в очереди, если не может быть выполнена сразу, порождать новые команды.

Заметки на полях

И Сommand и Strategy превращают поведение в объект, но с разными акцентами - Command: описывает действие, которое нужно выполнить (и, возможно, откатить), Strategy: описывает алгоритм, который нужно применить в контексте. Command часто используется внешне (игрок вызывает), а Strategy — внутри (AI выбирает сам).

Command: определяет, что делать, когда наступит событие,Observer: реагирует на изменения. По сути, Command — это реакция, инкапсулированная как объект, и может передаваться, сохраняться, отменяться.

Классические игровые связки.

  • Event "игрок нажал кнопку" → Observer ловит → Command выполняется

  • UI и кнопки → Command

  • AI и юниты → CommandQueue

  • undo/redo в редакторе → Command + Memento

  • сетевой реплей → сериализуем Command

  • скриптовые языки → сохраняемCommandв виде текста.

Прекрасно совмещается с другими паттернами Strategy, Observer, Memento, Chain of Responsibility, где Command становится ядром архитектуры реакций и действий, которое не просто обрабатывает команды — а формирует поведение систем в терминах действий.

S̶͔̬̬̱͙̃̈́̐̀͒̄͝͝͝Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠

Один слой состояний — fsm, два - система, три - гиперкуб.

Еще один фундаментальный игровой паттерн, без которого невозможно построение конечных автоматов (или FSM — finite state machines). Паттерн логически порождается командой, после выполнения которой, юнит может находиться в некотором состоянии.

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

void Unit::handleCommand(Command command)
{
  command.execute({
    :// JumpCommand
    _velocity += up;
    setGraphics(animation.up);
  })
}

Команда отрабатывает - добавляет импульс прыжка. Но здесь уже видно баг - может прийти несколько команды прыжка во время прыжка, во время прыжка и т.д. и юнит будет "прыгать" бесконечно и летать. Можно добавить булевый флаг _inAir, который отслеживает, находится ли юнит в прыжке, и отсеивать команды этого типа.

void Unit::handleCommand(Command command)
{
  command.execute({
    :// JumpCommand
    if (_inAir) return;

    _inAir = true;      
    _velocity += up;
    setGraphics(animation.jump);
  })
}

Cразу намечаются проблемы, надо как-то сбрасывать _inAir , сама команда этого сделать уже не может, значит надо тащить логику в базовый класс, плодя зависимости. Об этом мы решили подумать позже, когда соберем всю логику, а пока надо сделать приседание, если приходит команда присесть, юнит меняет анимацию. А при другой команде встает и снова меняет анимацию. И надо помнить, что юнит может быть в воздухе, появляется новый флаг в юните _isCrouching

void Unit::handleCommand(Command command)
{
  command.execute({
    :// CroucCommand
    if (_inAir) return;
    
    _isCrouching = true;
    setGraphics(animation.crouch);
  })
    
  command.execute({
    :// StandCommand
    if (_inAir) return;
    if (!_isCrouching) return;
    
    _isCrouching = false;
    setGraphics(animation.stand);
  })
}

Команды вроде небольшие, но уже зависят от состояния юнита, в воздухе, присел, стоит. Если добавим еще возможность стрелять, то станет только хуже, например в положении сидя, стрелять нельзя - прицела нет или за укрытием. Возникают вопросы, а если стрелять и сразу получить команду присесть? Или прыгнуть, присесть в воздухе и выстрелить? Или присесть и потом прыжок? Получается, что нам вручную придется перебрать все возможные стейты, да еще не забыть про обновление анимаций. Где-нибудь точно ошибка будет.

Если в юните появились подозрительные переменные вроде стоять, сидеть, лежать и т.д. — это признаки того, что пора заменить кучу флагов на стейты и вводить конечный автомат, который реализует механизм переключения состояний, где каждый тип поведения юнита — отдельное состояние: Standing, Jumping, Crouching, JumpShooting, и т.д., и в каждом состоянии прописаны допустимые переходы и реакции на ввод.

(640кб) FSM хватит всем

Finite State Machine - 640кб, которых пока что хватает для старта всем. FSM — концепция, которая позволяет заменить хаос однотипных команд на ясную, управляемую структуру. Применяется для управления поведением игровых объектов. Особенно хорошо он подходит для таких вещей, как анимации, реакции на ввод игрока, AI-состояния и вообще любого поведения, которое можно описать в чётких рамках с переходами. А в играх почти всё поведение детерминировано и может быть разбито на состояния.

Cтейт-машина значительно упрощает мышление: вместо того чтобы разбираться в каше из условий вроде «если персонаж присел, но не стреляет, и при этом в прыжке, и нажата какая-то кнопка...», надо описывать поведение для каждого конкретного состояния.

Это даёт гарантию корректности стейта — заранее определенные допустимые переходы исключают невозможные комбинации, например, стрельбу из положения сидя. Нельзя случайно оказаться в «невалидном» состоянии, если такой переход вообще не предусмотрен. Расширять такую систему легко — чтобы добавить новое поведение, надо описать новое состояние и переходы к нему, в идеально случае два, вход из одного стейта, и выход в другой. И это то, о чем говорится в "синей книге" про "Состояние" (State pattern). Т.е. не сам стейт, является паттерном, а связка стейт + FSM.

«Позвольте объекту изменять своё поведение при изменении его внутреннего состояния. Объект будет выглядеть так, как будто он изменил свой класс».

Если взять пример из прошлой статьи про реализацию стейт машины про юнита, и немного его переписать:

eat transition(init b);
sleep transition(eat b);
chase_player transition(sleep b);
runaway transition(chase_player b);
eat transition(runaway b);

struct statemachine
{
    std::variant<init, eat, sleep, chase_player, runaway> state;
    
    void next() 
    {
    	std::visit(
             //execute the edge
        	 [this](auto &current_state)
             {
                //assign the result as the next state
                state = transition(current_state);
             }, state);
    }
};

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

OnAir transition(Jump b);
Stand transition(OnAir b);
Crouch transition(Stand b);
Stand transition(Crouch b);
Shooting transition(Stand b);
Stand transition(Shooting b);

struct statemachine
{
    std::variant<Jump, OnAir, Stand, Crouch, Shooting> state;
    
    void next() 
    {
    	std::visit(
             //execute the edge
        	 [this](auto &current_state)
             {
                //assign the result as the next state
                current_state->handleInput(command);
                state = transition(current_state);
                
             }, state);
    }
};

Конечно, реальные игры будут сложнее, и юнит может быть одновременно в состоянии Jumping и Shooting или Walking и Сrouching? Тогда на помощь и приходят иерархические FSM (HFSM), где состояния могут быть вложенными или параллельными, но это повод для отдельной статьи.

FSM + Pushdown states

Без стека будешь патрулировать до скончания времён

Существует ещё одно важное расширение конечных автоматов — использование стека состояний. Это решение рождается из фундаментального ограничения классического конечного автомата: он не хранит историю.

Мы всегда знаем, в каком состоянии объект находится сейчас, но полностью теряем информацию о том, откуда он пришёл. А ведь на практике часто требуется временно перейти в новое состояние, например, в "Атаку" или "Диалог", а потом вернуться туда, где юнит был раньше, — будь то "Патрулирование", "Ожидание", "Преследование". В классическом FSM это сложно: придётся городить отдельные переходы вручную и помнить, куда вернуться. Стек решает эту проблему: при входе в новое состояние мы просто кладём текущее в стек, а когда задача выполнена — вытаскиваем его обратно.

Такое поведение уже ближе к pushdown automata — автомату с памятью. Именно он лежит в основе таких игровых систем, как иерархические FSM, AI-поведение с временными реакциями (например, реакцию на тревогу), управление состояниями UI или катсцен.

Это частый кейс, когда нужно сделать связанные состояния вроде Idle1 и Idle2, чтобы разнообразить анимацию ничегонеделания, повертеть головой, а потом опять ничего не делать. Если использовать обычный конечный автомат, то мы уже потеряли информацию о предыдущем состоянии. Чтобы это обойти, придётся создать целую кучу промежуточных состояний: LookinWhileStanding, FiringWhileRunning, FiringWhileJumping и так далее — только для того, чтобы прописать обратные переходы.

Возможности для расширения

HFSM, fsm c наследованием. Состояния могут вложенно наследоваться, создавая древовидную структуру, состояние "Атакует" может включать под-состояния "Замах", "Удар", "Восстановление"

Каждое состояние может задавать собственные условия выхода (время, события, флаги). Можно добавить действия при входе/выходе, переходные анимаций, сбросы, триггеры.

Состояние может само определять, какие команды допустимы и как они интерпретируются. Например, в состоянии "Прыжок" команда "Атака" может означать "удар в воздухе", а в "Стоит" — обычную атаку.

DSFM, динамические fsm. Состояния можно рассматривать как мини-компоненты AI, выделенные в отдельные модули, которые можно подключать динамически, таким подходом можно накидывать новое поведение в патчах и дополнениях (так например делает Witcher3).

Влияние на архитектуру

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

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

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

Недостатки

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

F̴̢̨̛̣͕̆̋̊̇̐̈́͝͝L̸̦̻̝̼̟̒͜Y̵̨̧̱͖͉̭̠͐̔̀͐̃̃̕ͅW̸̟̽E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠Í̵̥͕͓͖̤̞G̸̢͉̼͇̝̓̌̍͗H̷̝̍̅̔̈̉̋̐̍͝Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾

Клонируем эпос, экономим фреймы

Я могу описать масштабную битву или живой, динамичный мир всего в паре абзацев, но реализовать это в реальной игре — совсем другой уровень сложности. Когда на экране одновременно появляются сотни или тысячи юнитов. Да в принипе даже десятка разнотипных NPC хватит, каждый со своим поведением, анимацией и логикой. Тут программист AI видит не эпическую сцену, а лавину вычислений, которую нужно как-то уложить в таймлайн каждого кадра.

Каждый юнит требует обработки: распознаёт обстановку, принимает решения, двигается, атакует, реагирует на окружение. Добавьте к этому систему приоритетов, поиск пути, проверки столкновений, триггеры, команды от игрока — и вы получите настоящий шквал задач для движка, десятки микрозадач для одного NPC. Даже если у вас хватит памяти, чтобы хранить данные всех юнитов, на практике узким местом становится не объём, а частота обновления логики — всё это нужно пересчитывать и обновлять 60 раз в секунду (или чаще), чтобы игра оставалась отзывчивой и выглядела живой.

Если каждый юнит реализован как полноценный, независимый объект с полным набором данных и логики, то при масштабировании система просто захлебывается. Именно здесь на сцену выходит паттерн Flyweight (Легковес): он позволяет отделить уникальное поведение юнита от его повторяющейся структуры и переиспользовать данные, чтобы резко снизить нагрузку на систему. Благодаря этому можно реализовать битвы с тысячами бойцов, не теряя в производительности и управляемости.

struct Unit
{
  Mesh mesh_;
  Texture head_;
  Texture body_;
  Vec3 position_;
  float height_;
  float radiusAttack_;
  AIState state_;
  AIAction action_;
};

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

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

Паттерн предлагает вынести все общие данные, например для группы объектов, — такие как 3D-модель, анимации, базовую ИИ-логику — в отдельный, разделяемый объект (например, UnitType). Тогда каждый конкретный юнит (UnitInstance) будет содержать только уникальные для себя данные: позицию на карте, текущее здоровье, индивидуальные состояния, цели и т.д.

Вместо того чтобы хранить и обрабатывать 1000 отдельных копий "лучника", вы храните один ArcherType и 1000 ссылок на него с минимальным набором индивидуальных параметров. Это экономит память, упрощает логику, уменьшает время загрузки и позволяет легко масштабировать бой до действительно больших армий без просадок производительности.

Instancing

Чтобы минимизировать объем данных, которые нам нужно обрабатывать на СPU, мы обработаем общие данные — например позицию группы — всего один раз. Затем отдельно передаем уникальные данные каждого экземпляра лучника — его позицию, поворот, логику атаки. Это выходит намного быстрее, чем прогонять полный цикл для каждого юнита, когда 80% данных будут повторяться. Хотите 4k активных юнитов с анимациями, поиском пути и АI логикой, легко? Юниты обрабатываются группами, всё происходит на одном потоке, есть заметные фризы, но эта же логика с поюнитным обновлением будет просто слайдшоу с FPS 0.1

Пример реализации

Возможности для расширения

Flyweight^2: выносим поля объектов в массивы — сепарация данных (SoA), чем-то будет похожа на ECS(отдельно позиции, отдельно состояния), но быстрее и проще в реализации. Общие данные (например, модель, анимация, поведение) — это Flyweight, индивидуальные — индекс в массиве (реализация объектов в Factorio)

Если объектам всё-таки нужна сложная логика (например, враги с здоровьем и навигацией), но они часто создаются/удаляются — совмещаем Flyweight с пулом. Объекты кэшируются и получают Flyweight при инициализации, полноценная логика подключается позже, когда объекту понадобится полноценный апдейт

Схема работы
                        [ Shared Data - Flyweight ]
+--------------------------------------------------------------+
|                        ArcherFlyweight                       |
|  - mesh                                                     |
|  - animationData                                            |
|  - weaponType                                               |
|  - commonAIParams                                           |
+--------------------------------------------------------------+
                             ^
                             |
              +--------------+--------------+
              |                             |
              |                             |
              v                             v
+---------------------------+   +---------------------------+
|     ArcherInstance #1     |   |     ArcherInstance #1000  |
|  - position               |   |  - position               |
|  - rotation               |   |  - rotation               |
|  - currentHealth          |   |  - currentHealth          |
|  - attackCooldown         |   |  - attackCooldown         |
|  - reference to Flyweight |   |  - reference to Flyweight |
+---------------------------+   +---------------------------+
              |                             |
              v                             v
     Use shared mesh/logic        Use shared mesh/logic
       with local transform        with local transform
Влияние на архитектуру

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

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

Недостатки

Плюс - это два минуса. Разделение на общие (инвариантные) и уникальные (вариантные) данные требует дополнительного проектирования и такое разделение надо закладывать на ранних этапах разработки. Нужно точно понимать, какие поля можно сделать общими, а какие должны оставаться индивидуальными для каждой сущности. Общее упрощение логики добавляет архитектурную сложность и делает код неочевидным для новичков. Самый частый вопрос - "Как мне управлять одним юнитом"? - А никак, или отдельным AI модулем. Когда поведение вынесено в общий объект и используется множеством юнитов, баги в этом поведении могут проявляться непредсказуемо, так как связаны с контекстом уникальных данных. Это сильно усложняет отладку, особенно если используется пул объектов, кэширование или системы на подобии ECS, но это общая черта отладки ECS подобных систем.

Заметки на полях

Flyweight и Object Pool помогают уменьшить нагрузку на память и GC, но делают это по-разному - первый минимизирует повторяющиеся данные (например, текстуры, модели, поведение), второй минимизирует частое создание/удаление объектов (временные снаряды, партиклы). В играх они часто используются вместе: все пули используют Object Pool, все пули одного типа делят один Flyweight с данными визуализации.

ECS естественным образом реализует принципы Flyweight - данные скомпонованы, часто в виде массивов или таблиц (SoA), поведение реализуется раздельно, а не как часть объектов, снимая вычислительную нагрузку.

Все травинки на поле KCD/FarCry — один Mesh, разные позиции и скейл (Flyweight + Instancing). Все иконки в инвентаре — один TextureAtlas, разные UV (Flyweight + Instancing), поведение "беги, атакуй, умирай" в L2D — один BehaviorTree для сотен NPC (Flyweight + Instancing)

O̴͕̟̼͖̜͆̽̓͆̋͌́̒̏B̸̤́̈̀S̶͔̬̬̱͙̃̈́̐̀͒̄͝͝͝E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠R̶͇͚͇̠̒̄V̶̰͖̲͈̠͋͒͜E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠R̶͇͚͇̠̒̄

Когда ты устал от случайных связей, и наконец-то начал слушать.

Observer — один из самых известных и широко используемых паттернов из оригинальной "банды четырёх", но мир игростроя иногда бывает удивительно изолирован. Так что вполне возможно, что для вас всё это в новинку. Если вы давно не покидали монастырь ручного обновления UI и прямого связывания объектов, вам стоит знать, зачем всё это вообще нужно.

Это ещё один шаблон, который найдется в любой игре и он настолько часто используется в разработке, что его добавили прямо в стандартную библиотеку Java, если я правильно помню курс по языку(java.util.Observer), а в C# встроили в сам язык через ключевое слово event.

Допустим, мы внедряем в нашу игру систему ачивок. Она будет включать уникальные значки, которые игроки смогут зарабатывать, выполняя самые разные задачи — от привычных до откровенно безумных. Например, «Убей 100 лучников за сессию» — классика жанра, награда за упорство в бою. Или «Не используй онагры в бою» — достижение, отмечающее знание механик осад. Или вот «Победить, используя только базовых юнитов первой эпохи» — это уже вызов для истинных стратегов, которые ищут нестандартные способы прохождения. Такие достижения не только подстёгивают интерес к игре, но и позволяют игрокам проявить креативность, посоревноваться с друзьями и просто посмеяться.

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

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

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

Это риторический вопрос. Ни один AI-программист не позволит логике дизайнера ачивок влезать в математику расчета дамага. Дамаг отдельно - ачивки отдельно, по возможности вообще независимо от логики игрового процесса, потому что эту ачивку получат 10 пользователей один раз со времени покупки, а фпс она просадит условно миллиону на каждом запуске игры.

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

void Unit::dealDamage(Bullet& bullet)
{
  float damage = bullet.calcDamage(this);
  update();
  if (damage > 0.f)
  {
    notify(EventDamage(this.id, bullet.id, damage));
  }
}

Мы добавили новую логику, и все, что она делает — это говорит: «Эм, не знаю, интересно ли это кому-то, но эта штука только что нанесла дамаг другой штуке. Делайте с этим что хотите.»

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

Система ачивок регистрируется таким образом, что всякий раз, когда отправляется event c дамагом, система достижений его получает и некоторым образом обрабатывает. Это вообще может быть в отдельном потоке или в конце фрейма, чтобы не мешать основным вычислениям. Если условия совпадают, то активируется соответствующее достижение с фейерверками и салютами, и делает всё это без какого-либо участия со стороны системы расчета дамага.

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

struct Observer {
  virtual void onNotify(Event event)
};

struct Achievements : public Observer
{
  virtual void onNotify(Event event)
  {
    switch (event.type)
    {
    case EVENT_DEAL_DAMANGE:
      if (!achievements[ACHIEVEMENT_NOONAGRE_DAMAGE] 
          && event.entity.type == OBJECT_ONAGRE)
      {
          onagreDamageExec(event);
      }
      break;

      ...
    }
  }

  bool achievements[...];
};

Отправитель сообщений есть, получатель сообщений тоже есть, осталось как-то связать их. Метод уведомления вызывается объектом, за которым наблюдают. В терминологии «банды четырёх» этот объект называется «subject» — субъект. У него две задачи - он хранит список наблюдателей, которые терпеливо ждут от него весточки. Это позволяет внешнему коду контролировать, кто будет получать уведомления, не перемещая контекст применения ни в систему расчета дамага, ни в систему ачивок. Субъект (subject) общается с наблюдателями (observers), но не связан с ними напрямую. В нашем примере ни одна строка в коде расчета дамага не упоминает систему достижений. И всё же он способен с ней «разговаривать». В этом и заключается хитрость этого паттерна.

class Subject
{
public:
  void addObserver(Observer* observer);
  void removeObserver(Observer* observer);

  void notify(Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }
};

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

Observer особенно страдает от этой репутации, потому что часто оказывается в плохой компании. Его легко спутать с такими «тяжеловесами» как события (events), сообщения (messages) и особенно с магией data binding’а. А эти системы действительно могут быть тормозными — и зачастую это осознанный выбор архитекторов. Они используют очереди событий, откладывают обработку, делают динамическое выделение памяти под каждое уведомление. Всё это может быть оправдано в больших UI-фреймворках или распределённых системах, где нужна гибкость и изоляция.

Но бедный Observer тут ни при чём. Его базовая реализация — это просто список указателей и прямые вызовы функций. Без всякой магии. Никаких очередей, без отражений и прокси-классов. Это лёгкий и эффективный способ организовать связь «один ко многим» без жёсткого связывания компонентов. Но стоит кому-то услышать «Observer» — и в голове тут же возникает образ монструозного фреймворка с XML-конфигами и замедленным откликом на действия пользователя. На самом деле можно вполне бодро обрабатывать до 50к ивентов на одном фрейме (16мс) без особых накладных расходов, это только на одном треде.

Пример

Возможности для расширения

Буферизация обработчиков - вместо немедленного вызова подписчиков, события откладываются и исполняются в определённой фазе кадра — чтобы избежать каскадных вызовов и сделать поведение прогнозируемым

Фильтрация подписчиков, пространственная приоритизация - подписчики могут отбираться не просто по типу события, но и по положению в мире (spatial mask), по фракции, или по текущему состоянию. Что помогает не обрабатывать невалидных юнитов.

Схема работы
                   [ Subject (Publisher) ]
+-----------------------------------------------+
|                  PlayerHealth                 |
|  - health                                      |
|  - observers: List<IObserver>                 |
|                                               |
|  + takeDamage(amount)                         |
|      health -= amount                         |
|      notifyObservers()                        |
+-----------------------------------------------+
                           |
        +------------------+------------------+
        |                                     |
        v                                     v
+--------------------+             +---------------------+
|  HealthBarUI       |             |  ScreenEffectSystem |
|  (Observer)        |             |  (Observer)         |
|  update(health)    |             |  update(health)     |
+--------------------+             +---------------------+
        |                                     |
        v                                     v
+--------------------+             +---------------------+
|  Draw updated bar  |             | Show red flash      |
+--------------------+             +---------------------+
Влияние на архитектуру

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

Синяя книга вышла в 1994 году. Тогда ООП входило в моду, следующие десять лет прошли под знаком «выучить С++ за 21 день», а менеджеры среднего звена оценивали работу программистов по количеству созданных классов. А те, в свою очередь, мерялись длиной своих иерархий наследования, и паттерну Observer "неповезло" стать популярным именно тогда. Так что неудивительно, что он изначально был перегружен классами.

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

Недостатки

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

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

Заметки на полях

Observer — это про реакцию на изменение состояния. Очень похожую идею несут системы событий (EventBus, MessageBus, MailBox). Observer — это "локальный" вариант, EventBus — "глобальный", но идея одна: объекты подписываются и реагируют, не зная друг о друге напрямую.

В ECS архитектуре поведение — это система, которая реагирует на изменения данных. ECS во многом реализует Observer-подобное поведение — но через данные и обработчики, а не через прямые подписки.

E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠V̶̰͖̲͈̠͋͒͜E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠N̸̩͈̖͎̺̰̥̞͇͗̿̒̌͂̓̕ͅŢ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾B̸̤́̈̀Ṳ̵̳͇̥̠͗̅͒̉̏̄̈́̚͝S̶͔̬̬̱͙̃̈́̐̀͒̄͝͝͝

Игры не являются строго event-driven системами, как, например, GUI-системы или серверные приложения. Однако довольно часто там будет реализована собственная очередь событий — «позвоночник» системы основных реакций игры. Такие очереди нередко называют центральными, глобальными или основными. Они используются для организации высокоуровневой коммуникации между игровыми подсистемами, чтобы сделать их слабо связанными друг с другом.

Если вам интересно, почему же тогда игры не строятся целиком на событийной модели - потому что:
- так исторически сложилось, и это продолжает влиять новые проекты
- подход с непрерывным обновлением состояния (game loop) позволяет получать больше времени на задачу в простых случаях и проще в понимании и разработке, относительно такой же event-driven реализации, тут надо смотреть что важнее.

Это лучше всего просматривается на системе обучения в играх, которая показывает подсказки после определённых игровых событий. Допустим, мы делаем так, чтобы после первого убийства монстра игроку показывалась всплывающая подсказка: «Нажми X, чтобы забрать добычу!»

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

Код, отвечающий за боевую систему или основной геймплей, и без того достаточно сложный, и последнее, что хочется — это напичкать его дополнительными проверками на всякие «если игрок впервые убил монстра — показать обучение». Вместо этого лучше ввести очередь событий , чтобы любая система игры могла отправлять туда события. В нашем случае, код боевки может при убийстве врага добавлять событие типа "enemy_died", которым потом может воспользоваться и система ИИ и ачивок.

Точно так же, любая система может подписаться на события из этой очереди. Обучающий модуль может зарегистрироваться и указать, что его интересуют события типа "enemy_died". Таким образом, информация о том, что враг был убит, доходит до обучающего движка без прямой связи между этими системами. Боевой код ничего не знает об обучении, а обучение не знает, кто конкретно послал событие.

Если вам не хочется возиться с реализацией и наступать костылями на грабли, по которым до вас уже прошлись не один десяток разработчиков, то можете обратить внимание на эту библиотеку eventpp от одного из разработчиков движка EA Frostbite.

Возможности для расширения

Исполнение по фазам - игровой EventBus должен уметь буферизовать события и исполнять их по фазам: логика, AI, рендер, UI. Это предотвращает каскадные обновления и даёт возможность упорядочить время вызова обработчика.

Routing и модификация - можно добавить промежуточную обработку событий, прежде чем они дойдут до подписчиков, модификация данных (добавили фрейм создания ивента), логгирование, переадресация или удержание если нет обработчиков (deferred handling).

TTL ивента - некоторые события — мимолётны, например, "юнит увидел игрока", после одной обработки оно больше не нужно, другие могут ожидать обработчиков несколько фреймов (отложенные ивенты или удержание)

Влияние на архитектуру

Слабая связанность (loose coupling) - подсистемы не зависят напрямую друг от друга, это упрощает поддержку и масштабирование.

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

Основной геймплейный код не засоряется вспомогательной логикой, всё это можно вынести в отдельные модули, логировать или отображать поток событий отдельно, не погружаясь в детали каждой системы.

Код вынесенный в модули можно тестировать изолированно, просто отправляя им заранее заданные события.

Недостатки

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

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

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

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

Схема работы
 [ Combat System ]           [ Inventory System ]           [ AI System ]
            |                            |                            |
            |                            |                            |
            |                            |                            |
            v                            v                            v
+--------------------------------------------------------------------------+
|                         CENTRAL EVENT QUEUE                              |
|                                                                          |
|   <- receives: "enemy_died"     <- receives: "item_picked"               |
|   -> dispatches to:                                                      |
|       - Tutorial System                                                  |
|       - Achievement System                                               |
+--------------------------------------------------------------------------+
            ||                            ||                   ||         
            \/                            \/                   \/
     [ Tutorial System ]       [ Achievement System ]   [ Analytics System ]

D̵͚̩͍̖͈̀̈́̀͐̑͐̇͆̅ͅÍ̵̥͕͓͖̤̞R̶͇͚͇̠̒̄Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾Y̵̨̧̱͖͉̭̠͐̔̀͐̃̃̕ͅF̴̢̨̛̣͕̆̋̊̇̐̈́͝͝L̸̦̻̝̼̟̒͜Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠G̸̢͉̼͇̝̓̌̍͗

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

Граф сцены(обычно) это плоский список объектов, так проще с ними работать, и так выгоднее для cpu. Каждый объект имеет условно модель, которую использует подсистема, а также xform (позицию + углы поворота).

xform описывает положение, поворот и масштаб объекта в мире. Чтобы переместить или повернуть объект, достаточно изменить его иксформу.

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

Представьте, что наш юнит на карте может нести разное оружие, оружие может иметь аттачи, например прицел, глушитель или внешний магазин. Локальный xform юнита позиционирует его на карте, xform оружия позиционирует его на теле юнита, xform аттача позиционирует его на оружии и так далее.

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

T_world = T_root × T_unit × T_arm × T_weapon × T_muffler

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

Но такой подход просто тратит такты CPU, в реальности ничего не делая. 95% объектов любой игры не перемещаются в пределах 10 секунд, 80% объектов вообще никогда не меняют своего положения. Вся статическая геометрия, из которой состоит уровень — стены, здания, скалы и прочее окружение никогда не поменяют своих координат.

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

Как работает

В основе паттерна «Dirty Flag» лежит простая идея, что каждый объект или компонент, который может изменяться, содержит специальный маркер — "dirtyflag". Этот флаг указывает, что данные объекта изменились и требуют обновления зависимых состояний или пересчета производных данных.

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

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

Надо понимать, что dirtyflag's применяются в двух типах задач: вычисления и синхронизации. Попытки использовать их для чего-то другого, приводят только к переусложнению архитектуры и созданию ложных зависимостей.

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

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

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

struct UnitModel {
  UnitModel(Mesh* mesh) : _mesh(mesh),
    _xlocal(XForm::origin()),
    _dirty(true)
  {}
  ...

  void render(XForm xform, bool dirty)

  Mesh* _mesh;
  XForm _xlocal;
  XForm _xworld;
  bool _dirty;
  
};

void UnitModel::render(XForm world, bool dirty) {
  dirty |= _dirty;
  if (dirty) {
    _xworld = _xlocal.combine(world);
    _dirty = false;
  }

  render(_mesh, _xworld);

  for (auto &child: _children) {
    child->render(_xworld, dirty);
  }
}
Влияние на архитектуру

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

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

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

Недостатки

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

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

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

Заметки на полях

«Dirty Flag» можно рассматривать как облегченную версию паттерна Observer, где вместо немедленного уведомления всех наблюдателей мы просто устанавливаем флаг для последующей обработки.

Паттерн «Dirty Flag» воплощает принцип ленивых вычислений — данные обновляются только при необходимости.

Комбинация «Dirty Flag» с кешированием результатов представляет собой форму мемоизации, где вычисленные результаты сохраняются до тех пор, пока исходные данные не изменятся.

Ṳ̵̳͇̥̠͗̅͒̉̏̄̈́̚͝P̷͍͇͖͎̖̔̋̋̑̋̄D̵͚̩͍̖͈̀̈́̀͐̑͐̇͆̅ͅÄ̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠Ţ̴͓̼̠͕͖̬̇͛̉͆̋͛͂̾E̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠ L̸̦̻̝̼̟̒͜Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠Y̵̨̧̱͖͉̭̠͐̔̀͐̃̃̕ͅE̷̥̹̩͎̲͂̋̔͂̈́́̋̈́̽͠R̶͇͚͇̠̒̄S̶͔̬̬̱͙̃̈́̐̀͒̄͝͝͝

Просто не обновляй это

При разработке игр, особенно тех, которые содержат большие миры с большим количеством объектов, важно эффективно управлять производительностью. Один из самых эффективных подходов — это паттерн Update Layers, который позволяет организовать обновление игровых объектов на основе их расстояния от игрока и вряд-ли вы встретите его где-то за пределами игровой разработки. Такой подход помогает избежать излишней нагрузки на процессор, улучшая производительность, сохраняя при этом ощущение живости мира.

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

Этот подход позволяет более эффективно использовать ресурсы, фокусируясь на более важных объектах (которые находятся в непосредственной близости от игрока), и снизить нагрузку при обновлении объектов, которые в данный момент не взаимодействуют с игроком.

Как работает

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

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

Ближний слой (Close Layer) - охватывает объекты, условно расположенные в радиусе до 15 у.в. (условной высоты игрока) от игрока. Это пространство, где происходят важные взаимодействия, такие как сражения, взаимодействия с NPC и критичные для геймплея объекты. Все объекты этого слоя обновляются на каждом кадре игры.

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

Средний слой (Mid Layer) - обычно охватывает зону от 15 до 50 у.в. от игрока. Это зона, в которой происходят менее интенсивные взаимодействия. Обновление этих объектов происходит реже — например, раз в несколько кадров. Это позволяет существенно сэкономить ресурсы, не ухудшая восприятие мира в целом, тут еще работает человеческое восприятие далеких объектов, если делать так частый апдейт - юнит будет "мельтешить", т.е. часто дергаться и отвлекать внимание. В этом слое обновляются более простые взаимодействия, такие как патрулирование NPC, движущиеся по заранее заданным маршрутам, большая часть реакций на изменения в окружении отключены, оставлены только базовые и скриптовые. К примерам можно отнести NPC, которые не активно взаимодействуют с игроком, но могут патрулировать территорию. Также сюда могут входить машины, животные, или другие объекты, которые двигаются по сцене, но не влияют на ход событий.

Дальний слой (Far Layer) - включает все объекты, которые находятся на расстоянии более 50 у.в. от игрока. Это зона, где объекты не взаимодействуют с игроком напрямую. Обновление этих объектов происходит редко — возможно, только при конкретных событиях или даже только когда игрок приближается к ним. Для объектов дальнего слоя можно полностью отключить обновление или минимизировать его, обновляя лишь базовые параметры, позицию на сцене или состояние. В некоторых случаях объекты могут быть "заморожены", пока не попадут в радиус действия или видимости игрока. Примерами могут служить декоративные элементы окружения, дальние враги или животные, которые появляются в поле зрения игрока только по мере его продвижения.

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

Для реализации этого паттерна в игре обычно используется система, которая оценивает расстояние между игроком и каждым объектом на сцене в реальном времени или на основе spatial distance (нахождении объектов в соседнем от игрока блоке октодерева)

struct GameObject {
    vec3 _pos;
    bool _active;
              
    float distanceToPlayerSq() const {
        return _pos.distanceSq(player)
    }
    
    void updateClose(float deltaTime) override {
        // Полное обновление с физикой, анимациями, ИИ и т.д.
        if (distanceToPlayerSq() > 15.f) {
          return;
        }

        // Пример простого кода для перемещения объекта
        fight();
        searchEnemy();
        move();       
    }
    
    void UpdateMid(float deltaTime) override {
        // Упрощенное обновление с базовой логикой, 
        // только перемещение и простые проверки
        if (distanceToPlayerSq() > 30.f) {
          return;
        }

        if (_active) {
          patrol();
          move();
        }
    }
    
    void UpdateFar(float deltaTime) override {
        // Минимальное обновление или вообще отсутствие обновления
        // только проверка на необходимость активации
        if (distanceToPlayerSq() > 50.f) {
          _active = false;
          return;
        }

        // просто проверяем активность
        _active = distanceToPlayerSq() <= 50.0f;
    }
};
Влияние на архитектуру

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

Отлично масштабируется на неигровые компоненты, позволяя снизить возможность перегрузки системы. Делает такой подход к реализации апдейтов крайне полезным в жанрах типа RTS, MMO или open-world sandbox.

Позволяет гибко управлять логикой, изменяя поведение в слоях. В разных слоях можно по-разному обрабатывать физику, анимации, звуки, ИИ и другие аспекты: например, отключать ИИ для дальних NPC или заменять сложные симуляции звуков на упрощённые модели.

Недостатки

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

Возникают трудности с синхронизацией логики. Если один объект обновляется часто, а другой — редко, возможны рассинхронизации, например, когда активный юнит "догоняет" неактивного, который "просыпается" с опозданием.

Сложнее тестировать и отлаживать. Поведение объекта становится зависящим от его положения в мире, и баги могут воспроизводиться не всегда, а только при определённой дистанции до игрока или камеры.

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

Частые переходы объектов между слоями, например при хаотичном движении игрока, могут привести к флиппингу юнитов, пограничному состоянию, когда старая логика уже неактивна, а новая еще неактивна, и связанным с этим состоянием багов.

Заметки на полях

Update Layers интересно сочетается с рядом других архитектурных паттернов.

Observer: объекты, находящиеся в "спящих" слоях, могут подписываться на важные события, даже если их логика временно не обновляется. Например, враг может быть неактивен, но «проснуться», если игрок шумит рядом или входит в зону агро. Это даёт ощущение "живого" мира при минимальных затратах, особенно если события передаются через систему сообщений, а не прямыми вызовами. Система реализована в играх серии MGS

ECS: прекрасно вписывается в ECS-архитектуру - вместо включения/выключения логики на уровне сущностей можно добавлять или удалять определённые компоненты (например, AIUpdateComponent, PhysicsComponent) в зависимости от слоя.

Object pooling: объекты "замораживаются" в дальних слоях, но их можно не просто выгружать, а отдавать в пул. Например, далекие юниты могут быть выгружены и помещены в пул, сохранив минимальное состояние (позиция, здоровье), пока не будут нужны. Это даст минимальное время возврата юнита в игру по сравнению с обычным пулингом.

Jobs: легко комбинируется с системой задач. Частота обновления может задаваться как параметр задачи, и более редкие обновления могут перемещаться в неприоритетный worker, что помогает распределить нагрузку в кадре.

G̸̢͉̼͇̝̓̌̍͗O̴͕̟̼͖̜͆̽̓͆̋͌́̒̏Ä̵̡̠̮̻͍́̒͆̓͗̀̃͝͝͠P̷͍͇͖͎̖̔̋̋̑̋̄

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

Чтобы понять, как работает целеориентированное планирование (GOAP), и как оно применяется в играх, нужно сначала познакомиться с его основой — областью искусственного интеллекта под названием автоматическое планирование. Это технология, при которой система самостоятельно строит последовательность действий, необходимых для достижения поставленной цели. Такая последовательность называется планом, и она помогает персонажу (или агенту) принимать решения и действовать в сложной игровой среде.

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

Действие в GOAP состоит из трёх ключевых компонентов: во-первых, это объекты, с которыми действие взаимодействует (например, дверь). Во-вторых, предусловия/предикаты — то есть условия, которые должны быть выполнены до того, как действие можно будет выполнить. И в-третьих, эффекты — это результат действия, то, как оно изменяет состояние мира. Например, чтобы открыть дверь, предварительные условия могут быть такими: дверь должна быть закрыта, и NPC должен находиться рядом. После выполнения действия дверь становится открытой — это и есть эффект.

Допустим, нам нужно, чтобы персонаж перешёл из комнаты A в комнату B, но между ними закрытая дверь. В модели планирования мы должны указать, что дверь соединяет эти две комнаты, и что её текущее состояние — закрыта. А также — что нельзя пройти через закрытую дверь. Система, проанализировав это, построит план: сначала открыть дверь, а затем пройти в другую комнату. По завершении этого плана персонаж окажется в комнате B, а состояние мира изменится соответствующим образом.

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

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

Без назначенной цели юниты буквально ничего не делают. Им обязательно нужно задать цель, которую система планирования затем будет пытаться достичь. Чем больше различных целей вы сможете реализовать, тем более правдоподобным будет поведение. Каждая цель может быть инициализирована, обновлена и завершена. Более того, у неё есть функция, которая позволяет в каждый конкретный момент времени вычислять её приоритет.

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

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

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

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

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

Практическая реализация GOAP в играх за пределами логики отдельного NPC довольно сложная задача, требуется "словарь" состояний системы, каталог всех возможных действий с их предусловиями и эффектами, перекрывающий основные действия игрока, а также разработку эффективного планировщика, чаще всего A*/DFS/HTN-гибрид.

В опенсорс такие системы еще не выкладывали, но их реализация есть на разных этапах в больших играх (FarCry, Assasins Creed, почти все MMO):

  • F.E.A.R - одна из первых игр, где GOAP был применён на практике. AI выбирал действия на основе целей (искать укрытие, атаковать, отходить), что создавало “умное” поведение.

  • Far Cry 2+ - применялся для NPC, которые реагируют на ситуацию (подбегают к тушке друга, обходят игрока, лечат товарищей, меняют поведение при повреждении), тоже в основном на уровне AI NPC, но группа NPC уже имела собственный AI поведения на основе целей, который распределял роли в группе.

  • Shadow of Mordor - Nemesis, переняли GOAP из FEAR и доработали её, орки строят планы (убить игрока, отомстить, взойти по иерархии), хотя было не без сложностей и костылей. Реагируют на изменения мира, запоминают действия игрока и собирают банды.

  • Серия Deus Ex - динамическая система памяти и узнавания, позволяющая NPC запоминать действия игрока и реагировать на них при последующих встречах. Если вы ранее угрожали человеку или помогли ему, он мог узнать вас позже. Система боевого ИИ, учитывающая укрытия, групповую тактику и различные реакции на действия игрока.

  • В серии Assassin’s Creed используются гибридные модели - GOAP + Behavior Trees - охранники могут патрулировать, тревожиться, преследовать, возвращаться на пост, делиться информацией об игроке, и имеют более 70 целей для отдельного юнита. Поведение организуется через цели и условия глобально для всего региона (найти игрока, вызвать подкрепление, изменить лут и оружие у NPC, вещи у торговцев, доступные квесты в регионе)

  • RDR2 - "Living World" отвечает за cимуляцию экосистемы с животными и птицами, которые имеют собственные циклы поведения, цели для взаимодействия между собой и с окружающей средой. Распорядок дня NPC, где каждый персонаж мира имеет собственный график, работу, домашние дела и социальные потребности. Жители поселений просыпались, работали, обедали, общались и ложились спать в соответствии со временем суток.

Адаптивность к внешним действиям является главным преимуществом GOAP для приложений. Система не привязана к заранее определенным сценариям и может гибко реагировать на изменения условий, находя альтернативные пути достижения целей. Модульная природа GOAP облегчает расширение функциональности: новые действия и состояния могут быть добавлены без необходимости пересматривать существующую логику. Конечно придется писать базовые реакции на возможные ситуации и прописывать соответствующие действия, но с ростом числа простых реакций система будет выходить на плато реактивности, когда достаточно определить элементарные действия и целевые состояния, чтобы получить новый функционал, заранее не предусмотренный в системе.

Влияние на архитектуру

GOAP (Goal-Oriented Action Planning) — если его применять для разработки игровых систем, позволяет создавать адаптивное поведение. Его главное достоинство — способность системы самостоятельно планировать действия в зависимости от текущей ситуации, без привязки к жёстко заданным сценариям.

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

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

Недостатки

Однако вместе с адаптивностью приходит запредельная сложность. Реализация требует планировщика, формализации состояний и продуманной системы оценки стоимости действий. Это один из самых сложных приемов разработки в играх.

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

Примеры

В широком доступе нет

Заключение

Статья получилась достаточно объемной, и всеже была рассмотрена только треть часто применяемых при разработке игр паттернов. А еще есть не менее интересные Prototype, Update, Progression, Bytecode, Subclass, ObjectType, Component, Service, ObjectPool, SpatialTree. Возможно, стоит сделать вторую часть, или двинуться уже к финалу.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+5
Комментарии6

Публикации

Работа

Ближайшие события