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


«Тело» игры: как состояние симуляции превращается в картинку, и почему визуал плавный при том, что сим тикает всего 30 раз в секунду.

Связано с 00. Обзор и карта кода, 02. Детерминированная симуляция и тик, 01. Dependency Injection и события.


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

Симуляция считает бой 30 раз в секунду и держит «голые числа»: у юнита №5 позиция (3.2, 1.0) и 320 HP. Экран обновляется 60+ раз в секунду и должен показать это красиво: спрайт на месте, HP-бар, при ударе — цифра урона и вспышка.

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


2. CombatPresenter — мост сим→визуал

Assets/_Project/Scripts/Presentation/CombatPresenter.cs. MonoBehaviour, получает CombatSimulation и MessagePipe-паблишеры через [Inject]-метод (01. Dependency Injection и события).

Что делает:

  1. Подписывается на C#-события сима (OnUnitSpawned, OnUnitDied, OnDamageDealt, OnBattleEnded).
  2. На спавн — инстанцирует UnitView и привязывает к RuntimeUnit (Bind); кладёт в словарь id → view.
  3. На урон/смерть — дёргает вью (вспышка, цифра урона, анимация смерти).
  4. Ретранслирует каждое событие в MessagePipe (DamageDealtEvent…), чтобы Audio/VFX/UI подписывались независимо.
private void HandleUnitDied(RuntimeUnit unit) {
    if (_views.TryGetValue(unit.Id, out var view)) {
        view.OnDeath();
        _views.Remove(unit.Id);
    }
    _unitDiedPublisher.Publish(new UnitDiedEvent(unit));
}

Связь по unit.Id (а не по ссылке на объект) — потому что вью отслеживает юнита по стабильному идентификатору, и это переживёт будущую сетевую репликацию.


3. Интерполяция: 30 Гц сим → 60+ fps рендер

Если рисовать юнита ровно в его тиковой позиции, на 60 fps он будет «телепортироваться» раз в два кадра (сим обновляет позицию 30 раз/с, экран — 60). Чтобы движение было плавным, вью интерполирует между прошлой и текущей тиковой позицией.

Для этого RuntimeUnit хранит две позиции: PreviousPosition (где был на прошлом тике) и Position (где сейчас). MovementSystem в начале своего шага записывает PreviousPosition = Position, потом двигает Position. Вью рисует точку между ними:

визуальная позиция = lerp(PreviousPosition, Position, alpha)

где alpha ∈ [0,1] — «насколько мы продвинулись внутри текущего тика».

CombatPresenter.Update раздаёт alpha всем вью каждый кадр; UnitView.UpdateInterpolation(alpha) двигает спрайт.

🔎 Известная грубость (в техдолг, не критично). Сейчас alpha считается как Time.deltaTime / TickDelta, а это не корректная доля интерполяции — правильный alpha берётся из накопителя цикла (accumulator / TickDelta), который живёт в CombatLoopService. С текущей формулой движение сглажено, но не идеально точно по фазе. Чинить не срочно (косметика), но записано в 07. Техдолг, решения и changelog — правильное решение: пробросить дробную часть аккумулятора из loop в презентер.


4. Почему именно так

  • Read-only презентация = детерминизм цел, «тело» можно переписать (другой рендер, другой UI) не трогая «мозг».
  • Интерполяция вместо высокочастотного сима = дёшево. Считать бой 60 раз/с вдвое дороже, а визуально то же самое даёт интерполяция поверх 30 Гц.
  • Мост в MessagePipe = Audio/VFX/UI не знают про симуляцию; добавить новый подписчик (например, экранную статистику) можно, не трогая ни сим, ни презентер.

Это прямое следствие большого решения проекта — разделять симуляцию и презентацию. Именно оно позволяет «телу» быть толстым и «юнитёвым» (Feel-хуки, partikли, твины LitMotion), а «мозгу» оставаться чистым и сетевым. Подробнее о том, почему это решение правильное для нашего жанра — 06. Сетевая модель — решение и план.


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

  • Презентация только читает состояние сима; никогда его не меняет.
  • CombatPresenter — мост: подписка на C#-события → вью + ретрансляция в MessagePipe.
  • Плавность даёт интерполяция между PreviousPosition и Position (сим 30 Гц, рендер 60+).
  • Текущая формула alpha — грубая, помечена в техдолг (взять долю из аккумулятора loop).