Статус: Реализовано (2026-07-11, ветка feat/content-framework, пакеты 0–7, 221/221 EditMode). Дизайн — ред. 3; отступления реализации — §12, полный changelog — 07 §2.7.


Архитектура слоя данных: какие ScriptableObject-типы нужны, что в них хранится, как контент идентифицируется, загружается, валидируется и авторится. Покрывает roadmap Фазу 4 «Контент-каркас» (01. Roadmap реализации) и закладывает швы под Фазы 5–7. Основа: инвентаризация кода + внешний ресёрч (2026-07-10); ред. 2 — по замечаниям Макса (Trait/Consequence/TagData, предметы двух скоупов, текстовые ивенты, GameConfig); ред. 3 — иерархия UnitDataRelicData/EnemyData и Unity Animation с маркерами-событиями уже в Фазе 4. Связано: 2. Сохранения · 5. Технологический стек · 6. Боевая модель · 7. Флоу забега · 8. Steam Workshop


1. Принципы: три слоя данных

СлойЧтоФорматКто читает
АвторингИммутабельные определения контентаScriptableObject (нативная Unity-сериализация)Редактор, фабрики на старте боя
СимуляцияРантайм-состояние бояplain C# (POCO/структуры), собирается («бейкается») из SO при спавнеДетерминированный сим
ПерсистентностьСейвы, Workshop-UGCDTO + JSON/ES3, schemaVersionСлой сейвов, Workshop-лоадер

Слои ссылаются друг на друга только по строковому content id (§2). Уже действующие правила (вики 5 §«Моделирование контента») остаются: SO никогда не мутируется в рантайме; рантайм-стейт — в POCO; поведение — полиморфные plain-классы через [SerializeReference].

Как «добавляется эффект» (напоминание модели): эффекты — НЕ enum. EffectData — ассет-контейнер, собранный из [SerializeReference]-компонентов поведения (15 классов в Combat/Effects/Components: DoT, щит, стелс, шипы…). Прикрепить эффект к чему-либо = сослаться на EffectData-ассет (через пикер). Новый вид поведения = новый компонент-класс в коде + композиция в ассете. Это уже работает у 7 реликвий и 15 эффектов.

Жёсткие запреты (решения по итогам ресёрча):

  1. Odin Serializer запрещён (SerializedScriptableObject / SerializedMonoBehaviour — нигде). Причины: бинарный blob в .asset (недиффабельно в git), AOT-обвязка под IL2CPP, конфликты с prefab-overrides и netcode, несовместимость с Workshop-модами. Odin = только отрисовка инспектора, атрибуты, редакторные окна. Словарь в ассете = список пар + рантайм-словарь при бейке.
  2. AnimationCurve.Evaluate не вызывается из тика симуляции. Кривые — авторинг-формат: при бейке сэмплируются в таблицу значений (прецедент — Photon Quantum, у них AnimationCurve официально «non-deterministic type»). Дискретный аргумент (уровень/этаж) → массив значений; непрерывный → lookup-таблица + своя интерполяция.
  3. Конфиг-SO read-only для рантайма: сим работает со снапшотом, снятым на старте боя (§4). Правка SO в play mode сама по себе бой не меняет — и это защита детерминизма, а не недостаток.
  4. [MovedFrom]-дисциплина: переименование/перенос класса, сериализуемого через [SerializeReference] (компоненты эффектов, ивент-пейлоады), без атрибута [MovedFrom(...)] из UnityEngine.Scripting.APIUpdating молча обнуляет данные во всех ассетах. Правило: при любом rename — атрибут; типы, на которые есть данные, не удаляются без миграции. Не сохранять ассет, пока тип в состоянии missing.

2. Идентификаторы и конвенции ключей

2.1 ContentDefinition — базовый класс всех контент-SO

public abstract class ContentDefinition : ScriptableObject
{
    [SerializeField] private string _id;   // канонический content id
    public string Id => _id;
}

Общий родитель с id в основе — все контент-SO наследуются от него; реестр, валидация, лок/аудио-ключи и менеджер работают с любым наследником единообразно.

2.2 Формат id

  • domain.name, латиница, lower_snake внутри имени: relic.cryomancer, effect.frozen, trait.iron_will, ai_preset.aggressive, encounter.act1_wolves.
  • Домены: relic · enemy · vessel · effect · ability · trait · item · guildmaster · consequence · ai_preset · tag · run_mod · encounter · event · reward · act · fate (зарезервирован, post-festival).
  • Правила стабильности: id, попавший в сейв/реплей/чужой Workshop-item, не переименовывается и не переиспользуется под другой смысл. Имя .asset-файла = Cryomancer.asset — свободно, id живёт в данных, а не выводится из имени/пути.

2.3 Жизненный цикл id (решение)

  1. Автогенерация при создании — id заполняется один раз: domain — по типу SO, name — из имени ассета в lower_snake (Cryomancer.assetrelic.cryomancer). Работает и в менеджере (§9), и при создании через CreateAssetMenu (добивается в OnValidate, если поле пустое).
  2. НЕ синхронизируется с именем ассета после создания. Переименование .asset id не меняет — иначе rename ломал бы сейвы/реплеи/Workshop, и весь смысл стабильного id терялся бы.
  3. В инспекторе read-only + явная разблокировка (кнопка «Edit Id» с предупреждением). До выхода контента «в мир» (сейвы игроков, Workshop) id можно править свободно; после — только с миграцией.
  4. Дубликаты: при создании/дубликации — авто-уникализация суффиксом (relic.cryomancer_2 — placeholder до осмысленного имени); рукотворный дубль (copy-paste файла) ловится валидацией §8 → красный CI + подсветка в менеджере. Молчаливого «само переименуется» нет — дубль это ошибка, о которой надо знать.

2.4 Производные ключи (конвенция, не поля)

КлючШаблонПример
Локализация: имя{id}.namerelic.cryomancer.name
Локализация: описание{id}.descrelic.cryomancer.desc
Локализация: прочее{id}.{suffix}event.blood_altar.choice1
Аудио{id}.{action}relic.cryomancer.attack (§7)

Поле DisplayNameKey из RelicData/VesselData удаляется — ключ всегда выводится из Id (существующие ассеты уже следуют шаблону). Локализованное имя в SO не хранится вовсе — оно живёт в таблицах (§6); в инспекторе при этом имя/описание видны и редактируются (§6, editor-помощник).


3. Каталог SO

Все контент-типы — в Guildmaster.Data (asmdef без изменений), наследуют ContentDefinition, [CreateAssetMenu(menuName = "Guildmaster/…")]. Ассеты — Assets/_Project/ScriptableObjects/{Domain}/ (folder-per-type).

3.0 TagData — информационный тег (сквозной для тултипов/фильтров)

ПолеТипСмысл
Idstringtag.* (tag.role_tank, tag.dmg_magic, tag.style_aggressive)
Categoryenum TagCategoryRole / DamageType / Playstyle / Mechanic / … — для группировки в UI
IconSpriteИконка тега (опционально)
  • Название и тултип тега — по конвенции {id}.name / {id}.desc → теги сами локализуемый контент.
  • Авто-теги: выводятся из данных маппингом (например RelicData.DamageType == Magictag.dmg_magic) — не хранятся в ассете, считаются при отображении. Ручные теги: поле InfoTags: TagData[] у контент-типов (роль, стиль игры).
  • Не путать с боевыми EffectTag-флагами (Frozen, Marked…) — те остаются сим-логикой; TagData — чисто информационный слой UI.

3.1 Боевые юниты: UnitDataRelicData / EnemyData

Всё, что выходит на арену, описывается общим боевым китом UnitData; реликвии и враги — наследники с разной метой. Реликвии тащат мета-поля забега (редкость, проклятия, пресеты игрока), врагам они не нужны; у врагов своя мета (очки опасности, награда), не нужная реликвиям. Сим и RuntimeUnitFactory/BattleSetupBuilder работают с базовым UnitData — им всё равно, кто перед ними.

UnitData — боевой кит (abstract, общий родитель)

Поля — текущий боевой состав RelicData (сводно): DamageType/AttackType/ResourceType; ссылка на UnitVisual; форма и эффекты автоатаки; атака-в-движении; ResourceOnHit; стат-блок StatModifier[] (отличия от базы StatsConfig — так уже работает); GrantedEffects: EffectData[]; Abilities: AbilityData[] (inline — так «прикрепляются скиллы»).

ПолеТипСмысл
Id (base)stringдомен по наследнику: relic.* / enemy.*
боевой китсм. вышепереезжает из RelicData без изменений семантики
Ai НОВОЕAIPresetDataДефолтное поведение юнита (у врагов — единственное)
Icon НОВОЕSpriteИконка для UI
InfoTags НОВОЕTagData[]Ручные теги (роль, стиль); авто-теги — из DamageType и др. (§3.0)

Атака-в-движении и подобные капабилити пока остаются полями кита (рабочий код Фазы 3). Концептуально правильнее «капабилити = пассивный эффект» — переводим на компонент эффекта, когда появится первый не-юнитовый источник (предмет/трейт, дающий атаку-в-движении). Зафиксировано в §11 как плановый рефактор.

RelicData : UnitData — реликвия («чемпион», мета игрока)

ПолеТипСмысл
Rarity НОВОЕenum RelicRarityCommon / Cursed / Divine (GDD 5); у среза — все Common
RunEffects НОВОЕEffectData[]Нагрузка на весь забег (штрафы Cursed / бонусы Divine); у Common пусто. Чисто статовый штраф = EffectData со StatModifierComponent и бессрочной длительностью — отдельный массив модификаторов не нужен
AltAiPresets НОВОЕAIPresetData[]Альтернативные пресеты на выбор игрока в Prep (дефолт — Ai из базы); inline-Ai семи реликвий мигрирует в AIPresetData (§3.2)
ExtendedZoneAccessестьДоступ к Extended-зонам расстановки — специфика игрока, остаётся у реликвии

EnemyData : UnitData — вражеский юнит (мета врага)

ПолеТипСмысл
ThreatPointsint«Очки опасности» — ГД-метрика для превью опасности боя (§3.3 EncounterData) и бюджетов генерации; сим её не читает
GoldBountyintЗолото за убийство/бой (если по дизайну; черновик)

Миграция: 7 текущих реликвий остаются RelicData (поля переезжают в базу — для .asset это смена скрипта с сохранением сериализации при аккуратном переносе полей). Враги среза авторятся как EnemyData — можно копией кита реликвии с ослабленными статами.

VesselData — «Сосуд» (архетип-шаблон / авторская заготовка)

ИзменениеТипСмысл
Id (base)stringvessel.*
StartingTraits НОВОЕTraitData[]Фиксированные трейты авторской заготовки (у чистого архетипа пусто — генератор раздаёт сам)
InfoTags НОВОЕTagData[]Информационные теги (стартовый пример «Сосуда»)

Модель: VesselData-ассеты — это и есть «готовые заготовки»: шаблон для генератора и/или авторский пресет. Конкретный «Сосуд» забега — сид-генерируемая сущность в RunState (POCO/DTO): имя, досье (пулы — §3.4 DossierPoolsConfig), трейты, последствия (травмы/закалки) — всё ссылками на trait.* / consequence.* id. Выбор готовой заготовки — по id через пикер (ValueDropdown из реестра), не через C#-enum: enum ломается при добавлении/удалении контента и закрывает дорогу модам.

EffectData — определение эффекта

Как есть: Id (привести к формату effect.*), EffectPolarity, EffectTag (маска), BaseDuration, StackRule + MaxStacks, CleanseTier + Unremovable, [SerializeReference] IEffectComponent[] Components, фабрика CreateRuntime.

ИзменениеТипСмысл
наследование ContentDefinitionвместо собственного поля Id
Icon НОВОЕSpriteИконка для бафф-бара HUD (опциональна: у скрытых/технических эффектов пустая)
InfoTags НОВОЕTagData[]Информационные теги для тултипов

AbilityData — способность (остаётся plain [Serializable], inline в RelicData/GuildmasterData)

Как есть: Id, Effects: EffectData[], кулдаун/стоимость, AbilityTargetMode, урон/AOE/хил, cast-условия, tagged-targeting, displacement. Не SO: способность уникальна для носителя, шаринга между реликвиями нет — выносить в ассет незачем. Id привести к ability.* (для аудио/лок-ключей и логов).

ИзменениеТипСмысл
InfoTags НОВОЕTagData[]Теги скилла для тултипов
VisualSlot НОВОЕenum SkillSlotКакой слот анимации UnitVisual играет каст (Skill1..Skill4, §3.1а)

UnitVisual — визуал юнита (переход на Unity Animation, слоты клипов)

Текущее (плоские поля: fps, спрайты Idle/Run/Attack/Death, attackHitFrames, кастомный плеер в UnitView) → AnimationClip-слоты + Animator:

ПолеТипСмысл
Idle / Run / Attack / DeathAnimationClipОбязательные слоты
HitAnimationClipРеакция на удар (опциональный)
SkillsAnimationClip[4]До 4 анимаций способностей; AbilityData.VisualSlot выбирает слот
PortraitSpriteСтатичный портрет (карточки/HUD), если отличен от Icon носителя

Механика:

  • Один общий базовый AnimatorController (фиксированные стейты = слоты); на юнита в рантайме собирается AnimatorOverrideController, подставляющий клипы из UnitVisual. Fps/loop живут внутри клипов.
  • Маркеры-события = данные, не колбэки. Кадры контакта/эффектов авторятся как AnimationEvent прямо в клипе (окно Animation). Код читает их из AnimationClip.events (доступно в рантайме билда; авторенные в редакторе события персистятся в ассете) и при бейке боя конвертирует event.time (сек) → тики сима фиксированной математикой. Сим владеет таймингом удара (windup), как и сейчас.
  • Рантайм-срабатывание событий отключено: Animator.fireEvents = false — клип играет, Unity никого не дёргает, «no receiver»-предупреждений нет. Презентация реагирует на события сима (как сейчас), не на Animation Events.
  • Синхронизация: сим задаёт длительность замаха в тиках → презентация подгоняет скорость стейта атаки так, чтобы маркер удара лёг на сим-тик удара (та же логика, что в текущем кастомном плеере).
  • Миграция: 2 существующих UnitVisual-ассета — спрайт-массивы конвертируются в клипы (editor-скрипт), attackHitFramesAnimationEvent-маркеры в клипе Attack.

Это пересмотр решения Фазы 3 «кадровый кастомный плеер» (ред. 3): маркеры-как-данные снимают детерминизм-возражение, а стандартные клипы дают нормальный авторинг (dopesheet), готовность к аутсорс-арту и скелетной анимации без смены схемы данных.

3.2 Новые — мета-контент (Фаза 4)

TraitData — трейт «Сосуда» (бывш. «перк»; GDD: выбор 1 из 3, положительные/отрицательные)

ПолеТипСмысл
Idstringtrait.*
Polarityenum TraitPolarityPositive / Negative
ModsStatModifier[]Статовая нагрузка
GrantedEffectsEffectData[]Эффект-нагрузка (трейты-триггеры)
SelectionWeightfloatВес в генерации «1 из 3»
ExclusiveGroupstringВзаимоисключение (пусто = нет); один трейт из группы на «Сосуда»
InfoTagsTagData[]Информационные теги

Переименование Перк → Trait тянет обновление глоссария GDD 0.4 и терминов в GDD 4/9 — сделать одновременно, пока лок-ключей нет.

ConsequenceData — последствие боя: травма или закалка (объединяет бывш. Injury/Mettle; GDD 9)

ПолеТипСмысл
Idstringconsequence.*
Polarityenum ConsequencePolarityInjury (травма, снимается лечением) / Mettle (закалка, постоянна)
ModsStatModifier[]Модификаторы «Сосуду»
GrantedEffectsEffectData[]Для сложных последствий (триггерные)
SeverityintТяжесть/ступень
HealCostGoldintЦена снятия в «Лечении» (используется только при Injury)
WeightfloatВес при выдаче
InfoTagsTagData[]Информационные теги

AIPresetData — пресет поведения (roadmap: «2–3 пресета на архетип»)

ПолеТипСмысл
Idstringai_preset.*
ProfileAIProfileСуществующий plain-класс (Filter/Score/Override/Retreat/Kite) — без изменений
ArchetypeTagsTagData[]Каким реликвиям подходит (для пикеров/валидации)

Миграция: inline-Ai семи реликвий переезжает в 7 стартовых AIPresetData; дефолт юнита — UnitData.Ai, альтернативы игрока — RelicData.AltAiPresets. Игрок в Prep выбирает пресет; кастомные профили игрока (редактор AI, post-festival) — та же структура AIProfile, но в RunState/файле, не SO.

GuildmasterData — гильдмастер (GDD 4)

ПолеТипСмысл
Idstringguildmaster.*
SpellsAbilityData[]Начальные заклинания ГМ (реюз AbilityData: кулдаун/стоимость/таргетинг есть)
ModsStatModifier[]Модификаторы гильдии (пассивный бонус всей команде)
UniqueEffectsEffectData[]Уникальные особенности ГМ — эффект-нагрузка на забег/бой
StartingRelicIdsstring[]Начальные реликвии (id, пикер из реестра)
StartingGoldintСтартовый ресурс забега
PortraitSpriteПортрет для выбора/HUD
InfoTagsTagData[]Информационные теги (стиль ГМ)

ItemData — предмет (два скоупа в одном типе)

ПолеТипСмысл
Idstringitem.*
Scopeenum ItemScopeVessel (на персонажа, до N слотов — N в GameConfig, §3.4) / Party (на всю команду/бой)
ModsStatModifier[]Статовая нагрузка (носителю при Vessel, всей команде при Party)
GrantedEffectsEffectData[]Эффект-нагрузка (аналогично по скоупу)
ActiveAbilityAbilityDataАктивка предмета (зелье лечения и т.п.); пусто = пассивный предмет
ChargesintЗаряды активки на бой/забег (0 = без ограничения)
CostintБазовая цена в магазине
ShopWeightfloatВес в пуле магазина
IconSpriteИконка
InfoTagsTagData[]Информационные теги

Один SO-тип с Scope, а не два: поля совпадают почти полностью, а UI/магазин/сейв работают с «предметом вообще». Разделим только если поля разъедутся (например, у Party-предметов появятся правила-условия боя через собственные [SerializeReference]-компоненты — тогда выделим). Нейминг Party-предметов игроку («Знамя»/Banner — предложение, гильдейская фантазия без конфликта с «Реликвией») — §11.

RunModifierData — модификатор забега (заготовка; ascension/pact-механика)

ПолеТипСмысл
Idstringrun_mod.*
Targetenum RunModTargetPlayers / Enemies / Economy / Rules — на кого действует
ModsStatModifier[]Статовая нагрузка на цель
GrantedEffectsEffectData[]Эффект-нагрузка на цель
RewardMultfloatМножитель награды за взятый модификатор (если по дизайну)
TierintСтупень (кумулятивные уровни а-ля Ascension)
InfoTagsTagData[]Информационные теги

Черновик: точная механика модификаторов забега не задизайнена — поля минимальны, применяются тем же resolve-механизмом при бейке, что и DifficultyConfig. Расширение — [SerializeReference]-правила, когда появится дизайн.

3.3 Новые — структура забега (нужны Фазе 5; типы и id-домены закладываем сейчас, наполнение — по мере готовности флоу)

EncounterData — состав боя

ПолеТипСмысл
Idstringencounter.*
Tierenum EncounterTierCommon / Elite / Finalist / Special. Special — только из ивентов, на карте сам не спавнится (валидация §8)
UnitsEncounterUnit[]{ EnemyId: string, Count: int, ZoneHint } — состав по строковым id enemy.* (§3.1)
ArenaIdstringАрена боя (prefab-per-arena, вики 10)
RewardTableIdstringТаблица наград
(вычисляемое) DangerScoreeditor-onlyПревью опасности = Σ EnemyData.ThreatPoints × Count по составу; показывается в инспекторе/менеджере, в сим не идёт. Позже — вход для генерации боёв по бюджету

ActMapConfig — карта акта: генерация и пулы (она же «MapData»)

ПолеТипСмысл
Idstringact.* (конфиг на акт)
PathLengthint min/maxДлина пути по узлам
Branchingint min/maxШирина графа
NodeWeights{ NodeType, float }[]Шансы типов узлов (Бой/Элитка/Магазин/Лечение/Ивент/Турнир)
EncounterPools{ Tier, string[] }Пулы encounter.* id по тирам (Special сюда нельзя — валидация)
EventPool{ EventId, Weight }[]Пул текстовых ивентов акта с весами («какой шанс какой ивент выпадет»)
GuaranteedNodesправилаГарантии (магазин/лечение перед финалистом и т.п.)
FinalistEncounterIdstringБой-финалист акта

TextEventData — текстовый ивент с выборами (СтС-стиль)

ПолеТипСмысл
Idstringevent.*; интро-текст — {id}.intro (конвенция)
ImageSpriteИллюстрация ивента
ChoicesTextEventChoice[]Inline [Serializable] (не отдельный SO — выборы не шарятся между ивентами)
InfoTagsTagData[]Информационные теги

TextEventChoice: { Suffix (лок-ключи {id}.{suffix}/{id}.{suffix}.result), Requirements: [SerializeReference] IEventRequirement[] (золото ≥ N, есть реликвия X…), Outcomes: [SerializeReference] IEventOutcome[] (±золото, реликвия/предмет/трейт/последствие, запуск Special-EncounterData…) }. Полиморфия — тот же паттерн, что компоненты эффектов (+[MovedFrom]-дисциплина).

ShopConfig — магазин

ПолеТипСмысл
Idstringshop.*
Entries{ ContentId, Weight, PriceMin, PriceMax }[]Пул товара (предметы/реликвии по id)
SlotCountintСлоты витрины
RerollCostint[] (таблица)Стоимость рероллов (кривая уже запечённая — §1 п.2)

RewardTableData — награды

ПолеТипСмысл
Idstringreward.*
Entries{ Kind (Gold/Relic/Item/Trait), Weight, AmountTable }[]Взвешенный лут

DifficultyConfig — сложность

ПолеТипСмысл
Tiers{ HpMult, DamageMult, GoldMult, … }[]Таблица множителей по тирам; ступени сверх этого — RunModifierData (§3.2), тот же resolve при бейке

DossierPoolsConfig — пулы досье «Сосуда» (GDD 10)

ПолеТипСмысл
Dreams / Characters / Stories / Secretsstring[]Лок-ключи вариантов; генератор тянет по сиду

3.4 Конфиги (глобальные параметры) — детали в §4

StatsConfig — базовые значения юнита (расширение существующего)

ПолеСтатусСмысл
ArmorConstantKесть, починитьКонстанта K формулы брони. Становится единственным источником: дублирующее поле _armorK на CombatLifetimeScope удаляется (сейчас SO-поле мёртвое — сим его не читает)
AttackSpeedMin / AttackSpeedMaxестьКлампы скорости атаки
Defaults: StatDefault[]есть, заполнитьБазовые значения статов юнита: обычные HP, скорость движения, дальность атаки и т.д. Реликвии задают отличия от базы своим стат-блоком

SimTuningConfig — тюнинг симуляции (новый; переезд SimConstants + сбор магических чисел)

ПолеДефолт (из кода)Смысл
AiTickRate10Частота переоценки AI, Гц
MaxAttackAnimTicks30Потолок длительности свинга автоатаки («хлёсткость»)
MinWindupTicks3Пол замаха (минимальный телеграф)
BodyRadiusPerSize0.575Радиус тела = Size × это (сепарация)
SeparationStrength0.5Доля перекрытия, устраняемая за тик
SeparationIterations1Проходов сепарации за тик
SeparationSameTeamScale0.35Множитель расталкивания союзников
ProjectileHitRadiusFactor0.25Радиус коллизии снаряда/хил-снаряда = доля Size (сейчас магическое число в 2 местах)
ProjectileDespawnMarginОтступ деспавна снаряда за границами арены (замена несвязанного ±200)
KiteFleeFactor0.6Фолбэк-полоса кайта: flee = range × это
DefaultPassiveThresholdPct0.2Дефолт порога пассивок (сейчас захардкожен в 3 местах)
GlobalSearchRadius500«Глобальный» радиус поиска целей (сейчас в 3 местах)

TickRate = 30 остаётся константой в коде — это фундамент детерминизма и контракт реплеев, а не балансная ручка.

GameConfig — общие дефолты игры (новый)

ПолеТипСмысл
DefaultMasterVolume / DefaultMusicVolume / DefaultSfxVolumefloatНачальные громкости (первый запуск)
DefaultLocalestringЛокаль по умолчанию (или auto из системы)
VesselItemSlotsint = 3Слоты предметов на персонажа
AutosaveEnabled и др. UX-дефолтыпо мере появленияСкринум и прочие «общие» первичные значения

Разграничение: GameConfig хранит дефолты пользовательских настроек и общие мета-правила; фактические настройки игрока живут в файле настроек (ES3), редактируются через меню и в SO не пишутся.

Feel/камера уже конфигурируемы на префабах (UnitView, HealthBarView, FloatingText) и риге камеры в сцене — их в SO не переносим.

3.5 Презентация

AudioCatalog — карта аудио (лежит в Presentation-слое; единственное место с FMOD-типами)

ПолеТипСмысл
Entries{ Key: string, Event: FMODUnity.EventReference }[]Ключ (§7) → событие FMOD; инспектор-пикер и сканер битых ссылок FMOD — бесплатно
Defaults{ Action: string, Event: EventReference }[]Фолбэки по действию (attack, hit, death, cast, ui)

Контент-SO не содержат аудио-полей вообще (§7). Позже по тому же образцу — VfxCatalog.

3.6 Инфраструктура

ContentDatabase — реестр всего контента

ПолеТипСмысл
SchemaVersionintВерсия контент-схемы (общая с DTO/Workshop-контрактом, вики 8)
Relics / Enemies / Vessels / Effects / Traits / Items / Guildmasters / Consequences / AiPresets / Tags / RunModifiers / …списки ссылокПо списку на домен; Фаза 5 добавляет свои

Рантайм-API — за интерфейсом:

public interface IContentDatabase
{
    T Get<T>(string id) where T : ContentDefinition;      // бросает при отсутствии — отсутствие контента = ошибка конфигурации
    bool TryGet<T>(string id, out T def) where T : ContentDefinition;
    IReadOnlyList<T> All<T>() where T : ContentDefinition;
}

Регистрация: [SerializeField] ContentDatabase на RootLifetimeScopeRegisterInstance<IContentDatabase> (официальный VContainer-паттерн для SO). Словарь id→def строится один раз при регистрации. Никакого Resources.Load, никаких статиков.


4. Конфиги: бейк, live-тюнинг, фикс-лист

4.1 Правила

  1. Бейк на старте боя: RuntimeUnitFactory/системы снимают снапшот значений конфигов в plain-структуры сима (расширение уже существующего пути SO → RuntimeUnit). Из тика SO не читается.
  2. Live-тюнинг = фича: правка конфиг-SO в play mode в редакторе персистится — это инструмент баланса. Применение к идущему бою — только явное (dev-команда re-bake / Odin-кнопка), при этом бой помечается tainted (реплей невалиден). В заголовок реплея — SchemaVersion + хеш баланс-значений.
  3. Один источник правды: дефолт значения живёт в ассете. Инициализаторы полей в C# — только сид для свежесозданного ассета (изменение инициализатора НЕ обновляет существующие .asset — известная ловушка сериализации). Фолбэки вида config == null ? 5f : config.X запрещены — отсутствие конфига падает громко при композиции контейнера.

4.2 Фикс-лист из инвентаризации (чинится при переносе, шаг 0 §10)

#ПроблемаГде
1StatsConfig.ArmorConstantK мёртв: сим читает CombatLifetimeScope._armorK со сцены — два источника правдыCombatLifetimeScope.cs:25
2PassiveThresholdPct = 0.2 захардкожен в 3 местахAIProfile.cs, BulwarkComponent.cs:64, DodgeComponent.cs:79
3«Глобальный радиус» 500f в 3 местахMarkTransferComponent.cs:16, AbilitySystem.cs:144, WhirlDashLandingComponent.cs:80
4Деспавн снарядов ±200 не связан с реальной ареной (14×7)ProjectileSystem.cs:135
5Радиус коллизии снаряда Size × 0.25 продублированAutoAttackSystem.cs:117,148
6Дефолт-статы тест-болванчика (MoveSpeed=2.5…) — отдельный источник правды рядом со StatsConfigGuildmasterCommands.cs:503
7Проверить: DamageNumberSpawner/DamageNumber — легаси-дубль FloatingText?Presentation/
8Рассинхрон код-дефолта и сцены: CombatFocusTarget._positionDamping 3 vs 4CombatFocusTarget.cs

5. Загрузка контента и Addressables (решение)

Контент — прямые ссылки через ContentDatabase; Addressables — только как плумбинг Unity Localization.

  • Localization без Addressables не работает (позиция Unity), но свои группы (Localization-*) ведёт автоматически — наш контент не затрагивается. Руками эти группы не трогать.
  • Геймплейный контент не помечается addressable. Главная грабля смешивания: ассет с прямой И addressable-ссылкой попадает в билд дважды как два разных объекта в памяти (у Unity помечено «Unfixable» в Analyze).
  • Обязательная настройка: Build Addressables on Player Build (+ контент-билд в CI-методе GameCI) — иначе классика «в редакторе работает, в билде “No translation found”». Play Mode Script — Use Asset Database (итерация в редакторе не меняется). Пакет поднять 2.3.1 → 2.3.16 (фиксы зависаний WaitForCompletion).
  • Швы на будущую миграцию (уже в дизайне): доступ к контенту только через IContentDatabase; string id; async-бут. Миграция потом = замена типа полей в одном реестре.
  • Триггеры пересмотра (любой): арт-контент ~0.5+ ГБ или измеримые проблемы память/загрузка; remote/DLC-план; ассет-моды бандлами. Для JSON-модов Workshop Addressables не нужен (и официальная позиция по безопасности: бинарные бандлы от пользователей не принимать).

6. Локализация

  • Unity Localization, EN + RU, String Tables. Интеграция — в этой фазе (пакет уже стоит).
  • В контент-SO — никаких LocalizedString-полей и никаких текстов: ключи выводятся из Id по конвенции §2.4. UI резолвит через сервис-обёртку (ILocalizationService поверх таблиц).
  • Как редактировать удобно:
    • штатное окно Localization Tables — все языки колонками рядом, поиск, массовая правка (spreadsheet-опыт);
    • editor-помощник в инспекторе (ContentDefinition-drawer в менеджере §9): поля «Name (EN) / Name (RU) / Desc (EN) / Desc (RU)» читают/пишут прямо в таблицы по ключу конвенции. Автор правит имя не выходя из карточки контента; в сам ассет ничего не сериализуется;
    • кнопка «Create missing keys» — досоздаёт недостающие entries для выбранного ассета/домена.
  • Правила ключей (индустриальные): ключ в использовании не переименовывается; смысл строки радикально поменялся → новый ключ (иначе чужие переводы молча устаревают); английский текст ключом не является.
  • Валидация (§8): для каждого контент-ассета есть {id}.name/{id}.desc в таблицах (какие суффиксы обязательны — по домену; у TextEventDataintro + по выбору на каждый choice).

7. Аудио

  • Контент-SO аудио-полей не содержат. Presentation слушает боевые события (MessagePipe) с id контента и резолвит звук через AudioCatalog (§3.5): точный ключ {contentId}.{action} → дефолт действия → тишина + лог.
  • Только реализация IAudioService (FMOD-адаптер, Фаза 7/9) знает FMOD-типы. Саунд-пасс = правка одного каталога; сим и данные не трогаются.
  • Валидация: каждый ключ, который презентация может запросить, либо есть в каталоге, либо покрыт дефолтом; FMOD-сканер ловит битые EventReference.

8. Валидация

Инструмент: EditMode-тесты в существующем CI-прогоне (Test Runner + AssetDatabase.FindAssets). Odin Validator не покупаем (решение 2026-07-10) — интерактивное окно не стоит зависимости CI-гейта от платного плагина; Odin-атрибуты ([Required], [MinValue], [ValidateInput]) при этом используем как первую линию в инспекторе.

Набор правил (падает CI, не плейтест):

  1. Id: непустой, формат domain.name, домен соответствует типу, без дубликатов по всей базе.
  2. ContentDatabase: каждый ассет контент-типа зарегистрирован; ссылки не-null.
  3. [SerializeReference]-списки: без null-элементов (следы missing types).
  4. Локализация: обязательные ключи по конвенции присутствуют в таблицах EN и RU.
  5. Диапазоны и кросс-инварианты: FleeAtHpPct < ReturnAtHpPct, AttackSpeedMin < Max, веса ≥ 0, Severity в диапазоне, Charges ≥ 0 и т.п.
  6. Конфиги: SO равен дефолтам на свежем домене (ловит утёкшие play-mode правки в коммитах — по diff’у).
  7. Ссылочная целостность по id: EncounterData.Units[].EnemyId существует, ShopConfig.Entries[].ContentId существует, GuildmasterData.StartingRelicIds существуют и т.д.
  8. UnitVisual: обязательные слоты (Idle/Run/Attack/Death) заполнены; AbilityData.VisualSlot указывает на непустой слот; клипы Attack/Skill содержат ≥1 маркер-событие, время маркера в пределах клипа.
  9. EncounterTier.Special не встречается в ActMapConfig.EncounterPools (только из ивентов).

9. Контент-менеджер (редакторное окно)

Решение: OdinMenuEditorWindow (у нас Odin 4.0.1.4 — линейка под Unity 6). Чистый UI Toolkit отклонён: в UITK-инспекторе нет встроенного type-picker для [SerializeReference] + задокументированные баги биндинга managed references; это недели работы ради того, что Odin рисует из коробки.

Состав:

  • Дерево по доменам (AddAllAssetsAtPath на папку домена): Relics / Effects / Traits / Items / AI Presets / Tags / Events / Configs / …; поиск-тулбар; справа — полный Odin-инспектор выбранного ассета (+ лок-поля Name/Desc, §6).
  • Узлы «Create New {Type}»: авто-путь folder-per-type, автогенерация id (§2.3), фокус на переименование.
  • Тулбар: Delete (с проверкой использований через Unity Search ref= → диалог со списком), Duplicate (с регенерацией id — обязательно), Validate (прогон правил §8 по выбранному).
  • Шов: вся CRUD-логика (пути, создание, удаление, дубликация, ref-check, генерация id) — в plain editor-сервисе без GUI-вызовов; окно — тонкая оболочка. Надоест IMGUI — шелл меняется на UITK + встроенный Odin-инспектор без переписывания логики.
  • День 0 (без кода, уже сейчас): Unity Search (Ctrl+K) — докнутое окно, Saved Searches по t:RelicData и др., табличный вид с редактируемыми колонками, ref= для поиска использований.

10. Порядок работ (видимыми шагами)

ШагЧтоЗачем виден прогресс
0Фикс-лист §4.2 (дубли, мёртвый ArmorK, ±200)Маленький чистый PR, база не шатается
1ContentDefinition + жизненный цикл id §2.3 + Id всем существующим (миграция DisplayNameKey) + ContentDatabase/IContentDatabase в DI + валидация-тесты §8 (1–3, 5)Каркас id/реестра; CI ловит дубликаты с первого дня
2SimTuningConfig + расширение StatsConfig + GameConfig + бейк-путь + dev-команда re-bakeВсе глобальные ручки — в конфиг-SO, тюнинг без перекомпиляции
3Иерархия UnitDataRelicData/EnemyData (перенос кита в базу, миграция 7 ассетов) + TagData + новые мета-SO: TraitData, ConsequenceData, AIPresetData (+ миграция inline-AI), GuildmasterData, ItemData, RunModifierData; InfoTags-поля; обновление глоссария GDD 0.4 (Trait/Consequence)Контент-типы Фазы 4 готовы к наполнению
4UnitVisual → Unity Animation: клипы-слоты, базовый AnimatorController + override, бейк-ридер маркеров (clip.events → тики), fireEvents = false, конверсия 2 ассетов, AbilityData.VisualSlotНормальный анимационный авторинг + слоты под скиллы, детерминизм не тронут
5Контент-менеджер §9 (+ Unity Search-сетап сразу)Авторинг ускоряется до начала массового наполнения
6Localization: таблицы EN/RU + сервис + editor-помощник Name/Desc + Addressables-плумбинг §5 + валидация ключей (правило 4)Локализация перестаёт быть долгом
7AudioCatalog + конвенция аудио-ключей (события уже летят; сами звуки — Фаза 7/9)Саунд-шов готов к приходу FMOD-контента
8Ф5-типы §3.3 (EncounterData, ActMapConfig, TextEventData…) — по мере строительства флоу забегаНе раньше, чем нужны

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

  • Нейминг Party-предметов для игрока: «Знамя» (Banner)? «Талисман»? — «Артефакт»/«Реликвия» заняты. Решение при первом контенте.
  • Капабилити-как-эффект (атака-в-движении и т.п.): переводим на компоненты эффектов при первом не-юнитовом источнике капабилити; до тех пор — поля UnitData (рабочий код Ф3 с тестами не трогаем без причины).
  • EnemyData, состав меты: GoldBounty и прочие поля награды — черновик, уточнить при дизайне экономики боя.
  • Формат id в старых доках: вики 2 использует пример relic_flame_sword — обновить на relic.flame_sword.
  • Odin 4.0.1.4 → 4.0.2.x: не блокер; обновить при случае (стабильный патч-стрим), апдейт бесплатный по перпетуальной лицензии.
  • VfxCatalog: по образцу AudioCatalog, когда дойдём до VFX (Фаза 7).
  • Судьбы (fate.*): только резерв id-домена; дизайн и данные — post-festival.

12. Реализовано (Фаза 4) — отступления от дизайна

Каркас построен на ветке feat/content-framework (пакеты 0–7, 221/221 EditMode зелёные, 2026-07-11). Дизайн выше действует как есть; ниже — сознательные отступления, принятые при реализации (детали и обоснование — 07 §2.7).

#Дизайн говорилСделаноПочему
1ContentDatabase — реестр по доменам (§3.6)Единый плоский список всего контентаРеестр/валидация/менеджер и так работают с любым наследником ContentDefinition единообразно; per-domain разбивка — преждевременная структура на 28 ассетах. Шов на дробление остаётся.
2DisplayNameKey-поле на контентеУбрано; имя/описание — только через лок-ключи {id}.name/{id}.desc (§6)Одна конвенция ключей вместо двух источников имени.
3StatsConfig — только глобальные константы+ база статов юнита (Defaults: MaxHP 120, AAD 12, AS 1, AR 1.5, MS 3, PhysArmor 4); 7 реликвий переписаны как Flat-диффы от базы (эффективные статы сохранены)Реальная общая база вместо «каждая реликвия задаёт всё с нуля». Баланс-числа провизорные — Макс тюнит позже.
4IAudioService в Game.ServicesПереехал в Core.Audio (зеркалит ILocalizationService)Аудио-презентер зовёт сервис, не ссылаясь на composite-root Game. Реализация (UnityAudioService) осталась в Game.
5Все глобальные ручки — в конфиг-SO (§4)AiTickRate/MaxAttackAnimTicks/MinWindupTicks оставлены в SimConstants; DefaultPassiveThresholdPct — const в AIProfileСтруктурные/чисто-тайминговые величины с тестами (AttackTiming); не балансные ручки. Шов бейка готов, перенос — при первой нужде тюнить их без перекомпиляции.
6Деспаун снаряда по краю ареныПо краю камеро-видимой зоны (CameraZone + margin)Решение Макса: снаряд не должен гаснуть в видимой части боя.

Порядок §10 пройден целиком (0→7). Пакет 4 (Unity Animation) — жёсткий визуальный гейт, принят Максом в play-mode вживую (монк + болванчики анимируются, NPE-фиксы в MovementSystem).