Статус: Ready


Архитектура боевого юнита: композиция «Сосуд + Реликвия», стат-система и единая модель эффектов. Опирается на принципы из 5. Технологический стек и архитектура (детерминизм, SO-данные + поведение в коде, DI, дисциплина интерфейсов).


1. Модель идентичности

Аналогия из GDD — «игрок в LoL и его чемпион»:

СущностьРольЧто несёт
Реликвия«чемпион» — источник силыОсновной стат-блок, активки, пассивки, ресурс (тип/макс/старт/эффективность), категориальная конфигурация, теги
«Сосуд»«пилот»Перки — модификаторы поверх чемпиона (PerkModifiers). Сам статов не имеет. Супер-уник-слой «Судьба» — см. GDD: 5. Реликвии
Гильдмастер«тренер на бровке»Не юнит на поле. Кастует заклинания + даёт гильдие-широкие модификаторы. Задаёт стартовый набор забега

Решения (залочено):

  • Базовый стат-блок даёт Реликвия (поверх глобального шаблона-дефолта StatsConfig). Сосуд без Реликвии небоеспособен.
  • Ровно 1 Реликвия на Сосуда в бою. Реликвии копятся на уровне гильдии; назначение — шаг подготовки к бою.
  • Редкость ≠ множитель статов. Влияние на статы примерно равно у любой редкости; редкость = размер кита: Треш (минимум, 0–1 способность) → Обычная (≈1 пассивка + 1 активка) → Уникальная (расширенный кит именных героев). Прежняя градация Common/Rare/Epic отменена — см. GDD 5. Реликвии и GDD 12. Мета-прогрессия.
  • Ресурс привязан к Реликвии (сменил Реликвию → сменился тип ресурса).
  • Гильдмастер: GuildmasterController (отдельный кастер со слотами, перезарядка по времени/по числу боёв) + гильдие-широкий IModifierSource на всех юнитов.

2. Композиция RuntimeUnit (POCO, живёт один бой)

RuntimeUnit
├── Идентичность (ссылки на иммутабельные SO)
│   ├── VesselData   — перки, внешность, имя
│   └── RelicData    — «чемпион»: стат-блок, активки, пассивки, ресурс
├── Stats            — вычисляемые статы (слои модификаторов, §3)
├── Abilities[]      — AbilityRuntime: таймеры кулдауна/ресурса
├── ActiveEffects[]  — RuntimeEffect: эффекты в бою (§5)
├── Runtime-состояние: CurrentHP, CurrentResource, CurrentShield, Position
├── EffectTagMask    — битовая маска тегов активных эффектов (быстрый запрос для AI)
└── AI               — скомпилированный AIProfile (Filter/Score/Override)

RuntimeUnit — обычный C#-класс, не ScriptableObject.CreateInstance (мина прошлого проекта). SO — только иммутабельные определения, на них ссылаемся.


3. Стат-система

Слоистые модификаторы с источником — не плоский класс с 30 полями.

📋 Полный канонический список статов (ровно 30, EN+RU, операции, дефолты, клампы) — в отдельном доке 11. Стат-система. Ниже — только механика стат-системы; конкретные StatType см. там.

public enum ModifierOp { Flat, PercentAdd, PercentMult }
 
public readonly struct StatModifier
{
    public readonly StatType Stat;
    public readonly float Value;
    public readonly ModifierOp Op;
    public readonly object Source;     // реликвия/перк/эффект — чтобы снять РАЗОМ
}
 
public sealed class Stats
{
    public float Get(StatType stat);                                 // кэш по dirty-флагу
    public void AddModifiersFrom(object source, IReadOnlyList<StatModifier> mods);
    public void RemoveModifiersFrom(object source);                  // снял источник → ушли его моды
}

Формула (фиксированная — детерминизм + баланс):

final = (base + ΣFlat) × (1 + ΣPercentAdd) × Π(1 + PercentMult)
  • «Эффективности» (урона/эффектов/перезарядки) — это StatType со стартом 1.0 и PercentMult. Никакого спец-кода в десяти местах.
  • Пересчёт по dirty-флагу, не каждый тик.
  • Числовые vs категориальные: через модификаторы идут только числа. DamageType (маг/физ/чист), AttackType (ближний/дальний/AOE/пробивающий), ResourceType — категориальная конфигурация на RelicData, не в стат-пайплайне. (ProjectilePierce — это числовой стат, см. док 11, чтобы реликвии/эффекты могли его менять.)

Сборка юнита («компиляция»)

  1. base ← глобальный StatsConfig (SO).
  2. + модификаторы Реликвии (главный источник).
  3. + Перков → + Предметов → + Командных артефактов.
  4. (опц.) + гильдие-широкие модификаторы Гильдмастера.
  5. Регистрируем пассивки и активки Реликвии (активки → AbilityRuntime).
  6. Инициализируем рантайм: CurrentHP = Get(MaxHP), CurrentResource = StartResource.
  7. Прикручиваем скомпилированный AI-профиль.
  8. Эффекты в бою добавляются/снимаются динамически — через тот же пайплайн (Source = эффект).

4. Визуал

Решение (залочено): внешний вид — цельный на Реликвию (не модульный). «Сосуд» на поле не влияет на внешность; у него только портрет для UI (ростер/подготовка). Идея «узнаваемый Сосуд под разными реликвиями» из раннего черновика — отклонена.

Технология анимации — кадровая (sprite sheet). Конвейер презентации (живёт в Guildmaster.Presentation, только читает сим):

  • Один UnitView.prefab на всех юнитов: SpriteRenderer + Animator + дочерние HP-бар (Shapes) + узел VFX/Feel.
  • Один базовый AnimatorController со стейт-машиной (Idle/Run/Attack/Cast/Hit/Death + переходы) — описывается один раз.
  • На каждую Реликвию — AnimatorOverrideController, подменяющий клипы (спрайт-шиты), не трогая стейт-машину. Новая Реликвия = SO + шит + override, без кода.
  • Визуальные ссылки — на RelicData через Addressables AssetReference (спрайт-шит, override-контроллер, портрет). VFX способностей — AssetReference на AbilityData/EffectData.
  • При спавне CombatPresenter читает RuntimeUnit.Relic → грузит override + спрайт через Addressables → вешает на Animator.

⚠️ Анимация косметична и развязана с таймингом сим. Animator управляется чтением сим-состояния (isMoving из Position != PreviousPosition) и сим-событий (Attack/Hit/Die через MessagePipe). Урон наносится на сим-тике (AutoAttackSystem), а НЕ на anim-event — иначе рассинхрон в ко-опе. Скорость attack-клипа масштабируется от AttackSpeed (косметика).


5. Единая модель эффектов

Ключевая идея: пассивки, «поведенческая» часть перков, баффы/дебаффы, DoT/HoT, регенерация, контроль, триггеры (вампиризм/шипы), гильд-бонусы ГМ — всё это один Effect = контейнер с жизненным циклом + список полиморфных компонентов.

public class EffectData : ScriptableObject              // иммутабельное определение
{
    public string Id;                  // "burn" — строковый ID для сейва/Workshop
    public EffectPolarity Polarity;    // Positive / Negative
    public EffectTag Tags;             // [Flags]: Control, DoT, Heal, Shield...
    public float Duration;             // 0 = мгновенный, -1 = постоянный (пассивка)
    public StackRule Stacking;         // None / Stack / Refresh / StackAndRefresh
    public int MaxStacks;
    public int CleanseTier;            // устойчивость к диспелу (§5.4)
    public bool Unremovable;           // неснимаемо никаким диспелом
 
    [SerializeReference] public IEffectComponent[] Components;   // ЧТО он делает
}
 
public sealed class RuntimeEffect      // POCO на юните
{
    public EffectData Def;
    public RuntimeUnit Source;         // кто наложил — триггеры, скейл, атрибуция
    public float RemainingTime;        // в тиках симуляции
    public int Stacks;
    public float ScaledPotency;        // посчитано при наложении (§5.2)
}

5.1 Компоненты (интерфейсы «по делу»)

public interface IEffectComponent
{
    void OnApply(in EffectContext ctx);
    void OnExpire(in EffectContext ctx);
}
public interface IPeriodicComponent : IEffectComponent     // тик каждые Interval
{
    float Interval { get; }
    void OnTick(in EffectContext ctx);
}
public interface IReactiveComponent : IEffectComponent     // реагирует на события боя
{
    CombatEvent Events { get; }
    void OnEvent(in EffectContext ctx, in CombatEventData e);
}

Библиотека (контент-словарь, переиспользуется и активками): StatModifierComponent, PeriodicDamageComponent, PeriodicHealComponent, ControlComponent, ShieldComponent, LifestealComponent (реактивный), ThornsComponent (реактивный), DispelComponent (§5.4)…

  • EffectContext (передаётся in): { Target, Source, ICombatContext Combat, IRngService Rng, float Dt }.
  • ICombatContext — API влияния на мир (DealDamage, Heal, ApplyEffect, QueryUnitsInRadius, SpawnProjectile). Один реализатор, но настоящий шов: делает компоненты юнит-тестируемыми с мок-контекстом.

5.1.1 Скейлинг параметров (ScalableValue)

Скейл — по-параметрный, по-умению, опциональный (референс — LoL): у одного умения урон может скейлиться от AP, длительность контроля — от AP с другим коэффициентом, а радиус — не скейлиться вовсе. Поэтому скейл живёт на каждом числовом параметре, а не «на компоненте».

Любой масштабируемый параметр объявляется как ScalableValue (а не голый float):

[Serializable] public struct ScalableValue
{
    public float Base;
    public ScalingTerm[] Scalings;          // пусто = не скейлится
    public float Resolve(IStatReader src)    // base + Σ(stat × ratio)
        => Base + sum(src.Get(t.Stat) * t.Ratio for t in Scalings);
}
[Serializable] public struct ScalingTerm { public StatType Stat; public float Ratio; }
  • Поддерживает скейл от любого StatType (AP — частый случай, но можно от урона, бонус-HP и т.д.).
  • Пример: ShieldComponent { ScalableValue BlockAmount; ScalableValue BlockPercent; } — сумма и процент блока скейлятся независимо или не скейлятся.
  • Stats реализует IStatReader. Резолв — по снимку статов источника на момент каста/наложения (детерминированно).
  • Порядок: потенция параметра резолвится через ScalableValue (статы источника) + потенция-множители урона/исцеления; длительность — отдельно через эфф-эффекты (§5.2).

5.2 Четыре «эффективности» — масштабируют ТОЛЬКО длительность

Полная таблица семей множителей — 11. Стат-система §5.

Эфф-эффекты (Apply*/Receive*) меняют только длительность таймированного эффекта, не его силу:

длительность = base × Source.Get(ApplyEff[polarity]) × Target.Get(ReceiveEff[polarity])
  • polarity = PositiveApplyBuffEff × ReceiveBuffEff; NegativeApplyDebuffEff × ReceiveDebuffEff.
  • Потенцию (урон/тик, хил/тик, величину щита) масштабируют другие статы: DamageDealt/TakenEff, HealShieldDealt/TakenEff + ScalableValue.

⚠️ DoT/HoT total масштабируется от длительности. Хранить per-tick rate (или totalBase/baseDuration), не запечённый total. «160 HP за 5 сек» = 32 HP/сек; продлили до 10 сек → 320 HP total (rate тот же, тиков вдвое больше). Запекать готовый total при наложении нельзя — сломает масштабирование при смене длительности.

5.3 Стакинг и контроль

  • Стакинг по StackRule + MaxStacks. Компоненты читают Stacks для величины.
  • Контроль = ControlComponent, ставит флаги на юните (CanAct/CanMove/CanCast). Без diminishing returns — цикл контроля = заслуга игрока, не баг. Стакается/обновляется как любой эффект.
  • AI-фильтр «цель без эффекта X» спрашивает битовую маску тегов на юните (обновляется при add/remove), а не перебирает список — перф для staggered-AI.

5.4 Диспел / очищение (многоуровневый)

DispelComponent:

public EffectPolarity TargetPolarity;  // снимать баффы / дебаффы / любые
public EffectTag      TargetTags;      // категории: только DoT / только Control / всё
public int            DispelPower;     // снимает эффекты с CleanseTier ≤ DispelPower
public int            MaxCount;        // 0 = все подходящие

Разрешение: из активных эффектов цели берём Polarity match И Tags match И CleanseTier ≤ DispelPower И !Unremovable → принудительный OnExpire (корректный teardown).

Покрывает требования дизайна:

  • «снимает кровоток, но не оглушение» → TargetTags = DoT.
  • «неснимаемое» → Unremovable = true.
  • «уровни силы и развеивания» → лестница CleanseTier vs DispelPower.
  • purge (снять баффы врага) и cleanse (снять дебаффы союзника) — один механизм через TargetPolarity.

Канонические тиры CleanseTier (лестница снимаемости, она же ранг опасности):

ТирЧто входитПримеры
1 — Базовыеслоу, −статы, метка, разлом брони, DoTзамедление, поджог, яд, метка охотника, разлом брони
2 — Контрольжёсткий CCстан, заморозка, рут, оцепенение
3 — Особыередкие / долгиебоссовые проклятия, «Копия» (The Mirror), долгие штрафы
Unremovableвне лестницысмещение (KnockUp), перманентные проклятия реликвий
  • DispelPower сравнивается с лестницей: сила 2 снимает T1–T2, но не T3.
  • Смещение — Unremovable (нельзя снять в полёте, иначе рвётся комбо The Gale / The Tide): только предотвращается иммунитетом (§5.6), не снимается.
  • Баланс-интент: DispelPower = 3 — привилегия редких уников (The Paragon). Спец-клинсовики отличаются ШИРОТОЙ (AOE / все / проактивно) при DispelPower = 2, а не тиром, чтобы не обесценить уников.
  • Порядок при MaxCount > 0 (решено): высший CleanseTier первым; тай — по наибольшей оставшейся длительности. Детерминированно, соответствует «снять самый опасный».

5.5 Унификация

Что в GDDКак в модели
Пассивка РеликвииEffect, Duration = -1, наложен при сборке юнита
Перк (поведенческая часть)стат-модификаторы (§3) + опц. постоянный Effect
Бафф / дебафф / DoT / HoTEffect с компонентами + длительность
КонтрольEffect с ControlComponent
Вампиризм / шипыEffect с IReactiveComponent
Гильд-бонус ГМпостоянный Effect на всех юнитов
Мгновенный урон активкикомпонент с Duration = 0 (один OnApply)

5.6 Иммунитет (предотвращение наложения)

Диспел (§5.4) снимает УЖЕ висящие эффекты. Иммунитет — противоположный инструмент: не даёт наложить новые. Отдельный механизм, не диспел и не tenacity.

public sealed class ImmunityComponent : IEffectComponent   // висит как эффект-бафф
{
    public EffectPolarity BlockPolarity;   // обычно Negative — отвергать новые дебаффы
    public EffectTag      BlockTags;       // всё / только Control / …
    public bool           BlockDamage;     // премиум: неуязвимость к урону (The Paragon «Идеальная форма»)
}
  • Пока активен, ICombatContext.ApplyEffect отвергает входящие эффекты, совпадающие по BlockPolarity/BlockTags — включая смещение (это тоже эффект, значит иммунитет блокит откидывание).
  • BlockDamage проверяется в пайплайне урона (§6) до митигации — весь урон обнуляется.
  • Бинарный и короткий по дизайну (The Paragon: 3 сек за ману). Тирить иммунитет — не сейчас.

Три РАЗНЫХ ответа на контроль — не путать: снять (диспел, §5.4) · сократить (tenacity = ReceiveDebuffEff < 1, §5.2) · не пустить (иммунитет, §5.6). Собираются игроком отдельно — это и есть разнообразие саппорт-слота.


6. Пайплайн урона

Урон — событие, проходящее детерминированный конвейер БЕЗ RNG (крита и уклонения нет — решение зафиксировано). Полная формула митигации (броня = %-формула K/(K+armor), пробивание = плоское + процентное) — в 11. Стат-система §4:

raw → ×Source.DamageDealtEff → митигация брони/резиста (с пробиванием; True — пропуск)
    → ×Target.DamageTakenEff → поглощение Shield → вычет из HP
    → событие OnDamageDealt/Taken (триггеры: вампиризм, шипы, «при уроне…»)

Крит отсутствует. Если введём позже — будет детерминированным («каждый N-й удар»), не через ctx.Rng. Поэтому в пути урона Фазы 1 обращений к RNG нет; RNG входит через процы эффектов (Фаза 2) и генерацию/лут (Фаза 5).


7. Детерминизм: реентрантность

Эффекты порождают события, события — эффекты. Чтобы не словить недетерминизм/переполнение стека:

  • очередь событий, обрабатываемая FIFO в пределах тика (не глубокая рекурсия);
  • фиксированный порядок обработки событий и итерации эффектов (insertion order; добавления — в pending-очередь, не в коллекцию во время итерации);
  • тики эффектов от тика симуляции (фиксированный dt);
  • весь рандом компонентов — через ctx.Rng.

Достраивает чек-лист детерминизма из 5. Технологический стек и архитектура.


8. Интерфейсы «по делу» (итог)

ИнтерфейсПочему оправдан
IModifierSource6 реализаций (Relic/Perk/Item/CommandArtifact/Effect/Guildmaster), агрегатор итерирует разнородные источники
IEffectComponent (+ IPeriodic, IReactive)Много реализаций, [SerializeReference] в EffectData — основной контент-словарь
ICombatContextШов между логикой эффектов и симуляцией; граница для мок-тестов

Конкретные классы (без интерфейса): Stats, RuntimeUnit, RuntimeEffect — одна реализация, не плодим.


9. Связь с сейвом

UnitSaveData хранит vesselId, equippedRelicId, perkIds[], itemIds[]строковые ID. RuntimeUnit пересобирается из них + SO при загрузке, не сериализуется напрямую. См. 2. Сохранения.


10. Движковые расширения под контент реликвий (бэклог)

Фазы 1–2 дали движок эффектов/способностей и библиотеку компонентов (StatModifier, PeriodicDamage/Heal, Control, Shield, Lifesteal, Thorns, Dispel). Часть запланированных реликвий-классов (GDD 1. Контент - Реликвии) опирается на механики, которых в этой библиотеке ещё нет. Решение по скоупу: все 7 Обычных реликвий должны быть играбельны в бою к концу Фазы 3 (9. Roadmap §3), поэтому нужные им расширения затаскиваются в Фазу 3 (а не размазываются по Ф4).

РасширениеЧто нужноКому нужно (пример)Фаза
Заряды способностиAbilityData: N зарядов + независимая перезарядка заряда (поверх кулдауна/ресурса)Скрытный Убийца («Изворотливость», 2 заряда)Ф3
Атака в движенииФлаг на реликвии/AttackType: авто-атака не останавливает MovementSystemЛесной следопыт, Монах вихряФ3
Хил-автоатакаИнверсия таргета авто-атаки: выбор союзника + Heal вместо DealDamageСветлый пастырьФ3
Форма авто-атаки (линия/конус)Melee-AOE авто-атака: расширить AttackType или компонент формы удараЖелезный копейщикФ3
Смещения (displacement)Компонент knockback / pull / teleport, мутирующий Position детерминированноМонах вихря (толчок + телепорт в спину)Ф3
Эффект «подкидывание»Накладывается при любом перемещении вражеского юнита (отталкивание/притягивание/…). Не уменьшается контролем/tenacity. Реф — LoL knock-upсемейство контроллеровФ3
Реактивный перенос меткиIReactiveComponent на UnitDied: метка/эффект перепрыгивает на ближайшую новую цельЛесной следопыт (метка +25% урона)Ф3 (реактив, Ф2-совместимо)
Empower next attackОднострел-реактив: усилить следующую авто-атаку (×урон / доп. эффект), снять после срабатыванияМонах вихря, Маг-рыцарь (черновая идея)Ф3 (реактив, Ф2-совместимо)

Перенос метки и empower-next-attack ложатся на уже готовый IReactiveComponent — это контент, а не новый движок. Остальное — реальные движковые доработки Фазы 3.


Открытые вопросы

  • Точный список StatType (~30)решено, см. 11. Стат-система. Дефолты в StatsConfig — баланс (Фаза 4+).
  • Полный список EffectTag (категории для тегов/диспела/AI-фильтров).
  • Порядок выбора при MaxCount в диспелерешено (§5.4): высший CleanseTier первым, тай — по наибольшей оставшейся длительности.
  • Стартовые наборы и уникальные механики Гильдмастеров — контент-дизайн.