Дизайн боевой арены и фазы расстановки: геометрия как данные, prefab-per-arena, зоны расстановки (Normal/Extended), boot-flow загрузки арены.

Связано: 6. Боевая модель (юниты, статы, эффекты), 02. Архитектура реализации — Фаза 1 (Боевое ядро), 05. Презентация и интерполяция, 07. Техдолг, решения и changelog, 7. Флоу забега и сессия.

Статус: дизайн согласован (2026-07-09). Шаги 1-3 (чистая логика) реализованы и покрыты тестами (2026-07-10, EditMode 184/184). Шаг 4: код авторинга (ArenaLayoutAuthoring + гизмо) и проводка в DI готовы; создание арена-префаба, адреса Addressables, drag-drop UI, сетевые команды расстановки и визуальная проверка — за Максом. Отклонения реализации от исходного эскиза помечены ниже (§3, §10).


1. Зачем этот документ

Боевого поля как сущности в коде пока нет: стартовые позиции юнитов хардкодятся в dev-командах (GuildmasterCommands), границ движения нет вообще — MoveRetreat/MoveKite уводят юнита в бесконечность, отбрасывание (Displace, §9.9) летит в пустоту. Этой информации не было в документации — фиксируем её здесь до написания кода.

Документ описывает три связанные вещи:

  1. Арену — границы боевого поля (клампинг движения, «стены» для отбрасывания).
  2. Зоны расстановки — области, куда можно ставить юнитов перед боем (свои и вражеские; Normal и Extended).
  3. Способ хранения и загрузки контента арен (визуал + геометрия).

2. Главный принцип: геометрия ведёт, визуал следует

Боевое поле — это данные симуляции, а не объект сцены или физики.

  • Не BoxCollider2D, не Physics2D. Симуляция (CombatSimulation) детерминированная, headless, работает на голых Vector2, гоняется в EditMode-тестах без сцены и host-authoritative по нетокоду. Если границы боя читать с коллайдера на сцене — рушится и тестируемость, и авторитетность хоста (клиент не должен доверять своей локальной сцене). См. 02. Детерминированная симуляция и тик.
  • Геометрия первична, арт вторичен. Границы боя не зависят от того, как нарисован задник. Наоборот: игровая зона задана данными, а визуал подгоняется под неё. Художник двигает арт — но не имеет права молча менять границы боя.
  • Авторинг — в сцене/префабе с гизмо; источник правды в рантайме — снапшот данных. Редактируем зоны руками и видим их гизмами (удобно), но на старте боя из авторинга «печётся» неизменяемый снапшот, который уходит в симуляцию. Тесты собирают тот же снапшот руками.

3. Модель данных

Чистые структуры, зависят только от Vector2 (математика, не MonoBehaviour):

// Прямоугольная область. Вся геометрия формы — здесь (одна точка правды).
readonly struct Rect2D
{
    Vector2 Center;
    Vector2 Size;
    bool    Contains(Vector2 p);
    Vector2 Clamp(Vector2 p);        // ближайшая точка внутри
}
 
// Границы боевого поля (клампинг движения + «стены» отбрасывания).
readonly struct ArenaBounds
{
    Rect2D  Area;
    bool    Contains(Vector2 p);
    Vector2 Clamp(Vector2 p);
    Vector2 ClosestPoint(Vector2 p);
}
 
enum DeploymentSide { Player, Enemy }
enum DeploymentTier { Normal, Extended }
 
readonly struct DeploymentZone
{
    Rect2D         Area;
    DeploymentSide Side;
    DeploymentTier Tier;
}
 
// Неизменяемый снапшот арены, отдаётся в симуляцию и сервис расстановки.
sealed class ArenaLayoutData
{
    ArenaBounds                     Bounds;
    IReadOnlyList<DeploymentZone>   Zones;
}

Зоны: одно поле «арена» (ArenaBounds) разбито внутри на список зон расстановки. У каждой зоны — область, чья сторона, tier.

  • Normal — базовая зона своей стороны, доступна всем юнитам.
  • Extended — расширенная зона, доступна не всем: только юниту с правом (манёвренные/убийцы или выданный навык). Право — булев флаг RelicData.CanUseExtendedDeployment (источник данных чемпиона). Проверка — в сервисе расстановки, не в UI.
    • Отклонение от эскиза: флаг НЕ хранится на RuntimeUnit. Расстановка идёт до боя, RuntimeUnit тогда ещё не создан, и в самой симуляции этот флаг никто не читает — поле на рантайм-юните было бы мёртвым. Поэтому DeploymentService.CanPlace(pos, side, canUseExtended) принимает право как bool, а вызывающий (UI/обработчик команды) достаёт его из того, что ставит (RelicData при расстановке).

Разделение ответственности: арена задаёт зоны (области). Энкаунтер (EncounterData, см. 7. Флоу забега и сессия) задаёт, кто и где из врагов стоит внутри enemy-зоны. Player-зоны — куда игрок таскает своих.


4. Хранение контента: prefab-per-arena

Ориентир — 3-4 арены на старте, у каждой уникальный визуал + уникальная геометрия. Каждая арена — отдельный префаб, грузится по адресу (Addressables) в единственную боевую сцену.

Рассмотренные варианты

ВариантВердикт
Scene-per-arena (аддитивные сцены)Отклонён. Дублирует камеру/свет/DI-инфраструктуру, тяжелее грузится, боль в тестах. При 3-4 аренах выигрыша нет. Триггер пересмотра — только baked GI (см. §7).
Prefab-per-arena (Addressables)Выбран. Гизмо-авторинг работает в prefab-режиме, одна боевая сцена = один DI-scope, Addressables уже в стеке, тестируемость сохранена.
Чистый SO-data + общий визуалОтклонён. Зоны гизмами в SO не поредактируешь (нет трансформа и сцены под глазами).
Один префаб, свап задникаОтклонён. Геометрия у арен разная, не только картинка.

Анатомия арена-префаба

Arena_Forest (prefab)
├── ArenaLayoutAuthoring   // геометрия: bounds + DeploymentZone[]; рисует OnDrawGizmos. ВЕДЁТ.
│                          // BuildLayout() -> ArenaLayoutData (снапшот).
└── Visual                 // задник, декор, партиклы, свет, Volume (постпроцесс). СЛЕДУЕТ за геометрией.

Чистая сборка снапшота (Rect ArenaLayoutData) выносится в статику на примитивах — чтобы тест конструировал её без префаба и сцены.


5. Boot-flow боя

  1. EncounterData ключ арены ("arena_forest", Addressables) + список врагов.
  2. Addressables.LoadAssetAsync(key) Instantiate получаем ArenaLayoutAuthoring.
  3. authoring.BuildLayout() ArenaLayoutData (bounds + zones).
  4. Данные в CombatLifetimeScope: симуляция клампит движение по bounds; DeploymentService валидирует расстановку по zones.
  5. Фаза расстановки (до CombatSimulation.Tick): игрок свободно таскает юнитов; враги преднастроены из энкаунтера в enemy-зонах.
  6. Старт боя.
  7. После боя — Addressables.Release + Destroy инстанса арены.

6. Фаза расстановки (deployment)

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

Правило валидации:

CanPlace(unit, pos, side):
    pos внутри Normal-зоны стороны `side`
    ИЛИ pos внутри Extended-зоны стороны `side`, если у unit есть право на Extended

Кооп (host-authoritative): перетаскивание юнита — это команда хосту. Игрок тащит хост валидирует CanPlace применяет. Клиент себе не доверяет. Ещё один довод, почему правило живёт в логике (DeploymentService), а не в UI. См. 06. Сетевая модель — решение и план.


7. Интеграция с симуляцией

  • Клампинг движения. MovementSystem в конце тика приводит unit.Position = bounds.Clamp(unit.Position). DisplacementSystem (отбрасывание/«ядро», §9.9) тоже упирается в границу — это «стена» (клампит ДО расчёта сегмента «ядра», чтобы не задевать врагов за полем).
  • Часть сим-состояния. ArenaBounds детерминированы и одинаковы на всех клиентах. Расстановка задаёт стартовые Position, которые входят в ComputeChecksum() наравне с остальным.
  • Отклонение от эскиза (механизм передачи). CombatSimulation владеет ArenaBounds (ctor-параметр arena, дефолт ArenaBounds.Unbounded) и передаёт его в MovementSystem.Tick/DisplacementSystem.Tick параметром, а НЕ через ICombatContext.Arena. Причина: у ICombatContext 6-7 тестовых моков — расширение интерфейса сломало бы их все. Границы читаются и как CombatSimulation.Arena (для презентации/оверлея).

8. Свет, постпроцесс и вопрос сцен

Почти вся графика едет в префабе — сцена для неё не нужна:

  • Real-time свет (Light), постпроцесс (URP Volume + VolumeProfile), fog, партиклы, декали — всё это GameObject-based, кладётся в Visual арена-префаба. Каждая арена получает свою атмосферу, правится в prefab-режиме рядом с задником и зонами.
  • В один момент времени жива одна арена (грузим бой выгрузка), поэтому источники света и Volume между аренами не конфликтуют. Боевая сцена «пустая» по свету — Directional приходит из арены (нет двух «солнц»).

Единственный триггер миграции на аддитивные сцены — если понадобится baked GI / lightmaps или per-scene environment settings (ambient/skybox), которые привязаны к сцене, а не к префабу. Для 2D-спрайтового автобатлера это маловероятно.

Миграция при этом дешёвая, потому что арена изолирована: перенести содержимое корня в сцену и поменять способ загрузки (LoadAsset LoadScene). Boot-flow, симуляция, зоны — не трогаются. Поэтому платить за сцены превентивно не нужно.


9. Открытые вопросы

  • Анти-оверлап при свободной расстановке. Чтобы два юнита не встали в одну точку — минимальная дистанция/радиус при drop. Деталь фазы расстановки, решить при реализации §6.
  • Форма зон. Пока прямоугольники (Rect2D). Вся геометрия в Contains/Clamp — смена на полигон = одно место.
  • Владелец EncounterData и связь «энкаунтер ключ арены» — уточнить в 7. Флоу забега и сессия.
  • Подсветка Extended-зон в UI при перетаскивании манёвренного юнита — презентация, отдельная задача.

10. Порядок работ

ШагСодержаниеСтатус
1Rect2D + ArenaBounds (Contains/Clamp) в Guildmaster.Core.ArenaГотово. ArenaGeometryTests
2Клампинг в MovementSystem + DisplacementSystem (границы — ctor-параметр CombatSimulation)Готово. ArenaClampTests + регресс существующих слайс-тестов
3DeploymentZone/ArenaLayoutData + DeploymentService.CanPlace + флаг RelicData.CanUseExtendedDeploymentГотово. DeploymentServiceTests
4ArenaLayoutAuthoring (гизмо) + CombatLifetimeScope читает его и инъектит аренуКод готов; арена-префаб, адрес Addressables и визуал — за Максом

Шаги 1-3 — чистый headless-код с тестами (EditMode 184/184 на 2026-07-10). Шаг 4 — код авторинга готов; создание префаба, настройка Addressables, drag-drop UI и визуальная проверка — в сцене (зона Макса).

Осознанно НЕ сделано в этом срезе (не костыль, а граница объёма):

  • Хардкод стартовых позиций в GuildmasterCommands/BattleSetupBuilder НЕ выпиливался: замена требует источника арены (шаг 4, префаб) и полноценной фазы расстановки. Dev-спавны теперь просто клампятся арендой, если в сцене есть ArenaLayoutAuthoring.
  • Фаза расстановки как UI (drag-drop) и сетевые команды расстановки (host-authoritative) — отдельная задача; здесь только правило CanPlace + данные.
  • CombatLifetimeScope ищет ArenaLayoutAuthoring через FindFirstObjectByType (сцена-авторинг работает сегодня). Свап на загрузку арена-префаба через Addressables — будущий шаг, когда boot-флоу боя будет грузить арену по ключу энкаунтера.