Всем привет! В этот раз я решил написать про то, что, как я недавно узнал, называется headless симуляция, безголовая симуляция, так сказать.
Дисклеймер по традиции: я не профильный разработчик, пишу свой симулятор школы по вечерам, и вполне допускаю, что местами мои решения покажутся вам наивными или кривыми (или вообще НЕВЕРНЫМИ). Я просто рассказываю про проблему, в которую упёрся сам, и про то, как из неё выбирался. Если знаете, как лучше — буду рад в комментариях.
Сразу про важное, чтобы не было завышенных ожиданий: игра — это прототип. Что-то в нём работает прилично, что-то держится на гвоздях, часть систем существует в режиме «работает, но я иногда удивляюсь — почему». Так что это не рассказ «как надо», а просто один из кейсов и один из способов решения. Но очень полезный способ! ОЧЕНЬ
Это еще одна статья из цикла про разработку игр без прикладного опыта. Если вам интересна эта и подобные темы — подписывайтесь на мой ТГ-канал Homemade Gamedev, где посты выходят чаще, и я пишу про текущие задачи в проекте.
С чего всё началось
У меня в игре NPC (я их иногда зову Агентами) живут, как они думают (если они вообще на это способны), своей жизнью: выбирают, чем заняться, идут к нужному объекту, занимают у него слот, садятся, заводят социальные связи. Под капотом — самописный гибрид ECS для модуля NPC (про то, как я на него переходил и что из этого вышло, я уже писал отдельно), планировщик задач на HTN, поиск пути A* и граф для маршрутизации, ну и куча строчек кода, которые, как говорил один из моих преподов по информатике на физфаке МГУ, «надо написать, чтобы программа работала»
И вот в какой-то момент я поймал себя за довольно нудным занятием. Каждый раз, когда я что-то менял в поведении агентов, я делал так: заходил в Play Mode, ждал загрузки сцены, ставил парту, спавнил ученика и пялился в экран с включенным дебаггером — дойдёт ли он, сядет ли, не пройдёт ли сквозь стену, не застрянет ли. Если агентов несколько — пытался уследить за всеми сразу, записывая в тетрадь промежуточные данные, чтобы сверить, все ли ок.
Это очень нудно.
Поведение одного агента — это не просто человечек идёт. Это десяток кусочков состояния, разложенных по разным хранилищам: где он логически стоит, какой у него маршрут и прогресс по нему, какое сейчас намерение, какой слот действия занят, куда он идёт, в движении ли он.
Теперь представьте: агент дошёл до парты и не сел (или сел, но анимация не переключилась). Почему? Намерение не создалось? Создалось, но в неправильном слоте? Слот заняли и забыли освободить? Маршрут построился, а движение не стартовало? В Play Mode вы этого не видите — вы видите стоящего человечка и гадаете. А отлаживать гонки и тайминги в ECS, втыкая в экран, — это, мягко говоря, не самый эффективный способ провести вечер.
Что вообще происходит, когда NPC решает сесть
Чтобы стало понятно, почему за этим нереально уследить глазами, давайте глянем, что творится под капотом (слабонервных и детей просьба убрать от экранов), когда агент решает сесть на стул. На верхнем уровне это HTN-сценарий из трёх шагов, которые идут строго по очереди:
И это еще не все, потому что не зря тут HTN — это иерархическое дерево задач, каждый «GoTo» внутри себя разворачивается в отдельную цепочку, размазанную по тикам. Запрос ставится в очередь на одном тике, на следующем превращается в «намерение», потом считается маршрут (локально — синхронно через Burst, на дальние дистанции — асинхронным A* в фоне), намерение активируется в слоте, и только после этого агент тик за тиком перемещается к цели, пока критерий «дошёл» не станет истинным:
И это я оставляю за скобками обработку намерений и очередь намерений на стороне самих агентов, раскладку данных по компонентам и их последующую обработку, разного рода State Machine. В общем там геморр, поверьте на слово — поведенческие системы — это одно из самых сложных в играх, и их отладка — мутотень та ещё.
Вторая боль того же рода — граф путей. У меня поверх грида живёт граф, и когда игрок строит стены, граф должен корректно разрезаться (про эти графы и про то, почему покраска стен, маршрутизация и соц-связи — это одна и та же задача, я как-нибудь расскажу отдельно, тема большая). Проверить глазами, что после постройки конкретной стены путь разорвался ровно там, где надо, — почти нереально. А ошибка тут означает, что NPC либо ходят сквозь стену, либо считают нормальный коридор тупиком.
В общем, мне нужен был способ поднять мир, что-то с ним сделать и залезть в его внутренности кодом — быстро и повторяемо. То есть headless (безголово).
Идея: отделить логику от картинки
Сама мысль простая, если её сформулировать: надо разделить «что происходит в мире» и «как это показано».
«Что происходит» — это логика: агент решил сесть, построил маршрут, прошёл по клеткам, занял слот. «Как показано» — это рендер: меши, анимации, спрайты, баблы над головой, камера.
И если логика нигде внутри себя не дёргает рендер — не лезет в GPU, не читает Transform, не зависит от кадра — то картинку можно просто выключить. Логика этого даже не заметит и продолжит крутиться. Вот это «продолжит крутиться без картинки» и есть headless.
Чтобы это взлетело, у меня должны были сойтись три вещи:
-
Время идёт тиками, а не кадрами.
-
Логика не знает про Unity.
-
Граница с рендером спрятана за интерфейсы, которые можно подменить заглушками.
Разберём по порядку.
Время идёт тиками, а не кадрами
В моей симуляции нет Update() и нет Time.deltaTime. Есть один метод Tick(), который двигает весь мир на один дискретный шаг. Каждая «тикающая» система реализует небольшой интерфейс:
public interface ITickable { void Tick();}public interface IScheduledTickable : ITickable { int TickRate { get; } // выполняться раз в N тиков, а не каждый}public enum TickPhase { PreLogic, // намерения, HTN-планы, постановка задач Logic, // решения PostLogic, // движение, A*, эффекты, соц-связи Visual // синхронизация для рендера}
А сам тик — это проход по фазам, а внутри фазы — по системам в фиксированном порядке:
public void Tick(){ if (paused) return; tickCount++; foreach (TickPhase phase in Phases) // Phases кэшируем 1 раз: Enum.GetValues аллоцирует { foreach (var tickable in tickableManager.GetTickables(phase)) { if (tickable is IScheduledTickable scheduled) { // система с TickRate выполняется не каждый тик, а раз в N if (!lastTickMap.TryGetValue(scheduled, out int last) || (tickCount - last) >= scheduled.TickRate) { scheduled.Tick(); lastTickMap[scheduled] = tickCount; } } else { tickable.Tick(); } } }}
Что тут важно лично для меня:
┌──────────────────────── Tick() ─────────────────────────┐ PreLogic ──┤ намерения · HTN-планы · постановка задач │ Logic ─────┤ решения │ PostLogic ─┤ движение · A* · эффекты · социальные связи │ Visual ────┤ синк позиций для рендера ───────► в headless: пусто │ └──────────────────────────────────────────────────────────┘ Никакого Time.deltaTime — мир едет только когда я сам зову Tick().
-
Порядок детерминированный. Фазы — по значению enum, системы внутри фазы — по порядку регистрации. Один и тот же старт + одно и то же число тиков = один и тот же результат. Без этого тесты симуляции были бы флакающими, факапающими, и грош им цена.
-
Фаза
Visual— последняя и необязательная. В игре она синхронизирует логические позиции с тем, что рисуется. В headless систем этой фазы либо нет, либо они пустые — а логике всё равно. -
Тик зову я. В тесте —
Tick()в цикле. В игре — из игрового цикла. Источник времени снаружи, а не зашит внутрь. Вот это, по сути, и есть половина успеха.
Логика, которая не знает про Unity
Вся логика у меня живёт в отдельной сборке — назову её ядром или, прости господи, движком. Это ИИ агентов, поиск пути, ECS-данные, грид, граф. И оно не знает ни про школу, ни про рендер Unity.
Не подумайте, что это с самого начала так все вышло. Я не садился проектировать «чистое ядро, отделённое от движка». Как и все, я начинал на MonoBehaviour: NPC — это компонент, у него Update(), внутри логика, на нем был аниматор (это не тот чел, который детей развлекает, не путать). Просто со временем общие штуки, которые не завязаны на конкретную игру, я раз за разом выносил в отдельные классы на чистом C# — чтобы их можно было переиспользовать и хоть как-то тестировать. Плюс, конкретно с аниматорами у меня не сложилось — я спавнил пару сотен «пустых» NPC без логики с одной простой анимацией и fps уже меня не устраивал.
В какой-то момент этих классов набралось столько, что они сами собой сложились в «движок». То есть разделение — это результат рефакторинга под давлением боли, а не гениальный план. Плана, если быть точным, как такового, вообще не было.
Заглушки вместо рендера
Отделить логику — это полдела. Вторая половина: в игре рендер-системы вшиты в граф зависимостей. Когда я собираю DI-контейнер (у меня Zenject), туда биндятся реальные AgentRenderSystem, GpuVisualPool и прочие, и другие системы получают их через конструктор. Просто «не создавать сцену» недостаточно — половина графа зависимостей этих рендер-сервисов требует.
Решение оказалось довольно простым(хотя может это и костыль) — старый добрый Null Object. Рендер у меня и так спрятан за интерфейсами, так что для headless я сделал каждому пустую реализацию:
public class NullAgentRenderSystem : IAgentRenderSystem{ public void Render() { } public void RegisterVisualVariant(VisualVariant v, Mesh mesh, Material mat, int cap) { }}
Всё. Тот же интерфейс, только методы ничего не делают. Логика дёргает Render() как обычно — а там пусто.
Дальше — сборка мира. У меня есть HeadlessBootstrap, который поднимает контейнер без сцены и подменяет визуальные сервисы заглушками:
public ISimulationRuntime Boot(HeadlessConfig config = null){ config ??= HeadlessConfig.Default; container = new DiContainer(); // обычный Zenject-контейнер, никакой сцены InstallConfig(config); InstallEngineCore(config); InstallNullVisuals(); // ← вот тут подменяем рендер заглушками InstallHeadlessGrid(config); InstallPathfinding(); InstallWQS(); InstallSchoolCore(config); InstallApi(); InitializeEngine(config); return container.Resolve<ISimulationRuntime>();}
Если на пальцах, выбор реализации выглядит так:
После Boot() у меня на руках ISimulationRuntime — фасад, через который можно спавнить агентов, спрашивать позиции, искать пути и, главное, тикать. Работает и из юнит-теста, и из чистой консоли через Unity.exe -batchmode -nographics -executeMethod .... Ради интереса прогнал в консоли 10 000 тиков с одним агентом — заняло меньше секунды (998 мс, то есть порядка 0,1 мс на тик). Сразу честно: сам старт Unity в batch-режиме сжирает ещё около минуты на компиляцию скриптов и загрузку домена — но это разовые накладные расходы, к стоимости самой симуляции отношения не имеющие.
Что это в итоге дало
Сценарии вместо манки-дрочева
Самое ценное: поведение теперь можно записать как сценарий. Дано — мир, когда — прокрутили столько-то тиков, тогда — агент оказался в нужном состоянии. Вот укороченный (чисто для статьи) тест полного цикла «сесть на стул»: зарезервировал слот → дошёл → сел → встал.
[Test]public void SitOnChair_FullCycle_ReserveWalkSitRelease() // упрощено для статьи{ PlaceChair(50, 50); var h = SpawnAgent(52, 50); var e = AgentEntity(h); int tickReserved = -1, tickSitting = -1, tickReleased = -1; for (int tick = 1; tick <= 1000; tick++) { int freeBefore = GetFreeSitSlotCount(); runtime.Simulation.Tick(1); AssertFullEcsConsistency(new[] { h }, $"tick {tick}"); // проверяем консистентность КАЖДЫЙ тик int freeAfter = GetFreeSitSlotCount(); if (tickReserved < 0 && freeBefore == 1 && freeAfter == 0) tickReserved = tick; // слот заняли if (tickSitting < 0 && IsAgentSitting(e)) tickSitting = tick; // сел if (tickReserved > 0 && freeAfter == 1) tickReleased = tick; // встал if (tickReleased > 0) break; } Assert.IsTrue(tickReserved > 0, "должен зарезервировать слот"); Assert.IsTrue(tickSitting > tickReserved, "сесть — строго после резерва"); Assert.IsTrue(tickReleased > tickSitting, "встать — после того как сел");}
Такие тесты у меня есть на ходьбу, на «садится на ближайший из нескольких стульев», на «стульев нет — садится на месте», на блокировку пути стеной. Отдельно радует проверка, которую глазами не сделать вообще: что агент на длинном маршруте прошёл по всем клеткам и не телепортировался. В тесте я просто смотрю, что шаг между соседними клетками за тик ни разу не больше единицы. В Play Mode такой телепорт на одном кадре вы не заметите — а тест ловит.
А что по затратам?
Вы скажете — ой, да ну тебя нахер, и так времени нет, чтобы нормально фичу отладить, так ты еще предлагаешь космолёт какой-то, который никогда не окупится.
И будете правы, в некоторых случаях.
Но я для себя открыл другой путь. Действительно, писать тесты, по сути сценарные тесты через кучу систем — это, мягко говоря, довольно накладно. Но я действую так.
Я собрал базовую архитектуру, а дальше подключил ИИ-агента, которому даю сценарий (вот пример из недавнего)
Разберись, как устроены стены, и как они связаны с базовым графом. Напиши сценарные тесты в моей архитектуре на следующие кейсы
Строю один сегмент стены — проверь, что все связи корректно инициализируются на уровне данных, все компоненты навешены, расчеты (в том числе SortOrder)
Строю 2 разрозненных сегмента — тоже самое
Строю 2 сегмента на одной линии, затем соединяю их третьим — проверь, что компоненты связности сливаются в одну
Аналогичный сценарий, но с разделением (удаление сегмента)
Напиши сценарии на другие типы соединений
Г-образные
Т-образные
Х-образные
На выходе я получил 10 сценарных тестов и суммарно 41 проверку за, примерно 1-1.5 часа.
То есть по сути вы, как аналитик/тестировщик, описываете словами ожидаемый результат поведения фичи на уровне данных (не обязательно с числами) — вы описываете суть, как и что надо проверить, а агент пишет в вашей архитектуре тесты, которые затем либо сам прогоняет, либо вы прогоняете.
Выводы
Если коротко, то вот что я для себя вынес:
-
Headless почти не требует отдельной работы, если архитектура уже к нему готова. Нужны три вещи: время тиками, логика без зависимости от движка, граница с рендером через интерфейсы. У меня первые две сложились сами по другим причинам, а третья — это один вечер с Null Object и ИИ-агентом.
-
Окупается это не у всех. Если вся ваша логика — пара скриптов на
MonoBehaviourи физика, городить ради этого разделение смысла нет, налог на дисциплину съест выгоду. Но чем сложнее симуляция и чем хуже она проверяется глазами — тем раньше наступает момент, когда руками уже не уследить. -
Каждый пойманный баг превращайте в инвариант. Не просто чините — добавляйте проверку, которая бежит каждый тик. Один раз поймали
ComponentNotAttachedна масштабе — и больше он молча не вернётся.
Молодцы, если осилили и дочитали до конца. Буду рад вопросам и критике — особенно если делали похожее и набили свои шишки.
ссылка на оригинал статьи https://habr.com/ru/articles/1043498/