Тайминг авто-атаки — 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 из неё убран полностью.
Проблема
Хотим, чтобы:
- Анимация атаки выглядела хлёстко на любой скорости атаки — быстрый юнит не превращал свинг в блюр, медленный не растягивал 7 кадров на 3 секунды (вяло/рвано). Свинг играет за фиксированное короткое время, остаток интервала юнит ждёт в idle.
- Урон авто-атаки наносился не в начале замаха, а на конкретном кадре анимации (напр. удар на 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, кламп к интервалу закрывает быстрый край, медленные юниты остаются хлёсткими.
MaxAttackAnimTicks(иMinWindupTicks) — сим-величины (из них считается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 | Поле |
|---|---|---|
| Как часто бьёт | RelicData | AttackSpeed в _stats |
| Кадр(ы) удара | UnitVisual | _attackHitFrames (0-based массив, Odin-клик по полоске) |
| Потолок длительности свинга | SimConstants | MaxAttackAnimTicks (глобально, int) |
| Мин. пол замаха (телеграф) | SimConstants | MinWindupTicks (глобально, int) |
| Темп Idle/Run/Death | UnitVisual | _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эдитор-кнопкой при изменении кадра. Работает, детерминизм цел, но добавляет шаг синхронизации и риск «забыл нажать».
Путь дизайнера (от «загрузил анимацию» до «работает в игре»)
- Импорт. Спрайт-лист атаки в проект, нарезка (пиксель-арт пресеты импорта уже настроены).
- Визуал. Открыть/создать
UnitVisual(Create → Guildmaster/.../Unit Visual), перетащить кадры в_attack. - Метка удара. В инспекторе
UnitVisual— Odin-полоска кадров: листаешь скраббером, кликаешь кадр контакта → пишется в_attackHitFrames(для Фазы 3 — один кадр; массив поддержан на будущее). - Связка с реликвией. Ссылка
_visualвRelicData(или существующий_visualOverrides). - Без ручной синхронизации. При сборке боя фабрика читает
hitFrame/frameCountвизуала и кладётwindupTicksв юнита. - Проверка в Play. Дев-харнесс: число урона и кадр контакта совпадают; свинг играет за фикс. время, остаток интервала — idle.
- Тюнинг. Момент удара — двигаешь
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).
Швы реализации (сигнатуры — в следующем чате)
- Data:
UnitVisual→ вGuildmaster.Data; новое поле_attackHitFrames(массив 0-based, отсортирован; рантайм Фазы 3 берёт[0]) + Odin-drawer полоски; (опц.) ссылка наRelicData/перенос связки из_visualOverrides. ГлобальныеMaxAttackAnimTicksиMinWindupTicks(int) вSimConstants. - Сим — таймеры на 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), удар не наносится, кулдаун НЕ потрачен (бьёт снова, как только сможет).
- Развязка
AutoAttackTargetvsCurrentTarget(предусловие). СейчасAutoAttackSystemчитаетunit.CurrentTarget, хотяRuntimeUnit.AutoAttackTargetпомечен «читает AutoAttackSystem» (для хилера цель авто-атаки ≠ цель движения). До добавления снапшота цели в замах — развести явно, какую ссылку бьёт авто-атака, и снапшотить именно её вWindupTarget. - События сима (хук для анимации + аудио/VFX):
OnAttackStarted(unit, target)— вход в замах (свинг во View + «вжух»-SFX);OnDamageDealt— конец замаха = кадр контакта (импакт-SFX + хит-спарк VFX; удалённый флинч можно вернуть как VFX здесь, а не спрайт-клипом);OnAttackInterrupted(unit)— отмена замаха (View рвёт свинг → idle/stunned, гасит звук замаха). Перевесить запуск анимации сOnDamageDealtнаOnAttackStarted(сейчасCombatPresenter.HandleDamageDealtдёргаетOnAttack()ПОСЛЕ урона — наоборот). - Дальнобой (снаряды):
hitFrame= кадр выпуска снаряда. На windup-endAutoAttackSystemспавнит снаряд (как сейчас, но из конца замаха, не из старта); время полёта добавляетProjectileSystemповерх windup. Снапшот цели/прерывание — те же правила, что для мили (снаряд уже в полёте после выпуска живёт по правиламProjectileSystem). - Фабрика: считает
WindupTicksизhitFrames[0]/frameCountвизуала реликвии +MaxAttackAnimTicks+MinWindupTicks+ интервала приCreate. - View: двухсегментный
StepFrames— кадры доhitFrameгонятся под фактическую длительность замаха (привязка кOnAttackStarted/WindupRemainingсима, чтобы контакт сел ровно наOnDamageDealt), кадры после — фоллоу-сру; затем idle до следующегоOnAttackStarted. Снять зависимость атаки от_fps; реагировать наOnAttackInterrupted.- Save/load и ресинк (критично):
OnAttackStarted— событие, при загрузке не переиграется. View при спавне/ресинке обязан реконструировать свинг из состояния: еслиIsWindingUp/WindupRemaining > 0— войти в свинг на кадре, соответствующем уже прошедшей части замаха (не ждать события).
- Save/load и ресинк (критично):
- Тесты: детерминизм 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-триггер отклонён.