Статус: 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 и события).
Что делает:
- Подписывается на C#-события сима (
OnUnitSpawned,OnUnitDied,OnDamageDealt,OnBattleEnded). - На спавн — инстанцирует
UnitViewи привязывает кRuntimeUnit(Bind); кладёт в словарьid → view. - На урон/смерть — дёргает вью (вспышка, цифра урона, анимация смерти).
- Ретранслирует каждое событие в 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).