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


Из чего «слеплен» юнит и как считаются его числа. SO-контент, стат-система со слоистыми модификаторами, чистый пайплайн урона.

Связано с 00. Обзор и карта кода, 04. Эффекты и способности, 03. Стат-система (полный список статов), 6. Боевая модель (юниты, статы, эффекты).


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

Есть два мира данных:

  • Контент (определения) — то, что авторится в редакторе и не меняется в бою: «реликвия Огненный меч даёт +50 урона и эффект поджога». Это ScriptableObject (SO) — ассеты на диске. Иммутабельные, общие для всех.
  • Рантайм (состояние) — то, что живёт один бой и постоянно меняется: «у этого юнита сейчас 320/500 HP и 2 стака яда». Это обычные C#-объекты (RuntimeUnit, Stats, RuntimeEffect).

Граница строгая: SO только читают, рантайм-объекты меняют. Один SO «Огненный меч» обслуживает сто юнитов одновременно — поэтому в нём не может быть состояния конкретного юнита.


2. SO-контент (слой Data)

SOЧто описывает
RelicData«Чемпион»: тип атаки (ближний/снаряд), тип урона, стат-блок, пассивные эффекты, активные способности
VesselData«Пилот»: идентичность, таланты (стат-модификаторы)
EffectDataОпределение эффекта: полярность, теги, длительность, стакинг, диспел-стойкость, компоненты поведения
AbilityDataОпределение способности: стоимость ресурса, кулдаун, режим цели, эффекты
StatsConfigГлобальные дефолты статов + тюнинг (armor-K, клампы скорости атаки)

Композиция «Сосуд + Реликвия». Юнит — это не один монолитный класс, а композиция: VesselData (кто) + RelicData (чем сражается). Это даёт комбинаторику контента без наследования. Подробнее — 6. Боевая модель (юниты, статы, эффекты).


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

3.1 StatType — канонический список

Assets/_Project/Scripts/Data/Stats/StatType.cs. Ровно 30 статов с явно присвоенными номерами:

public enum StatType {
    MaxHP = 0, HpRegenFlat = 1, HpRegenPct = 2, PhysArmor = 3, …
}

⚠️ Номера менять НЕЛЬЗЯ. Unity сериализует enum по int. Если переставить значения, все .asset-файлы, ссылающиеся на старые номера, «поедут». Поэтому список финализирован сразу на все фазы; новые статы только дописываются в конец. Часть статов помечена [Ф1] (используются в Фазе 1), часть ждёт своих фаз.

3.2 Stats — слоистые модификаторы

Assets/_Project/Scripts/Combat/Stats/Stats.cs. Итоговое значение стата считается по формуле:

final = (base + ΣFlat) × (1 + ΣPercentAdd) × Π(1 + PercentMult)

Три типа операций (ModifierOp):

  • Flat — плоская прибавка (складываются): +50 урона.
  • PercentAdd — аддитивный процент (суммируются в одну скобку): +10% и +20% = ×1.30.
  • PercentMult — мультипликативный процент (каждый отдельной скобкой): +10% и +20% = ×1.1×1.2 = ×1.32.

Почему два вида процентов? Это рычаг баланса. Аддитивные проценты «разбавляют» друг друга (десять источников по +10% дадут ×2, а не ×2.6), а мультипликативные — множатся и опаснее. Разделение позволяет дизайнеру выбирать, какой бонус «стакается мощно», а какой «насыщается».

Группировка по источнику. Модификаторы добавляются пачкой с ключом-источником:

stats.AddModifiersFrom(relic, relic.Stats);     // ключ = реликвия
stats.AddModifiersFrom(effect, effectMods);      // ключ = RuntimeEffect

stats.RemoveModifiersFrom(effect);               // снять разом всё от источника

Так эффект, накладывающий «+20% брони», при истечении убирает ровно свой вклад одним вызовом по ключу. Чисто и без утечек.

Кэш с dirty-флагом. Get(stat) отдаёт кэшированное значение; любое добавление/снятие модификаторов ставит _dirty = true, и следующий Get пересобирает кэш. Это амортизирует расчёт: читаем часто, пересобираем только при изменениях.

🔎 Тонкость производительности (на заметку для Фазы 2). RebuildCache аллоцирует три временных float[StatCount] массива на каждую пересборку. В Фазе 1 модификаторы добавляются один раз при создании юнита — значит пересборка одноразовая, аллокация неважна. В Фазе 2 эффекты будут часто менять статы → пересборки участятся → стоит перевести на переиспользуемые буферы. Сейчас НЕ трогаем (преждевременно), но записано в 07. Техдолг, решения и changelog.

3.3 ScalableValue — числа, зависящие от статов

Assets/_Project/Scripts/Data/Stats/ScalableValue.cs. Авторится на SO, разворачивается в бою: Base + Σ(stat × ratio). Например «120 + 0.6×AbilityPower». Основа для урона/исцеления способностей.


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

Assets/_Project/Scripts/Combat/Damage/DamagePipeline.cs. Статический и чистый — никакого состояния, только in-параметры и мутация HP/щита цели.

Порядок расчёта

raw
 × DamageDealtEff (источника)          — множитель «сколько наношу»
 × ArmorK/(ArmorK+effArmor)            — броня (кроме True-урона)
 × DamageTakenEff (цели)               — множитель «сколько получаю»
 → max(0)
 → поглощение щитом (CurrentShield)
 → вычет из HP

Броня считается по формуле mult = K/(K+effArmor) — это «убывающая отдача»: каждая единица брони даёт чуть меньше, чем предыдущая, без жёсткого потолка. effArmor = max(0, armor×(1−penPct) − pen) — сначала процентное пробитие, потом плоское.

Почему пайплайн — чистая функция, а не метод юнита? Чистота = тестируемость и предсказуемость. На вход — DamageRequest (структура: источник, цель, raw, тип, K), на выход — DamageResult (HP-урон, поглощено щитом, убил ли). Никаких скрытых зависимостей, легко проверить любой кейс в юнит-тесте и невозможно случайно зацепить глобальное состояние.

Где живут lifesteal/хил

Сам пайплайн только считает и вычитает HP. Вампиризм прикручен на уровень выше — в CombatSimulation.DealDamage, после расчёта:

// правка ②: источник лечится на долю нанесённого по HP урона
float lifesteal = req.Source.Stats.Get(StatType.Lifesteal);
if (lifesteal > 0f) Heal(req.Source, result.HpDamage * lifesteal, req.Source);

🔧 До недавнего реген (HpRegenFlat/Pct) и Lifesteal были помечены [Ф1], но их никто не читал — статы-«пустышки». Правка ② это закрыла: вампиризм в DealDamage, а регенерация — в новом RegenSystem (см. 02. Детерминированная симуляция и тик §3.1). В Фазе 1 вампиризм применяется ко всему урону без разбора; сузить до авто-атак/способностей — задача Фазы 2.

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


5. SpatialHash — пространственный индекс

Assets/_Project/Scripts/Combat/Spatial/SpatialHash.cs. Равномерная сетка ячеек для запросов «кто в радиусе» без аллокаций (списки ячеек берутся из пула). Поле боя при этом остаётся непрерывным — хэш только ускоряет поиск, не «квантует» позиции.

Где он уже нужен, а где ещё нет. Перестраивается каждый тик после движения. Используется для AOE-запросов (QueryUnitsInRadius). А вот таргетинг «ближайший враг» пока бегает простым перебором O(n²) — и это осознанно: на десятках юнитов перебор дешевле, чем запрос к хэшу. Перевод таргетинга на SpatialHash помечен TODO в коде на случай роста числа юнитов (Фаза 3).


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

  • Два мира: иммутабельный SO-контент (Data) и изменяемый рантайм (Combat). SO читают, рантайм меняют.
  • StatType — фиксированные номера (Unity-сериализация); Stats — формула из трёх слоёв модификаторов, сгруппированных по источнику, с dirty-кэшем.
  • Урон — чистый статический пайплайн (DamageRequestDamageResult); вампиризм/реген — слоем выше.
  • SpatialHash ускоряет пространственные запросы без аллокаций; таргетинг пока перебором — намеренно.