Статус: Реализовано (все 10 шагов §8 закрыты, 90 EditMode-тестов зелёные)
Детальная архитектура Фазы 2 из роадмапа (9. Roadmap реализации): единая модель эффектов + способности. Наполняет швы, заложенные пустыми в Фазе 1 (10. Архитектура реализации — Фаза 1). Дизайн-источник: 6. Боевая модель §5 (единая модель эффектов), 11. Стат-система §5 (потенция vs длительность). Следует пайплайну phase-design-pipeline: данные → швы → классы → спайк → тесты-имена.
Это план для ревью, а не финальный код. Сначала Макс читает и даёт мнение → потом реализуем.
0. Гейт: Фаза 1 закрыта (контекст этого дока)
Фаза 2 опирается на готовое ядро. Сверено по коду, не по статусам:
| Что готово в Ф1 | Где |
|---|---|
| Тик-цикл, фикс. порядок систем, детерминизм + checksum | CombatSimulation.Tick |
Stats (слои модов по источнику, dirty-кэш, формула) | Combat/Stats/Stats.cs |
DamagePipeline (чистые функции, броня/пробивание/щит) | Combat/Damage/DamagePipeline.cs |
RuntimeUnit/Factory, spatial hash, авто-атаки, снаряды | Combat/... |
Швы под Ф2 (пустые): ICombatContext.ApplyEffect (no-op), EffectData/IEffectComponent/ScalableValue (скелеты), RelicData.GrantedEffects, enum StatType (все 30 объявлены), EffectPolarity | Data/..., Combat/ICombatContext.cs |
Чего ещё НЕТ и что добавляет Ф2: RuntimeEffect, внутренняя event-queue (DrainEventQueue в тике отсутствует), EffectSystem, библиотека компонентов, способности (кулдаун/ресурс), диспел, проводка эфф-эффективностей в урон/хил.
1. Что Фаза 2 даёт и чего НЕ делает
Деливерабл: единая модель эффектов (баффы/дебаффы/DoT/HoT/контроль/щиты/триггеры/пассивки — один Effect с полиморфными компонентами), активные/пассивные способности с кулдауном и ресурсом, многоуровневый диспел, четыре эфф-эффективности длительности и потенция-множители исцеления/щитов — всё под headless EditMode-тестами.
| ✅ В Фазе 2 | ⛔ НЕ в Фазе 2 (куда переезжает) |
|---|---|
RuntimeEffect + жизненный цикл (apply/tick/expire) | Решение «когда кастовать» (Filter/Score/Override) → Фаза 3. Ф2 даёт механизм каста + детерминированный плейсхолдер-триггер для тестов |
Библиотека IRuntimeEffectComponent (StatMod, PeriodicDamage/Heal, Control, Shield, Lifesteal, Thorns, Dispel) | Полный контент-набор эффектов/реликвий (~10 эфф., 6–8 реликвий) → Фаза 4 (Ф2 делает 2–3 представителя каждого типа для тестов) |
ScalableValue в бою (потенция от статов источника) | Odin-авторинг контента, метаданные статов → Фаза 4 |
Стакинг (StackRule/MaxStacks), EffectTagMask | Гильдмастер-кастер GuildmasterController, гильд-широкие моды → каркас-шов в Ф2, наполнение → Фаза 5 |
Четыре эфф-эффективности длительности + HealShieldDealt/TakenEff (потенция) + CooldownEff | Сетевая репликация эффектов/ресурса → Фаза 6 (Ф2 держит детерминизм → реплицируется тем же путём команд) |
AbilityRuntime: кулдаун, ресурс, каст активки | Презентация эффектов (иконки баффов, тики DoT-цифр) → минимум в Ф2 (переиспользуем хуки Ф1), полировка → Фаза 7 |
| Внутренняя event-queue (FIFO, без рекурсии) для реактивных компонентов | — |
Многоуровневый диспел (CleanseTier vs DispelPower) | — |
Принцип границы: Ф2 строит движок эффектов и способностей, а не контент. Реликвии/эффекты под срез наполняются в Ф4 через готовые точки; AI решает «когда» в Ф3.
2. Данные первыми (Уровень 1 — фиксируем)
Данные — хребет. Здесь — главные решения, дорогие задним числом.
2.1 EffectData (Data) — дополнить скелет до полной модели (§6 дизайна)
К текущим Id/Polarity/Tags/BaseDuration/Components добавляются поля стакинга и диспела:
| Поле | Тип | Назначение |
|---|---|---|
Stacking | StackRule (None/Stack/Refresh/StackAndRefresh) | Поведение при повторном наложении |
MaxStacks | int | Потолок стаков |
CleanseTier | int | Устойчивость к диспелу (лестница vs DispelPower) |
Unremovable | bool | Неснимаемо никаким диспелом |
Tags— переводим соstring[]на[Flags] EffectTagenum (Control/DoT/HoT/Heal/Shield/Buff/Debuff/…). Нужен для диспела по категории иEffectTagMask. Полный список тегов — открытый вопрос §9._baseDuration ≤ 0семантика:0= мгновенный (одинOnApply),-1= постоянный (пассивка). Фиксируем сейчас.
2.2 RuntimeEffect (Combat, новый POCO) — экземпляр эффекта на юните
RuntimeEffect
├── Def — ссылка на иммутабельный EffectData
├── Source — кто наложил (атрибуция, скейл, триггеры)
├── RemainingTime — в секундах сим (или тиках); 0/∞ по Def.BaseDuration
├── Stacks — текущее число стаков
├── ratePerSecond[] — ПОТЕНЦИЯ периодики, посчитана при наложении через ScalableValue
│ (НЕ запечённый total — см. §11 §5.1: масштаб от длительности)
└── tickAccumulator — для IPeriodicComponent.Interval
⚠️ Ключевое (док 11 §5.1): для DoT/HoT хранить rate за секунду, не готовый total. Длительность меняется эфф-эффектами → число тиков меняется → total пересчитывается сам. Запекать total при наложении НЕЛЬЗЯ.
2.3 RuntimeUnit (Combat) — новые поля (швы Ф1 ждут их)
| Поле | Тип | Кто мутирует |
|---|---|---|
ActiveEffects | List<RuntimeEffect> | EffectSystem (add → pending-очередь, не в коллекцию во время итерации) |
EffectTagMask | EffectTag (битовая маска) | EffectSystem при add/remove — быстрый запрос для AI (Ф3) и диспела |
Abilities | List<AbilityRuntime> | RuntimeUnitFactory (сборка), AbilitySystem (тик) |
Control-флаги | CanAct/CanMove/CanCast (bool) | ControlComponent через apply/expire; читают Movement/AutoAttack/Ability |
CurrentResource/CurrentShield— поля уже есть с Ф1, в Ф2 их начинают наполнять (StartResourceпри сборке,ShieldComponent).
2.4 AbilityRuntime (Combat) + AbilityData (Data, на RelicData)
| Сущность | Поля | Примечание |
|---|---|---|
AbilityData (SO/вложенный) | EffectData[] применяемых эффектов, baseCooldown, resourceCost, targetMode (Self/Enemy/Allies/Area), ScalableValue параметры | Активки авторятся на реликвии; пассивки = GrantedEffects с Duration = -1 |
AbilityRuntime (POCO) | ссылка на AbilityData, cooldownRemaining, готовность | actualCd = baseCd × Source.Get(CooldownEff) |
Слоты по редкости (Common 1/1 → Rare 2/2 → Epic 3/3, док 6 §1) — это число элементов на реликвии; механика слотов одинакова.
2.5 Эфф-эффективности — данные уже есть, проводим логику
Enum StatType уже содержит ApplyBuffEff/ApplyDebuffEff/ReceiveBuffEff/ReceiveDebuffEff/CooldownEff/HealShieldDealtEff/HealShieldTakenEff/AbilityPower/MaxResource/StartResource/ResourceGainEff. Ф2 добавляет им дефолты/клампы в StatsConfig и проводку в формулы (§4), но не трогает enum (нумерация залочена).
3. Швы (Уровень 1 — фиксируем)
3.1 Кросс-сборочный шов компонентов (ГЛАВНЫЙ риск — §5 спайк)
Док 10 §2.2 заложил разрыв: сериализационный якорь — в Data, рантайм-хуки — в Combat.
// Data (низ): пустой маркер — Relic/Effect ссылаются, НЕ создавая Data→Combat вверх.
public interface IEffectComponent { } // уже есть (Ф1)
// Combat (верх): рантайм-контракт. Контекст-типы (RuntimeUnit, ICombatContext) живут тут.
public interface IRuntimeEffectComponent : IEffectComponent
{
void OnApply(in EffectContext ctx);
void OnExpire(in EffectContext ctx);
}
public interface IPeriodicComponent : IRuntimeEffectComponent { float Interval { get; } void OnTick(in EffectContext ctx); }
public interface IReactiveComponent : IRuntimeEffectComponent { CombatEvent Events { get; } void OnEvent(in EffectContext ctx, in CombatEventData e); }- Конкретные компоненты (
BurnComponentи т.д.) живут вCombat, реализуютIRuntimeEffectComponent. EffectData._components(Data) типизированIEffectComponent→ хранит экземпляры Combat-типов полиморфно через[SerializeReference], без compile-зависимости Data→Combat.EffectSystem(Combat) кастуетDef.Components[i]кIRuntimeEffectComponent/IPeriodicComponent/IReactiveComponent.
Это работает, только если Unity/Odin умеет: (а) сериализовать Combat-тип в Data-поле, типизированное Data-интерфейсом, и (б) показать его в
[SerializeReference]-дропдауне Odin. Не уверены → спайк ДО дизайна на этом (§5).
3.2 EffectContext (Combat) — передаётся in в компоненты
public readonly ref struct EffectContext // или struct, решить в спайке alloc-профиля
{
public RuntimeUnit Target;
public RuntimeUnit Source;
public ICombatContext Combat;
public IRngService Rng;
public float Dt;
public int Stacks; // для масштаба от стаков
}3.3 ICombatContext — наполняем стаб + добавляем
ApplyEffect(target, def, source)— тело вместо no-op Ф1: резолв длительности (§4), стакинг, добавлениеRuntimeEffectв pending,OnApplyкомпонентов, обновлениеEffectTagMask.Heal(...)— добавить проводкуHealShieldDealtEff × HealShieldTakenEff(сейчас Ф1 хилит «плоско»).- Новое:
Dispel(in DispelRequest req)— разрешение поPolarity ∧ Tags ∧ CleanseTier ≤ DispelPower ∧ !Unremovable(док 6 §5.4). QueryUnitsInRadius/SpawnProjectile/DealDamage— уже есть, переиспользуются компонентами.
3.4 Внутренняя event-queue (детерминизм реактивных компонентов)
Док 6 §7 / док 10 §5.6: отдельный механизм, не путать с командами и не с MessagePipe.
public enum CombatEvent { None, DamageDealt, DamageTaken, UnitDied, EffectApplied /*…*/ } // [Flags] для подписки
public readonly struct CombatEventData { /* source, target, amount, … */ }DealDamage/Heal/смерть энкьюятCombatEventDataво внутреннюю FIFO-очередь.- Новый шаг тика
DrainEventQueue()(сейчас вTickего НЕТ): FIFO, диспатч вIReactiveComponentподписчиков (вампиризм/шипы). Реакция может породить ещё урон → ещё событие → дренится в той же очереди без рекурсии. - Outward-события для презентации (
OnDamageDealtC#-event) остаются как в Ф1 — презентация только читает.
3.5 Порядок тика — куда вставляем (дельта к Ф1)
Tick(dt):
FlushPendingSpawns
ApplyDueCommands
AbilitySystem.Tick ← НОВОЕ: кулдауны↓, ресурс-реген, каст готовых активок
Targeting → Movement → SpatialHash.Rebuild → AutoAttack → Projectiles
EffectSystem.Tick ← НОВОЕ: таймеры эффектов, OnTick периодики, expire, маска тегов
DrainEventQueue ← НОВОЕ: реактивные компоненты (FIFO, без рекурсии)
Death → CheckOutcome → currentTick++
EffectSystemдоDeath— чтобы DoT мог добить.DrainEventQueueпосле систем, доDeath— реакции на урон тика учтены до проверки смертей.- Control-флаги читаются Movement/AutoAttack/AbilitySystem — порядок гарантирует, что эффект, наложенный командой/прошлым тиком, действует этим тиком.
4. Формулы (фиксируем — детерминизм + баланс)
4.1 Длительность при наложении (док 11 §5)
RemainingTime = base × Source.Get(ApplyEff[polarity]) × Target.Get(ReceiveEff[polarity])
polarity=Buff → ApplyBuffEff × ReceiveBuffEff
polarity=Debuff → ApplyDebuffEff × ReceiveDebuffEff
ReceiveDebuffEffскейлит и длительность контроля → роль tenacity (отдельного стата нет).- Neutral-полярность: какая пара применяется? → открытый вопрос §9 (склоняюсь: не скейлится вовсе).
4.2 Потенция — ОТДЕЛЬНО от длительности
- Резолв параметра:
ScalableValue.Resolve(Source.Stats)(база + Σ стат×ratio, напр.AbilityPower). - Урон/тик → обычный
DamagePipeline(DamageDealtEff× митигация ×DamageTakenEff). - Хил/щит/тик →
× Source.HealShieldDealtEff × Target.HealShieldTakenEff. - DoT/HoT total =
ratePerSecond × фактическая длительность(вытекает из §2.2, не запекать).
4.3 Кулдаун
actualCooldown = baseCooldown × Source.Get(CooldownEff) // <1 = быстрее
5. Неизвестное → спайк (ДО дизайна, который на это опирается)
| # | Неизвестное | Спайк (одноразовый код) | Если не выйдет |
|---|---|---|---|
| S1 | Кросс-сборочный [SerializeReference] (§3.1): сериализует ли Unity Combat-тип в Data-поле типа IEffectComponent, и видит ли Odin-дропдаун эти типы | На тест-EffectData положить 1 Combat-компонент через инспектор Odin, сохранить .asset, загрузить в EditMode-тесте, проверить что Components[0] is IRuntimeEffectComponent и поля не потерялись после domain reload | Альтернатива: компоненты в Data + рантайм-хуки через visitor/switch в Combat (хуже, но развязывает) |
| S2 | Реентрантность event-queue: реакция-на-урон порождает урон → не словить недетерминизм/переполнение | Шипы + вампиризм на двух юнитах, прогнать N тиков headless, сверить checksum дважды (детерминизм) и убедиться в отсутствии stack overflow | Ограничить глубину дренажа на тик + лог-варнинг |
| S3 | EffectContext как ref struct vs struct — zero-alloc в горячем тике | Прогнать 100 эффектов × 1000 тиков, замерить аллокации профайлером | readonly struct с копией |
Правило пайплайна: упёрлись в S1 — это самый дорогой разворот всей фазы, поэтому он шаг №1 реализации (§7), а не «по ходу».
6. Классы и ответственность (без тел методов)
| Класс | Сборка | За что отвечает | Ключевой публичный метод |
|---|---|---|---|
EffectSystem | Combat | Тик таймеров RuntimeEffect, OnTick периодики, expire + OnExpire, обновление EffectTagMask, стакинг при наложении | Tick(units, ctx, dt), Apply(unit, def, source, ctx) |
RuntimeEffect | Combat | POCO-экземпляр (§2.2). Без интерфейса — одна реализация | — |
AbilitySystem | Combat | Кулдауны↓, реген ресурса, исполнение каста готовой активки (триггер «когда» — плейсхолдер в Ф2, AI в Ф3) | Tick(units, ctx, dt), TryCast(unit, abilityIdx, ctx) |
IRuntimeEffectComponent (+IPeriodic,IReactive) | Combat | Полиморфное поведение эффекта. Много реализаций (контент-словарь) | OnApply/OnTick/OnEvent/OnExpire |
| Библиотека компонентов | Combat | StatModifierComponent, PeriodicDamageComponent, PeriodicHealComponent, ControlComponent, ShieldComponent, LifestealComponent(реакт.), ThornsComponent(реакт.), DispelComponent. Ф2 — по 1–2 представителя на тип для тестов | по интерфейсу |
DamagePipeline | Combat | Расширить: эмит во внутреннюю event-queue (для реактивных) поверх Ф1-формулы | Execute (есть) |
StatsConfig | Data | Дефолты/клампы Ф2-статов (эфф-эффективности старт 1.0, ресурс) | поля |
RuntimeUnitFactory | Combat | Шаги 5–6 сборки (док 6 §3): регистрация пассивок (GrantedEffects → Apply с Duration=-1), активок (AbilityRuntime), CurrentResource = StartResource | Build (есть, дополнить) |
7. Контракт = тесты (имена, EditMode headless)
EffectSystem / жизненный цикл
Apply_AddsEffect_AndSetsTagMaskBitExpire_RemovesEffect_AndClearsTagMaskBit_AndCallsOnExpirePeriodicDamage_TicksEveryInterval_NotEveryFrameDurationChange_ScalesTotalDot_RateStaysConstant(320 vs 160 при ×2 длительности)
Длительность / эфф-эффективности
ApplyDuration_ScaledBy_ApplyEff_TimesReceiveEffNeutralPolarity_DurationNotScaled(фиксирует решение §9)Tenacity_ReceiveDebuffEff_ShortensControl
Потенция
ScalableValue_Resolves_FromSourceStats_AtApplyTimeHeal_ScaledBy_HealShieldDealt_TimesTaken
Стакинг / контроль
Stack_RespectsMaxStacksRefresh_ResetsDuration_KeepsStacksControl_SetsCanActFalse_BlocksAutoAttack
Реактивные / event-queue (S2)
Lifesteal_HealsSource_FromDamageDealtThorns_DamageReflection_DoesNotRecurseInfinitelyEventQueue_DrainsFifo_DeterministicChecksum
Диспел
Dispel_RemovesMatchingPolarityAndTags_BelowDispelPowerDispel_SkipsUnremovable_AndHigherCleanseTier
Способности
Cooldown_ScaledBy_CooldownEffCast_ConsumesResource_AndFailsWhenInsufficientPassive_GrantedEffect_AppliedAtBuildWithInfiniteDuration
Спайк-гейт (S1)
SerializeReference_CombatComponent_SurvivesReloadInDataAsset
8. Порядок реализации внутри Фазы 2
- S1-спайк — кросс-сборочный
[SerializeReference](§5). Гейт всей фазы. Зелёный → дальше. - Данные — дополнить
EffectData(стакинг/диспел/EffectTag),RuntimeEffect, поляRuntimeUnit, дефолты Ф2-статов вStatsConfig. - Швы —
IRuntimeEffectComponent-иерархия,EffectContext, наполнитьApplyEffect,Heal-проводка эффективностей. - EffectSystem + тик — вставить в порядок (§3.5), таймеры/периодика/expire/маска. Тесты жизненного цикла + длительности.
- Библиотека компонентов — StatMod → PeriodicDamage/Heal → Control → Shield (по 1–2 на тип). Тесты на каждый.
- Event-queue (S2) + реактивные —
DrainEventQueue, Lifesteal/Thorns. Тесты детерминизма. - Диспел —
DispelComponent+ICombatContext.Dispel. Тесты. - Способности —
AbilityData/AbilityRuntime/AbilitySystem, кулдаун/ресурс, плейсхолдер-триггер каста. Тесты. - Интеграция — пассивки/активки через
RuntimeUnitFactory; 2–3 тест-реликвии с эффектами; battle-integration с DoT-добиванием. - Презентация (минимум) — переиспользовать Ф1-хуки: тики DoT-цифр через
OnDamageDealt, без новых экранов (полноценные иконки баффов — Ф7).
Темп (док 9): после шага 4 — «эффекты тикают» в headless-тесте; после 8 — кастующиеся способности; видимый слой — минимальный, полировка в Ф7.
9. Открытые вопросы (на ревью)
- Полный список
EffectTag([Flags]): категории для диспела/AI-фильтров/маски. Минимум:Control/DoT/HoT/Heal/Shield/Buff/Debuff. Финализировать перед шагом 2. EffectPolarity.Neutral— какая пара эфф-эффективностей? Предлагаю: Neutral длительность не скейлится (тестNeutralPolarity_DurationNotScaledэто и фиксирует). Согласовать.- Триггер каста в Ф2 (до AI Ф3): «кастовать готовую активку на ближайшего по
targetMode» детерминированно — достаточно для тестов? Или только черезCastGuildmasterSpellCommand/тест-команду? Предлагаю простой детерминированный плейсхолдер + тест-команду; настоящий выбор — Ф3. - Модель ресурса (
ResourceRegenзарезервирован в доке 11): мана регенит по времени, ярость копится от боя. Ф2 — минимум (StartResource, трата на каст, простой реген поResourceGainEff)? Или ярость отложить в Ф4? Уточнить. RemainingTimeв секундах или тиках? Сим тикает фикс.dt→ тики проще для детерминизма; секунды нагляднее в авторинге. Предлагаю тики внутри, конверсияBaseDuration(сек)→тикипри наложении.EffectContext:ref structvsstruct— решается профилем в S3.
10. Следующий шаг
Макс читает → правки → по апруву начинаем с S1-спайка (§7 шаг 1): кросс-сборочный [SerializeReference]. Зелёный гейт — разворачиваем данные и EffectSystem. После фазы — ретро дизайна (что разошлось с планом) на вход Фазы 3 (AI-движок).
11. Ретро реализации (что разошлось с планом)
Фаза закрыта. Дельты к плану — на вход Фазе 3:
- Шаг 9 — пассивки применяет
RuntimeUnitFactory(как и планировалось §6), но это потребовало дать ейEffectSystem+ICombatContext. Причина: порядок сборки §5.2 (пассивки → CurrentHP = Get(MaxHP)) обязателен — пассив на+MaxHPдолжен поднимать и стартовое HP, поэтому наложение происходит внутриCreateдо инициализацииCurrentHP, а не на спавне вCombatSimulation. DI (CombatLifetimeScope) резолвит сим как контекст в фабрику; цикла нет (сим не зависит от фабрики). Источник пассивки — сам юнит. - Шаг 9 — ресурс:
CurrentResource = Stats.Get(StartResource)при сборке (без клампа кMaxResource— баланс в Ф4). Активки оборачиваются вAbilityRuntimeтут же. - Шаг 10 — презентации нового кода не понадобилось. DoT идёт
PeriodicDamageComponent → DealDamage → OnDamageDealt, т.е. цифры урона переиспользуют Ф1-хук без изменений. Доказано тестомDotTicks_ReuseOnDamageDealtHook_ForPresentation. Иконки баффов/HoT-числа — Ф7, как и планировалось. - Тесты интеграции:
RuntimeUnitFactoryTests(пассивка с ∞-длительностью при сборке, +MaxHP до CurrentHP, сборка активок, старт ресурса) +DotBattleIntegrationTests(DoT-добивание завершает бой; хук презентации).