Статус: 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 { … } ← реальное поведение
EffectData (в Data) хранит IEffectComponent[] — знает только пустой маркер. А лежат там Combat-классы. Хуки жизненного цикла объявлены в Combat, где доступны боевые типы. Так зависимость не идёт вверх, а полиморфизм работает. Это подтверждённый «спайк S1» из доков фазы.
Семейство интерфейсов компонентов
Assets/_Project/Scripts/Combat/Effects/IRuntimeEffectComponent.cs:
| Интерфейс | Хук | Для чего |
|---|---|---|
IRuntimeEffectComponent | OnApply / OnExpire | База: наложение и снятие (щит, стат-моды) |
IPeriodicComponent | OnTick каждые Interval сек | DoT / HoT / реген по тикам |
IReactiveComponent | OnEvent(событие) | Вампиризм, шипы — реагируют на урон/хил |
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)
- Если эффект уже есть на цели → ветка стакинга (см. 4.3).
- Иначе: резолв длительности в тики (с учётом эфф-эффективностей), снимок потенции каждого компонента из статов источника.
- Если длительность > 0 — добавить в
ActiveEffects, обновить маску тегов. - Вызвать
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.