Статус: Ready — отражает код на 2026-06-19


Сердце игры: как бой считается шаг за шагом, почему он «детерминированный» и зачем это нужно. От азов («что такое фиксированный тик») до тонкостей (очередь команд, пауза, checksum).

Связано с 00. Обзор и карта кода, 01. Dependency Injection и события, 06. Сетевая модель — решение и план, 02. Архитектура реализации — Фаза 1 (Боевое ядро).


1. Простыми словами

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

Детерминированный значит: при одинаковом входе — одинаковый выход, всегда. Один и тот же стартовый сид + одна и та же последовательность команд → бит-в-бит одинаковый бой, хоть сто раз. Это не «приятный бонус», а фундамент: на нём держатся пауза, реплеи, тесты и (в будущем) сетевой кооп.

Почему 30 тиков фиксированной длины, а не «считать по реальному времени кадра» (Time.deltaTime)? Потому что реальное время плавающее: один кадр 16 мс, другой 50 мс. Если считать физику боя по плавающему времени, результат зависит от FPS игрока — а это смерть детерминизма. Поэтому симуляция всегда шагает ровно на 1/30 секунды, сколько бы ни длился реальный кадр.


2. Разделение «реальное время → фиксированный тик»

Это ключевой приём. Есть два разных времени, и они аккуратно разведены:

  • Реальное время живёт ТОЛЬКО в CombatLoopService (Game-слой). Он один трогает Time.deltaTime.
  • Тиковое время живёт в CombatSimulation (Combat-слой). Он знает только SimConstants.TickDelta = 1/30 и понятия не имеет про кадры и Time.

Accumulator-паттерн

Assets/_Project/Scripts/Game/Services/CombatLoopService.cs

_accumulator += Time.deltaTime;   // накопили реальное время
 
int ticksThisFrame = 0;
while (_accumulator >= SimConstants.TickDelta
       && ticksThisFrame < SimConstants.MaxCatchUpTicksPerFrame)
{
    _simulation.Tick(SimConstants.TickDelta);   // шаг ровно 1/30
    _accumulator -= SimConstants.TickDelta;
    ticksThisFrame++;
    if (_simulation.Outcome != BattleOutcome.Ongoing) break;
}
 
// Упёрлись в кап, а долг ещё есть — отбрасываем остаток (анти-лавина).
if (ticksThisFrame >= SimConstants.MaxCatchUpTicksPerFrame
    && _accumulator > SimConstants.TickDelta)
{
    _accumulator = 0f;
}

Идея: копим реальное время в «копилку», и пока в ней набралось хотя бы на один тик — отшагиваем тик и вычитаем 1/30. Если кадр был долгим (например, 100 мс), за него отшагнём 3 тика подряд — бой «догонит» реальное время.

🔧 Анти-лавина — это недавняя правка ①. Раньше while не имел потолка. Представь, что кадр завис на полсекунды (сборка мусора, подгрузка). В копилке — 0.5 с = 15 тиков. Цикл прогоняет 15 тиков за один кадр, это само по себе долго → следующий кадр ещё длиннее → ещё больше тиков. «Спираль смерти»: игра проваливается в слайд-шоу и не вылезает. Кап MaxCatchUpTicksPerFrame = 5 ограничивает догон, а остаток долга отбрасывается. Цена — при жёстком лаге бой чуть «замедлится» относительно реального времени, но не уйдёт в штопор. Для PvE это правильный размен.


3. CombatSimulation — сердце

Assets/_Project/Scripts/Combat/CombatSimulation.cs. Это чистый C#-класс (никакого MonoBehaviour). У него три роли:

  1. Тик-циклTick(dt) прогоняет системы в фиксированном порядке.
  2. ICombatContext — единственная «ручка», через которую системы и эффекты меняют мир (урон, хил, спавн снаряда, наложение эффекта).
  3. Оркестратор — владеет списком юнитов, снарядов, очередью команд, очередью внутренних событий, шлёт C#-события наружу.

3.1 Порядок систем за тик

FlushPendingSpawns          — добавить юнитов из очереди спавна
ApplyDueCommands            — применить команды, чей TargetTick наступил
  ─ Targeting               — выбрать цель (ближайший враг)
  ─ Abilities               — кулдауны + каст готовых способностей
  ─ Movement                — двигаться к цели до дистанции атаки
  ─ SpatialHash.Rebuild     — перестроить пространственный индекс
  ─ AutoAttack              — атаковать/спавнить снаряды по кулдауну
  ─ Projectiles             — двигать снаряды, проверять попадания (swept)
  ─ Regen                   — регенерация HP
  ─ Effects                 — тик эффектов: периодика, длительность, контроль
  ─ DrainEventQueue         — доставить внутренние боевые события (шипы/вампиризм)
  ─ Death                   — пометить мёртвых (HP ≤ 0), убрать из хэша
CheckOutcome                — победа/поражение/ничья
currentTick++

Почему порядок именно такой — он не случаен:

  • Цель выбирается до движения (иначе двигались бы к старой/мёртвой цели).
  • SpatialHash перестраивается после движения (позиции уже новые) и до атак/снарядов (которые им пользуются).
  • Regen стоит перед Effects, чтобы реген не «вытаскивал» юнита из летального DoT задним числом (и у RegenSystem есть гард CurrentHP > 0).
  • Death — в самом конце: сначала весь урон тика (включая отложенные события шипов), потом единая обработка смертей.

Мёртвые юниты не удаляются из списка _units в течение боя — только помечаются IsDead, и каждая система их пропускает. Это упрощает итерацию (список не мутирует под ногами). Удаление из SpatialHash происходит сразу при смерти.

3.2 ICombatContext — шов мутаций

Assets/_Project/Scripts/Combat/ICombatContext.cs. Системы и компоненты эффектов не лезут в состояние боя напрямую — они зовут методы контекста:

void DealDamage(in DamageRequest req);
void Heal(RuntimeUnit target, float amount, RuntimeUnit source);
void SpawnProjectile(in ProjectileSpawn spawn);
int  QueryUnitsInRadius(Vector2 center, float radius, List<RuntimeUnit> results, …);
void ApplyEffect(RuntimeUnit target, EffectData def, RuntimeUnit source);
void Dispel(in DispelRequest req);
IRngService Rng { get; }  int CurrentTick { get; }  float ArmorK { get; }

CombatSimulation реализует этот интерфейс. Зачем интерфейс, если реализация одна? Потому что компоненты эффектов (в Combat) должны мочь влиять на мир, но не должны знать про конкретный класс CombatSimulation и не должны мутировать его поля. Контекст — это узкий контракт влияния, который ещё и легко замокать в тесте.

3.3 RNG — единственный источник случайности

Assets/_Project/Scripts/Core/Random/XorShiftRng.cs. Любой рандом в бою идёт только через IRngService. Почему свой генератор, а не UnityEngine.Random?

  • UnityEngine.Random — глобальное состояние, его дёргает кто угодно (партиклы, анимации), порядок непредсказуем → недетерминированно.
  • Наш XorShiftRngxorshift128, только целочисленные операции (одинаковы на любой платформе), состояние выводится из одного ulong-сида через SplitMix64 (даже сид 0 даёт хорошо размешанное состояние).
  • Snapshot() отдаёт 64-битный отпечаток состояния — для checksum (см. §5).

3.4 Команды — мутации на границе тика

Assets/_Project/Scripts/Combat/ICombatCommand.cs + Commands/*. Всё, что меняет бой «снаружи» (пауза, спавн юнита), оформлено как команда с целевым тиком:

public interface ICombatCommand : ISimCommand {
    int TargetTick { get; }
    void Apply(CombatSimulation sim);
}

EnqueueCommand вставляет команду в очередь, отсортированную по TargetTick. В начале тика ApplyDueCommands применяет все команды, чей TargetTick ≤ currentTick.

Зачем эта церемония вместо прямого вызова sim.Pause()? Это — фундамент сетевого кооператива и реплеев. Команда с тиком означает «применить ровно на этом шаге». Клиент шлёт намерение → хост назначает тик → применяется детерминированно. Командный слой остаётся keeper при host-authoritative модели (см. 06. Сетевая модель — решение и план); меняется только сетевая обвязка вокруг него.

3.5 Пауза — тонкое место

Пауза реализована нетривиально, и это сознательно: бой ставится на паузу командой PauseCommand, но пауза, поданная на тике N, вступает в силу со следующего тика (текущий досимулируется). А пока бой на паузе, счётчик currentTick продолжает идти, если в очереди есть команды — иначе будущий ResumeCommand со своим TargetTick никогда бы не наступил и бой завис бы навсегда. Это закомментировано прямо в коде Tick() — если будешь трогать паузу, читай комментарии там очень внимательно.


4. RuntimeUnit — юнит на один бой

Assets/_Project/Scripts/Combat/Units/RuntimeUnit.cs. POCO (Plain Old C# Object) — никакого MonoBehaviour/ScriptableObject. Хранит изменяемое состояние боя: HP, щит, позицию (и PreviousPosition для интерполяции вида), цель, кулдаун атаки, активные эффекты, флаги контроля.

Почему публичные изменяемые поля, а не свойства с инкапсуляцией? Это сознательный размен в пользу скорости и простоты (Data-Oriented стиль): системы много раз за тик читают/пишут эти поля. Комментарии в коде фиксируют дисциплину («мутирует только такая-то система»), но компилятор её не навязывает. Цена — нужна аккуратность; выгода — нет аллокаций и геттеров на горячем пути.

Создаётся RuntimeUnit только через RuntimeUnitFactory (единая точка сборки из SO: дефолты статов → моды реликвии → таланты → пассивки → способности → CurrentHP = MaxHP). Порядок сборки важен — например, пассивки на +MaxHP применяются до инициализации CurrentHP, иначе юнит стартовал бы не с полным здоровьем.


5. Checksum — детектор рассинхрона

ComputeChecksum() сворачивает состояние (тик, снимок RNG, позиции и HP всех юнитов) в один ulong. Изначально задумывался для lockstep-сети (сравнивать слепки хоста и клиента).

⚠️ Статус при host-authoritative модели. Поскольку мы выбрали host-authoritative (считает только хост, см. 06. Сетевая модель — решение и план), сетевое сравнение checksum не используется — инструмент SimSyncProbe, который этим занимался, запаркован в Net/_Parked/. Но сам ComputeChecksum остаётся полезным для одиночных целей: тесты детерминизма (CombatSimulationTests сравнивает checksum двух прогонов с одним сидом) и отладка реплеев. Поэтому метод — keeper, а сетевая проба — нет.


6. Детерминизм: что важно помнить

  • Любой рандом — только через IRngService. Никаких UnityEngine.Random, DateTime.Now, Guid.NewGuid в логике боя.
  • Никаких Time.deltaTime в Combat — только SimConstants.TickDelta.
  • Никакой зависимости от порядка итерации хэш-структур, влияющего на результат.
  • Периодика эффектов считается целыми тиками (int-счётчики), а не накоплением float-времени — float-аккумулятор дрейфует и ломает детерминизм.

Тонкость на будущее (важно для сети). Текущий детерминизм — это воспроизводимость на одной машине (целочисленный RNG, фиксированный тик). Бит-в-бит совпадение между разными машинами мы НЕ гарантируем, потому что вся боевая математика на float/Mathf/Vector2sqrt, normalize и т.п. разнятся по платформам). Для host-authoritative это и не нужно (считает только хост). Если бы выбрали lockstep — пришлось бы переписать математику на fixed-point. Разбор этого размена — 06. Сетевая модель — решение и план.


Итог документа

  • Бой — фиксированные тики 30 Гц; реальное время отделено от тикового accumulator-паттерном (с анти-лавина-капом).
  • CombatSimulation — чистый оркестратор: тик-цикл + ICombatContext + команды + события.
  • Порядок систем за тик строго обоснован; мёртвые помечаются, не удаляются.
  • Рандом — только IRngService (детерминированный xorshift); команды — мутации на границе тика (основа сети/реплеев).
  • Детерминизм сейчас — однопроцессный; межмашинный потребовал бы fixed-point, но при host-authoritative не нужен.