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


Самая интрикатная подсистема: как работают баффы/дебаффы/DoT/щиты/шипы/контроль. Полиморфизм через [SerializeReference], разделение stateless-поведение ↔ per-unit-состояние, реактивные события с защитой от зацикливания.

Связано с 03. Данные, статы и урон, 02. Детерминированная симуляция и тик, 04. Архитектура реализации — Фаза 2 (Эффекты + способности), 6. Боевая модель (юниты, статы, эффекты).


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

Эффект — это «что-то навешенное на юнита на время»: яд, ускорение, щит, оглушение, шипы. Вместо того чтобы плодить классы PoisonEffect, ShieldEffect, StunEffect…, мы собираем эффект из кубиков поведения (компонентов):

  • эффект «Ядовитый щит» = [PeriodicDamage] + [Shield];
  • эффект «Оглушающие шипы» = [Thorns] + [Control(оглушение)].

Дизайнер в редакторе берёт EffectData, выставляет длительность/стаки/теги и накидывает список компонентов из дропдауна. Новое поведение = новый компонент, а не новый тип эффекта. Это композиция вместо наследования.


2. Полиморфизм через [SerializeReference]

Assets/_Project/Scripts/Data/Definitions/EffectData.cs:

[SerializeReference] private IEffectComponent[] _components;

[SerializeReference] — это Unity-механизм, который умеет сериализовать полиморфный список: в массиве лежат разные конкретные типы (ThornsComponent, ShieldComponent…), а поле типизировано интерфейсом. Odin Inspector рисует дропдаун выбора типа.

Кросс-сборочный шов (важный приём)

Вспомни граф сборок: Data ниже Combat, ссылаться вверх нельзя (00. Обзор и карта кода). Но эффекту-данным нужно хранить поведение, которое оперирует боевым состоянием (RuntimeUnit, контекст) — а это Combat. Как?

Data:    interface IEffectComponent { }                  ← пустой маркер (якорь сериализации)
                     ▲
Combat:  interface IRuntimeEffectComponent : IEffectComponent {
             void OnApply(in EffectContext ctx);
             void OnExpire(in EffectContext ctx);
         }
Combat:  class ThornsComponent : IReactiveComponent { … }  ← реальное поведение

EffectDataData) хранит IEffectComponent[] — знает только пустой маркер. А лежат там Combat-классы. Хуки жизненного цикла объявлены в Combat, где доступны боевые типы. Так зависимость не идёт вверх, а полиморфизм работает. Это подтверждённый «спайк S1» из доков фазы.

Семейство интерфейсов компонентов

Assets/_Project/Scripts/Combat/Effects/IRuntimeEffectComponent.cs:

ИнтерфейсХукДля чего
IRuntimeEffectComponentOnApply / OnExpireБаза: наложение и снятие (щит, стат-моды)
IPeriodicComponentOnTick каждые Interval секDoT / HoT / реген по тикам
IReactiveComponentOnEvent(событие)Вампиризм, шипы — реагируют на урон/хил
IScalablePotencyсвойство PotencyОбъявляет, что сила компонента скейлится от статов источника

3. Stateless-поведение ↔ per-unit-состояние

Это центральный принцип, легко споткнуться. Компонент один на всех носителей эффекта (он лежит в общем SO). Значит компонент обязан быть stateless — он не может хранить «сколько у этого юнита осталось щита». Иначе сто юнитов затрут состояние друг друга.

Где же тогда живёт per-unit-состояние? В RuntimeEffect — экземпляре эффекта на конкретном юните:

Assets/_Project/Scripts/Combat/Effects/RuntimeEffect.cs

public sealed class RuntimeEffect {
    public EffectData Def;            // общее определение
    public RuntimeUnit Source;        // кто наложил
    public int RemainingTicks;        // -1 постоянный, 0 мгновенный
    public int Stacks;
    public float[] ScaledPotency;     // снимок силы на компонент (параллельно Def.Components)
    public int[]   PeriodicTicks;     // счётчик тиков периодики (целый — детерминизм!)
}

Компонент при вызове получает EffectContext (in-структура) с целью, источником, контекстом боя, своим срезом потенции и числом стаков. Так компонент остаётся чистой функцией поведения, а всё изменяемое — снаружи, в RuntimeEffect/EffectContext. Бонус: компонент тривиально юнит-тестится с мок-контекстом.

Пример — ThornsComponent (Effects/Components/ThornsComponent.cs): реагирует на DamageTaken носителя, отражает e.Amount × _reflectFraction × ctx.Stacks обратно атакующему. Поля компонента (_reflectFraction) — это настройки из SO (общие), а число стаков — из ctx (per-unit). Никакого состояния в самом компоненте.


4. EffectSystem — жизненный цикл

Assets/_Project/Scripts/Combat/Effects/EffectSystem.cs. Владеет всем циклом эффектов.

4.1 Наложение (Apply)

  1. Если эффект уже есть на цели → ветка стакинга (см. 4.3).
  2. Иначе: резолв длительности в тики (с учётом эфф-эффективностей), снимок потенции каждого компонента из статов источника.
  3. Если длительность > 0 — добавить в ActiveEffects, обновить маску тегов.
  4. Вызвать OnApply всех компонентов.

🔧 Правка ⑤ — мгновенные эффекты больше не текут. Мгновенный эффект (BaseDuration = 0) НЕ кладётся в ActiveEffects (он одноразовый). Раньше у него вызывался только OnApply, а OnExpire — никогда. Если на такой эффект повесить stateful-компонент (StatModifier/Shield), он навешивал бафф, который никто не снимал — вечная утечка. Теперь для мгновенного сразу после OnApply вызывается OnExpire: stateful-компоненты делают «навесил→снял» (нетто-ноль, корректно), а чисто-мгновенные делают работу в OnApply и имеют OnExpire-no-op.

4.2 Снимок потенции — правило

Потенция (сила DoT, величина щита и т.п.) резолвится из статов источника один раз при наложении и кладётся в ScaledPotency. Хранится rate-per-second, не «запечённый» тотал.

📌 Правило (осознанное, не баг — правка ⑦). При добавлении стака снимок потенции не пересчитывается — сила «заморожена» по статам источника на момент первого каста. Меняется только stateful-вклад компонентов под новое число стаков (через OnExpire→OnApply в Reapply). Это зафиксировано комментарием в коде, чтобы потом не было «почему мой DoT не растёт со статами». Если захочешь динамическую потенцию — это сознательное изменение правила, а не «фикс».

4.3 Стакинг (StackRule)

ПравилоПоведение
Noneповторное наложение игнорируется
Stack+1 стак (до MaxStacks)
Refreshобновить длительность
StackAndRefreshи то, и другое

При изменении числа стаков зовётся Reapply (OnExpire→OnApply), чтобы stateful-компоненты пересчитали вклад под новый Stacks.

4.4 Тик

Каждый тик по всем юнитам с эффектами: периодика (OnTick по целым PeriodicTicks-счётчикам) → декремент длительности → истечение (Expire с корректным OnExpire и пересборкой маски тегов). Итерация идёт по копии списка эффектов в переиспользуемый буфер — чтобы Apply/Dispel во время тика не ломали коллекцию под ногами.

4.5 Контроль (оглушение/корень/немота)

Флаги CanAct/CanMove/CanCast на RuntimeUnit пересобираются из активных ControlComponent в RecomputeControl (перекрытие без счётчиков: хоть один запрещает — запрещено). Системы движения/атаки/каста только читают эти флаги.

🔧 Правка ④ — диспел контроля больше не залипает. RecomputeControl зовётся в конце тика только для юнитов с эффектами (Count > 0). Раньше Dispel/Remove снимали эффект, не пересчитывая флаги. Сценарий бага: на юните один стан → его сдиспелили → эффектов 0 → юнит больше не попадает в тик эффектов → флаги навсегда заморожены в false → юнит парализован до конца боя. Теперь Dispel и Remove явно зовут RecomputeControl после снятия. Это была латентная мина (срабатывает, когда диспел+контроль попадут в контент Фазы 2) — закрыли заранее.


4.6 Эффекты ↔ статы (модель «эффекты кормят статы», решение 2026-06-19)

Ключевая связка двух подсистем. Большинство «временных усилений» — это НЕ отдельный механизм, а эффект, который двигает стат через StatModifierComponent:

  • «Хаст +50% на 5 сек» = эффект (таймер) с StatModifierComponent(+AttackSpeed %).
  • «Вампирик-аура» = эффект с StatModifierComponent(+Lifesteal); само исцеление делает стат-путь в DealDamage.
  • «Реген-бафф» = эффект с StatModifierComponent(+HpRegen); лечит RegenSystem.

При истечении эффект снимает свой вклад одним вызовом по ключу-источнику (RemoveModifiersFrom(effect)) — слоистая стат-система (03. Данные, статы и урон §3.2) делает это чисто.

Лакмус «стат vs эффект»: просто множит/прибавляет число → стат (или эффект, кормящий стат); реагирует на событие либо включается по длительности/условию → эффект-компонент. Шипы реагируют → только эффект (ThornsComponent). Реактивный LifestealComponent под этой моделью избыточен (лечит мимо стата) — заменяется на StatModifierComponent(+Lifesteal); удаление + миграция теста — 07. Техдолг, решения и changelog §3.7.


5. Внутренние боевые события (реактивность)

Шипы и вампиризм реагируют на урон. Механизм — внутренняя FIFO-очередь в CombatSimulation (не C#-события, не MessagePipe — это мозг реагирует сам на себя):

DealDamage → enqueue(DamageDealt источнику) + enqueue(DamageTaken цели)
   …все системы тика отработали…
DrainEventQueue → для каждого события: доставить реактивным компонентам носителя
                   (которые могут нанести новый урон → новые события в ту же очередь)

Защита от зацикливания

Взаимные шипы порождают пинг-понг: A отражает B, B отражает A, A отражает… Очередь дренажится итеративно (без рекурсии), а счётчик MaxEventsPerDrain = 512 обрывает бесконечный обмен. Остаток отбрасывается детерминированно. Это разобрано как «спайк S2» в доках фазы.

🔧 Правка ⑥ — Heal теперь шлёт событие. Раньше DealDamage клал события, а Heal — нет, хотя DrainEventQueue умеет CombatEvent.Healed. Значит «реагирует на исцеление» компоненты никогда бы не срабатывали. Теперь Heal кладёт Healed (по фактически вылеченному, без оверхила). Симметрия с уроном восстановлена.


6. Способности (AbilitySystem)

Assets/_Project/Scripts/Combat/Abilities/AbilitySystem.cs. Тикает кулдауны и кастует готовые активки: списывает ресурс, ставит кулдаун (× CooldownEff), накладывает эффекты способности на цель (Self/NearestEnemy/NearestAlly).

⚠️ Это плейсхолдер, и так задумано. Сейчас логика триггера тупая: «кастуй первую готовую активку, если можешь». Полноценный выбор «когда и что кастовать» — это AI Фазы 3 (Filter/Score/Override). Текущий каст детерминирован (годится для тестов ядра), но бездумен — может жечь ресурс впустую. Не считать это багом — это заглушка с понятной точкой расширения.


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

  • Эффект = композиция stateless-компонентов; полиморфизм через [SerializeReference] с маркером в Data и поведением в Combat.
  • Per-unit-состояние — в RuntimeEffect/EffectContext, не в компонентах (они общие).
  • Потенция — снимок при наложении (заморожена при стаке, осознанно); периодика — по целым тикам.
  • Контроль — пересборка флагов из ControlComponent; диспел теперь корректно пересчитывает (правка ④).
  • Реактивность — внутренняя FIFO-очередь с капом 512 от зацикливания; Heal теперь тоже шлёт событие (правка ⑥).
  • AbilitySystem — сознательный плейсхолдер до AI Фазы 3.