Тайминг авто-атаки — windup + кадр удара (дизайн)

2026-06-01. Дизайн-заметка, зафиксированная перед реализацией (обсуждение в чате). Код ещё не написан — это согласованная идея, причины и решённые развилки для продолжения в новом чате. Связано с Фазой 3 (доку 13, вертикальный срез «Железный копейщик») и развязкой анимации S4.

Ревизия 2026-06-01 (вечер): модель тайминга пересмотрена — вместо пропорциональной выбрана фикс. длительность свинга с клампом (см. ниже); 4 развилки (цель в замахе / дальнобой / тип таймера / первый удар+прерывание) — решены.

Ревизия 2026-06-01 (ревью кода): сверено с реальным боевым ядром (AutoAttackSystem, CombatSimulation, RuntimeUnit, SimConstants). Закрыто 5 критичных пробелов (целочисленная формула без float, порядок систем на тике контакта, View при save/load, windup-таймер при стане, развязка AutoAttackTarget/CurrentTarget) и решены 4 развилки: рут на весь замах, windup — геймплейный телеграф (с мин. полом), _attackHitFrames — массив на будущее (рантаймом резолвим один кадр), MaxAttackAnimTicks в тиках (int) в SimConstants. Формула windup переведена на целочисленную арифметику — float из неё убран полностью.

Проблема

Хотим, чтобы:

  1. Анимация атаки выглядела хлёстко на любой скорости атаки — быстрый юнит не превращал свинг в блюр, медленный не растягивал 7 кадров на 3 секунды (вяло/рвано). Свинг играет за фиксированное короткое время, остаток интервала юнит ждёт в idle.
  2. Урон авто-атаки наносился не в начале замаха, а на конкретном кадре анимации (напр. удар на 5-м кадре из 7).

При этом не сломав детерминизм боевого ядра.

Почему детерминизм не страдает (исходная развязка)

Граница уже заложена и её НЕ трогаем:

  • Сим тикает фиксированным шагом TickDelta = 1/30 (SimConstants), Time.deltaTime живёт только в CombatLoopService.
  • View листает кадры по рендер-времени и никогда не пишет в сим (UnitView, инвариант S4). Урон уже нанесён на тике — кадр косметичен.

Правило: урон наносит только сим, по счётчику тиков. Анимация — наблюдатель.

Ключевое архитектурное решение: windup в симе

Авто-атака становится двухфазной внутри сима (Вариант A):

  • Когда кулдаун дошёл до нуля и цель в радиусе — юнит входит в замах (windup) на N тиков.
  • Урон наносится, когда windup-таймер истёк (attackStartTick + windupTicks).
  • Это просто ещё один tick-таймер рядом с AttackCooldown → детерминизм сохраняется, пауза работает автоматически. (AttackCooldown сейчас float на фикс. dt; в рамках этой работы оба переводим на int-тики — единые единицы, см. швы §2.)

Новое событие из сима OnAttackStarted(unit, ...) (фейрится при входе в замах) запускает анимацию замаха во view; существующее OnDamageDealt (в конце замаха) совпадает с кадром удара. Раньше анимацию запускал OnDamageDealt — теперь этого мало, т.к. урон уехал в конец.

Отклонённые альтернативы

  • Animator + Animation Event как рантайм-триггер урона — НЕТ. Event срабатывает на рендер-времени, frame-rate-зависим, и в мультиплеере у каждого клиента свой Animator → урон падает в разные моменты → рассинхрон чек-суммы. Animator для кадрового пиксель-арта к тому же оверкилл (у нас уже чистый кадровый степпер).
  • Animation Event как «метка», считываемая в данные — допустимо, но не выбрано. Метка-данные ≠ рантайм-обработчик (это безопасно). Но Event живёт на AnimationClip, которого у нас нет (атаки — это Sprite[]). Вместо этого делаем удобную метку через Odin прямо на Sprite[].

Источник истины: кадр(ы) контакта

Дизайнер авторит индексы ударных кадров — _attackHitFrames (0-based, «момент, когда этот кадр появляется») в SO визуала, кликами по Odin-полоске кадров. Всё остальное выводится:

  • доля удара = hitFrame / frameCount (см. модель ниже);
  • сим из доли и длительности свинга считает windupTicks.

Форма данных vs рантайм (решение «мультикадр на будущее»). Поле — массив (_attackHitFrames, отсортированный), чтобы будущие релики (двойной удар, серия) не ломали формат SO. Но рантайм Фазы 3 резолвит ровно один контакт — берётся первый элемент (hitFrames[0]); линейный cleave (AreaShape.Line) уже бьёт многих на одном кадре, этого достаточно для вертикального среза. Полноценный мульти-резолв (несколько damage-тиков внутри одного свинга) — отдельный шаг позже; до него массив с одним элементом эквивалентен старому единственному числу.

Свинг = атомарная атака (решение для мульти-резолва). Когда дойдёт до серии в одной анимации: кулдаун и прерывание считаются на весь свинг, не на каждый кадр. Якорь кулдауна — старт замаха (как и для одиночного удара); прерывание (стан/смерть/потеря CanAct) в любой момент свинга отменяет всю оставшуюся серию — уже нанесённые контакты остаются, последующие hitFrames не срабатывают, кулдаун НЕ потрачен. То есть «серия» — это один логический заход атаки с несколькими damage-тиками внутри, а не N независимых атак.

Модель тайминга: ФИКС. ДЛИТЕЛЬНОСТЬ СВИНГА + КЛАМП (выбрана), ЦЕЛОЧИСЛЕННАЯ

Есть глобальная константа MaxAttackAnimTicks (int, в сим-тиках; напр. 30 = 1с при TickRate 30), общая для всех юнитов. Свинг всегда играет за фиксированное короткое время; остаток интервала юнит ждёт в idle. При очень быстрой атаке длительность ужимается до интервала, чтобы свинг не наезжал сам на себя.

Вся формула — на int-тиках, без float (детерминизм без ловушки округления, см. ниже):

intervalTicks      = max(1, round(TickRate / AttackSpeed))   // ЕДИНСТВЕННОЕ округление (конверсия AttackSpeed)
attackDurationTicks = min(MaxAttackAnimTicks, intervalTicks)
windupTicks        = (hitFrame * attackDurationTicks) / frameCount   // целочисленное деление = floor
windupTicks        = clamp(windupTicks, MinWindupTicks, intervalTicks − 1)

Почему без float (критичная правка). Mathf.RoundToInt / Math.Round в .NET по умолчанию — banker’s rounding (к чётному). Если бы формула считала через float (MaxAttackAnimSeconds, доли), округлять пришлось бы в трёх местах (интервал, длительность свинга, сам windup) — три почти-одинаковых округления = классический источник рассинхрона чек-суммы между сборками/платформами. Целочисленная форма оставляет ровно одно округление — конверсию AttackSpeed → intervalTicks (AttackSpeed — float-стат); дальше всё int. Округление этой конверсии зафиксировать явным MidpointRounding в тестах.

Пол intervalTicks ≥ 1 закрывает край «очень высокий AttackSpeed»: без него intervalTicks мог стать 0 → пустой диапазон клампа […, −1] и деление на ноль в производных.

MinWindupTicks (мин. пол, глобальная int-константа рядом с MaxAttackAnimTicks): windup — геймплейный телеграф (решение ниже), а не косметика. У тяжёлого удара с маленьким hitFrame/frameCount без пола замах ≈ 0 → телеграфа и окна прерывания нет. Пол гарантирует минимальную «читаемость удара».

Два режима (в тиках; TickRate 30, MaxAttackAnimTicks 30):

  • Медленный юнит (интервал > max). Пример: интервал 90 тиков (3с), hitFrame/frameCount = 5/7. Свинг играет за 30 тиков (attackDurationTicks), удар на тике 5×30/7 = 21 (~0.71с), затем 60 тиков idle, затем снова. Анимация хлёсткая, не растянутая.
  • Быстрый юнит (интервал < max). Пример: интервал 15 тиков (0.5с). attackDurationTicks = 15, свинг ужимается, idle нет. Кламп min(...) закрывает краевой случай «интервал короче свинга» — без него анимация не успевала бы доиграть.

Якорь кулдауна: кулдаун рестартится в начале замаха (свинг «внутри» интервала). Период damage→damage = ровно интервал; windup не добавляется к интервалу.

Следствие — _fps не нужен для атаки. Длительность задаёт attackDurationTicks, а тайминг урона — hitFrame / frameCount (fps в уравнении отсутствует). Сим для windup читает только hitFrame + frameCount (= _attack.Length) + глобальный MaxAttackAnimTicks. _fps остаётся только для Idle / Run / Death — клипов без геймплейной каденции.

Почему не две отклонённые модели

  • Пропорциональная (длительность клипа = интервал, клип растянут под 1/AttackSpeed): медленный тяжёлый юнит растягивает 7 кадров на 3с (~0.47с/кадр) — рвано и вяло; быстрый — блюр. Не даёт стабильного «хлёсткого» фида.
  • Абсолютная (windup = hitFrame / fps, замах фикс по авторскому fps спрайтов): fps становится load-bearing для тайминга урона, нужен per-clip кламп при высокой скорости, и темп зависит от того, как дизайнер нарезал кадры.
  • Выбранная берёт лучшее: один глобальный рычаг MaxAttackAnimTicks, без load-bearing fps, кламп к интервалу закрывает быстрый край, медленные юниты остаются хлёсткими.

MaxAttackAnimTicksMinWindupTicks) — сим-величины (из них считается windupTicks), живут в SimConstants (Core) как int-константы. View читает их же, чтобы знать, за сколько проиграть свинг и когда уйти в idle. Возможен per-visual override в будущем, пока — глобально.

Дизайн-ценность постоянной длины телеграфа. Глобальный потолок означает, что и медленный тяжёлый молотобоец, и быстрый разбойник заводят оружие за одно и то же wall-clock время. В паузо-реалтайме, где игрок парсит десятки юнитов одновременно (тем более в кооперативе), постоянная длина свинга = читаемость поля боя: «занос оружия» всегда занимает узнаваемый отрезок времени. Это прямой аргумент за глобальность (а не per-fps/per-clip).

Топология SO (где что настраивается)

«SO юнита» у нас = RelicData (SO «Чемпион»). Он хранит ссылку на UnitVisual (SO визуала):

SimConstants
├── MaxAttackAnimTicks        ← глобальный потолок длительности свинга (int-тики, ≈30 = 1с)
└── MinWindupTicks            ← мин. пол замаха = гарантия телеграфа (int-тики)

RelicData  (SO «Чемпион»)
├── _stats → AttackSpeed         ← КАК ЧАСТО бьёт (геймплей)
└── _visual → UnitVisual  (SO визуала)
              ├── _attack : Sprite[]    ← кадры атаки
              ├── _attackHitFrames      ← НА КАКИХ КАДРАХ урон (массив, рантайм берёт [0])  ← новая ручка
              └── _fps                  ← темп Idle/Run/Death (для атаки не используется)
Что настраиваешьВ каком SOПоле
Как часто бьётRelicDataAttackSpeed в _stats
Кадр(ы) удараUnitVisual_attackHitFrames (0-based массив, Odin-клик по полоске)
Потолок длительности свингаSimConstantsMaxAttackAnimTicks (глобально, int)
Мин. пол замаха (телеграф)SimConstantsMinWindupTicks (глобально, int)
Темп Idle/Run/DeathUnitVisual_fps

Связка чемпион → визуал: сейчас живёт в CombatPresenter._visualOverrides (Presentation). Предлагается перенести ссылку в сам RelicData, чтобы было «SO юнита хранит SO визуала» и фабрика боя могла прочитать relic.Visual.AttackHitFrames[0].

Граница asmdef — почему встаёт вопрос переноса UnitVisual

  • Guildmaster.Combat ссылается только на Core + Data.
  • Guildmaster.Presentation ссылается на Combat + Data.
  • UnitVisual лежит в Presentationсим физически не может его прочитать (была бы циклическая ссылка).

Значит число, по которому сим считает урон, обязано быть доступно Combat (т.е. в Guildmaster.Data). Два пути:

  • (Рекомендуется) Перенести UnitVisual в Guildmaster.Data. Это чистый контент (Sprite[] + числа, без поведения/Presentation-типов). Тогда: один источник истины, Odin-превью работает там же, фабрика сама считает windupTicks, никакого бейка и инверсии зависимостей.
  • (Альтернатива) Оставить в Presentation + запекать windup в RelicData эдитор-кнопкой при изменении кадра. Работает, детерминизм цел, но добавляет шаг синхронизации и риск «забыл нажать».

Путь дизайнера (от «загрузил анимацию» до «работает в игре»)

  1. Импорт. Спрайт-лист атаки в проект, нарезка (пиксель-арт пресеты импорта уже настроены).
  2. Визуал. Открыть/создать UnitVisual (Create → Guildmaster/.../Unit Visual), перетащить кадры в _attack.
  3. Метка удара. В инспекторе UnitVisual — Odin-полоска кадров: листаешь скраббером, кликаешь кадр контакта → пишется в _attackHitFrames (для Фазы 3 — один кадр; массив поддержан на будущее).
  4. Связка с реликвией. Ссылка _visual в RelicData (или существующий _visualOverrides).
  5. Без ручной синхронизации. При сборке боя фабрика читает hitFrame/frameCount визуала и кладёт windupTicks в юнита.
  6. Проверка в Play. Дев-харнесс: число урона и кадр контакта совпадают; свинг играет за фикс. время, остаток интервала — idle.
  7. Тюнинг. Момент удара — двигаешь hitFrame; частота — AttackSpeed в реликвии; общую «хлёсткость» свинга — MaxAttackAnimTicks глобально; минимум телеграфа — MinWindupTicks. Рычаги независимы.

Порядок систем и тик контакта (КРИТИЧНО — сверено с кодом)

Реальный порядок за тик (CombatSimulation.Tick): ApplyCommands → Brain → Ability → Movement → SpatialHashRebuild → AutoAttack → Projectiles → Effects → DrainEvents → Death → CheckOutcome → tick++. Из этого вытекают два правила, которые windup обязан соблюдать:

  • Стан применяется в Effects, т.е. ПОСЛЕ AutoAttack. Значит прерывание замаха проверяется по CanAct, посчитанному на прошлом тике: стан, прилетевший на тике T, отменяет замах только с тика T+1, а удар, чей windup истёк на T, на T ещё проходит. Это допустимо (1 тик = 33 мс) и должно быть явно записано, иначе будет выглядеть багом.
  • Death — последняя система. Юнит со смертельным уроном, но ещё не помеченный IsDead (флаг ставит DeathSystem в конце), доигрывает свой windup и наносит удар на том же тике. Следствие для контрплея: «убить врага в замахе» прерывает его удар только если урон-убийца сел на более раннем тике, чем резолв. Это и есть окно телеграфа — не баг, а механика (см. развилку про телеграф).
  • Movement идёт ДО AutoAttack → рут на время замаха реализуется флагом, который читает MovementSystem (см. §2).

Швы реализации (сигнатуры — в следующем чате)

  1. Data: UnitVisual → в Guildmaster.Data; новое поле _attackHitFrames (массив 0-based, отсортирован; рантайм Фазы 3 берёт [0]) + Odin-drawer полоски; (опц.) ссылка на RelicData/перенос связки из _visualOverrides. Глобальные MaxAttackAnimTicks и MinWindupTicks (int) в SimConstants.
  2. Сим — таймеры на int-тиках:
    • AttackCooldown перевести с float-секунд на int-тики (заодно с windup, единые единицы; сейчас float, вычитается фикс. dt в AutoAttackSystem). Сейчас кулдаун не тикает при стане (if (!unit.CanAct) continue; ДО декремента) — это поведение сохраняем.
    • RuntimeUnit.WindupRemaining (int) + хранимый WindupTicks (посчитан раз на старте замаха, не пересчитывается при смене AttackSpeed в полёте) + RuntimeUnit.WindupTarget (снапшот цели на старте — см. ниже про AutoAttackTarget).
    • Целочисленная формула (без float): windupTicks = clamp( (hitFrame * attackDurationTicks) / frameCount, MinWindupTicks, intervalTicks − 1 ), где intervalTicks = max(1, round(TickRate / AttackSpeed)), attackDurationTicks = min(MaxAttackAnimTicks, intervalTicks). Целочисленное деление = floor; единственное округление — конверсия AttackSpeed, фиксируется явным MidpointRounding в тестах. Кламп ≤ intervalTicks−1 исключает совпадение удара с тиком старта следующей атаки.
    • Двухфазный AutoAttackSystem: кулдаун дошёл до 0 + цель в радиусе → вход в замах (кулдаун рестартится здесь = якорь), снапшот цели в WindupTarget на старте; на нуле windup → разрешение удара.
    • Рут на время замаха: на старте замаха юнит обездвижен — флаг (напр. IsWindingUp), который уважает MovementSystem. Свинг играет на месте; facing фиксируется на старте. Мили-whiff, если цель вышла из радиуса.
    • Windup-таймер при стане: замах сбрасывается при потере CanAct (а не замораживается) — см. прерывание. (Согласуется с тем, что кулдаун при стане не тикает.)
    • Цель пропала к удару (мертва / вне радиуса / сменилась): урон не наносится (вхолостую), кулдаун уже потрачен.
    • Прерывание (стан/смерть/потеря CanAct в замахе): замах сбрасывается (WindupRemaining, IsWindingUp), удар не наносится, кулдаун НЕ потрачен (бьёт снова, как только сможет).
  3. Развязка AutoAttackTarget vs CurrentTarget (предусловие). Сейчас AutoAttackSystem читает unit.CurrentTarget, хотя RuntimeUnit.AutoAttackTarget помечен «читает AutoAttackSystem» (для хилера цель авто-атаки ≠ цель движения). До добавления снапшота цели в замах — развести явно, какую ссылку бьёт авто-атака, и снапшотить именно её в WindupTarget.
  4. События сима (хук для анимации + аудио/VFX): OnAttackStarted(unit, target) — вход в замах (свинг во View + «вжух»-SFX); OnDamageDealt — конец замаха = кадр контакта (импакт-SFX + хит-спарк VFX; удалённый флинч можно вернуть как VFX здесь, а не спрайт-клипом); OnAttackInterrupted(unit) — отмена замаха (View рвёт свинг → idle/stunned, гасит звук замаха). Перевесить запуск анимации с OnDamageDealt на OnAttackStarted (сейчас CombatPresenter.HandleDamageDealt дёргает OnAttack() ПОСЛЕ урона — наоборот).
  5. Дальнобой (снаряды): hitFrame = кадр выпуска снаряда. На windup-end AutoAttackSystem спавнит снаряд (как сейчас, но из конца замаха, не из старта); время полёта добавляет ProjectileSystem поверх windup. Снапшот цели/прерывание — те же правила, что для мили (снаряд уже в полёте после выпуска живёт по правилам ProjectileSystem).
  6. Фабрика: считает WindupTicks из hitFrames[0]/frameCount визуала реликвии + MaxAttackAnimTicks + MinWindupTicks + интервала при Create.
  7. View: двухсегментный StepFrames — кадры до hitFrame гонятся под фактическую длительность замаха (привязка к OnAttackStarted/WindupRemaining сима, чтобы контакт сел ровно на OnDamageDealt), кадры после — фоллоу-сру; затем idle до следующего OnAttackStarted. Снять зависимость атаки от _fps; реагировать на OnAttackInterrupted.
    • Save/load и ресинк (критично): OnAttackStarted — событие, при загрузке не переиграется. View при спавне/ресинке обязан реконструировать свинг из состояния: если IsWindingUp/WindupRemaining > 0 — войти в свинг на кадре, соответствующем уже прошедшей части замаха (не ждать события).
  8. Тесты: детерминизм windup (EditMode); граничные hitFrame (0 / последний / пустой массив); пол MinWindupTicks срабатывает при крошечном hitFrame/frameCount; пол intervalTicks ≥ 1 при очень высоком AttackSpeed; кламп при интервале < MaxAttackAnimTicks (быстрый юнит); пересчёт под разные AttackSpeed; цель умерла/вышла из радиуса в замахе → урона нет, кулдаун потрачен; прерывание стаганом → урона нет, кулдаун цел; смена AttackSpeed в замахе не двигает текущий WindupTicks; рут — юнит не двигается в замахе; «убийство в замахе на раннем тике прерывает, на тике резолва — нет» (порядок систем); save/load посреди замаха восстанавливает WindupRemaining/WindupTarget; чек-сумма с новыми полями (WindupRemaining/WindupTicks).

Решённые развилки

#ВопросРешение (2026-06-01)
1Перенос UnitVisual в Data vs эдитор-бейкПеренести в Guildmaster.Data (рекомендация; финально подтвердить при коде). Чистый контент — без инверсии зависимостей и шага синхронизации.
2Модель таймингаФикс. MaxAttackAnimTicks + кламп к интервалу, целочисленная (заменила пропорциональную; float убран из формулы).
3Цель пропала в замахеСнапшот цели на старте; мимо = вхолостую, кулдаун потрачен.
4ДальнобойВходит в дизайн (все релики Фазы 3): hitFrame = выпуск снаряда, полёт поверх.
5Тип таймераint-тики, WindupTicks раз на старте; AttackCooldown тоже на тики.
6Первый ударС замахом (юнит сначала заносит оружие — честный телеграф).
7Прерывание (стан/смерть)Удар пропадает, кулдаун НЕ потрачен; windup-таймер сбрасывается (не замораживается).
8Рут в замахеДа, рут на весь windup (флаг уважает MovementSystem); свинг на месте, мили-whiff если цель ушла.
9Рамка windupГеймплейный телеграф, не косметика: окно прерывания и задержка первого удара — намеренный контрплей. Мин. пол MinWindupTicks гарантирует телеграф.
10Мультикадр контактаФорма данных — массив _attackHitFrames (на будущее); рантайм Фазы 3 резолвит [0]. Мульти-резолв — отдельный шаг позже. Свинг = атомарная атака: кулдаун/прерывание на всю серию, не на кадр; прерывание отменяет остаток серии, кулдаун не потрачен.
11Где/в чём MaxAttackAnimTicksВ тиках (int), в SimConstants (рядом — MinWindupTicks). Убирает float и тройное округление.

Зафиксировано: двухфазный windup в симе (int-тики, целочисленная формула без float); источник истины — _attackHitFrames (массив 0-based, рантайм берёт [0]); модель «фикс. длительность свинга + кламп»; рут на весь замах; windup = телеграф (мин. пол MinWindupTicks); _fps только для не-атакующих клипов; контакт View прибит к damage-тику; порядок систем определяет окно прерывания (стан/смерть видны со следующего тика); save/load реконструирует свинг из состояния; Animator/Event-триггер отклонён.