Статус: 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). У него три роли:
- Тик-цикл —
Tick(dt)прогоняет системы в фиксированном порядке. ICombatContext— единственная «ручка», через которую системы и эффекты меняют мир (урон, хил, спавн снаряда, наложение эффекта).- Оркестратор — владеет списком юнитов, снарядов, очередью команд, очередью внутренних событий, шлёт 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— глобальное состояние, его дёргает кто угодно (партиклы, анимации), порядок непредсказуем → недетерминированно.- Наш
XorShiftRng— xorshift128, только целочисленные операции (одинаковы на любой платформе), состояние выводится из одного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/Vector2(аsqrt,normalizeи т.п. разнятся по платформам). Для host-authoritative это и не нужно (считает только хост). Если бы выбрали lockstep — пришлось бы переписать математику на fixed-point. Разбор этого размена — 06. Сетевая модель — решение и план.
Итог документа
- Бой — фиксированные тики 30 Гц; реальное время отделено от тикового accumulator-паттерном (с анти-лавина-капом).
CombatSimulation— чистый оркестратор: тик-цикл +ICombatContext+ команды + события.- Порядок систем за тик строго обоснован; мёртвые помечаются, не удаляются.
- Рандом — только
IRngService(детерминированный xorshift); команды — мутации на границе тика (основа сети/реплеев). - Детерминизм сейчас — однопроцессный; межмашинный потребовал бы fixed-point, но при host-authoritative не нужен.