Статус: Реализовано (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 — иерархия
UnitData→RelicData/EnemyDataи Unity Animation с маркерами-событиями уже в Фазе 4. Связано: 2. Сохранения · 5. Технологический стек · 6. Боевая модель · 7. Флоу забега · 8. Steam Workshop
1. Принципы: три слоя данных
| Слой | Что | Формат | Кто читает |
|---|---|---|---|
| Авторинг | Иммутабельные определения контента | ScriptableObject (нативная Unity-сериализация) | Редактор, фабрики на старте боя |
| Симуляция | Рантайм-состояние боя | plain C# (POCO/структуры), собирается («бейкается») из SO при спавне | Детерминированный сим |
| Персистентность | Сейвы, Workshop-UGC | DTO + JSON/ES3, schemaVersion | Слой сейвов, Workshop-лоадер |
Слои ссылаются друг на друга только по строковому content id (§2). Уже действующие правила (вики 5 §«Моделирование контента») остаются: SO никогда не мутируется в рантайме; рантайм-стейт — в POCO; поведение — полиморфные plain-классы через [SerializeReference].
Как «добавляется эффект» (напоминание модели): эффекты — НЕ enum. EffectData — ассет-контейнер, собранный из [SerializeReference]-компонентов поведения (15 классов в Combat/Effects/Components: DoT, щит, стелс, шипы…). Прикрепить эффект к чему-либо = сослаться на EffectData-ассет (через пикер). Новый вид поведения = новый компонент-класс в коде + композиция в ассете. Это уже работает у 7 реликвий и 15 эффектов.
Жёсткие запреты (решения по итогам ресёрча):
- Odin Serializer запрещён (
SerializedScriptableObject/SerializedMonoBehaviour— нигде). Причины: бинарный blob в.asset(недиффабельно в git), AOT-обвязка под IL2CPP, конфликты с prefab-overrides и netcode, несовместимость с Workshop-модами. Odin = только отрисовка инспектора, атрибуты, редакторные окна. Словарь в ассете = список пар + рантайм-словарь при бейке. AnimationCurve.Evaluateне вызывается из тика симуляции. Кривые — авторинг-формат: при бейке сэмплируются в таблицу значений (прецедент — Photon Quantum, у нихAnimationCurveофициально «non-deterministic type»). Дискретный аргумент (уровень/этаж) → массив значений; непрерывный → lookup-таблица + своя интерполяция.- Конфиг-SO read-only для рантайма: сим работает со снапшотом, снятым на старте боя (§4). Правка SO в play mode сама по себе бой не меняет — и это защита детерминизма, а не недостаток.
[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 (решение)
- Автогенерация при создании — id заполняется один раз:
domain— по типу SO,name— из имени ассета в lower_snake (Cryomancer.asset→relic.cryomancer). Работает и в менеджере (§9), и при создании черезCreateAssetMenu(добивается вOnValidate, если поле пустое). - НЕ синхронизируется с именем ассета после создания. Переименование
.assetid не меняет — иначе rename ломал бы сейвы/реплеи/Workshop, и весь смысл стабильного id терялся бы. - В инспекторе read-only + явная разблокировка (кнопка «Edit Id» с предупреждением). До выхода контента «в мир» (сейвы игроков, Workshop) id можно править свободно; после — только с миграцией.
- Дубликаты: при создании/дубликации — авто-уникализация суффиксом (
relic.cryomancer_2— placeholder до осмысленного имени); рукотворный дубль (copy-paste файла) ловится валидацией §8 → красный CI + подсветка в менеджере. Молчаливого «само переименуется» нет — дубль это ошибка, о которой надо знать.
2.4 Производные ключи (конвенция, не поля)
| Ключ | Шаблон | Пример |
|---|---|---|
| Локализация: имя | {id}.name | relic.cryomancer.name |
| Локализация: описание | {id}.desc | relic.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 — информационный тег (сквозной для тултипов/фильтров)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | tag.* (tag.role_tank, tag.dmg_magic, tag.style_aggressive) |
Category | enum TagCategory | Role / DamageType / Playstyle / Mechanic / … — для группировки в UI |
Icon | Sprite | Иконка тега (опционально) |
- Название и тултип тега — по конвенции
{id}.name/{id}.desc→ теги сами локализуемый контент. - Авто-теги: выводятся из данных маппингом (например
RelicData.DamageType == Magic→tag.dmg_magic) — не хранятся в ассете, считаются при отображении. Ручные теги: полеInfoTags: TagData[]у контент-типов (роль, стиль игры). - Не путать с боевыми
EffectTag-флагами (Frozen,Marked…) — те остаются сим-логикой;TagData— чисто информационный слой UI.
3.1 Боевые юниты: UnitData → RelicData / 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 RelicRarity | Common / 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 — вражеский юнит (мета врага)
| Поле | Тип | Смысл |
|---|---|---|
ThreatPoints | int | «Очки опасности» — ГД-метрика для превью опасности боя (§3.3 EncounterData) и бюджетов генерации; сим её не читает |
GoldBounty | int | Золото за убийство/бой (если по дизайну; черновик) |
Миграция: 7 текущих реликвий остаются
RelicData(поля переезжают в базу — для.assetэто смена скрипта с сохранением сериализации при аккуратном переносе полей). Враги среза авторятся какEnemyData— можно копией кита реликвии с ослабленными статами.
VesselData — «Сосуд» (архетип-шаблон / авторская заготовка)
| Изменение | Тип | Смысл |
|---|---|---|
Id (base) | string | vessel.* |
StartingTraits НОВОЕ | TraitData[] | Фиксированные трейты авторской заготовки (у чистого архетипа пусто — генератор раздаёт сам) |
InfoTags НОВОЕ | TagData[] | Информационные теги (стартовый пример «Сосуда») |
Модель:
VesselData-ассеты — это и есть «готовые заготовки»: шаблон для генератора и/или авторский пресет. Конкретный «Сосуд» забега — сид-генерируемая сущность вRunState(POCO/DTO): имя, досье (пулы — §3.4DossierPoolsConfig), трейты, последствия (травмы/закалки) — всё ссылками на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 / Death | AnimationClip | Обязательные слоты |
Hit | AnimationClip | Реакция на удар (опциональный) |
Skills | AnimationClip[4] | До 4 анимаций способностей; AbilityData.VisualSlot выбирает слот |
Portrait | Sprite | Статичный портрет (карточки/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-скрипт),attackHitFrames→AnimationEvent-маркеры в клипе Attack.
Это пересмотр решения Фазы 3 «кадровый кастомный плеер» (ред. 3): маркеры-как-данные снимают детерминизм-возражение, а стандартные клипы дают нормальный авторинг (dopesheet), готовность к аутсорс-арту и скелетной анимации без смены схемы данных.
3.2 Новые — мета-контент (Фаза 4)
TraitData — трейт «Сосуда» (бывш. «перк»; GDD: выбор 1 из 3, положительные/отрицательные)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | trait.* |
Polarity | enum TraitPolarity | Positive / Negative |
Mods | StatModifier[] | Статовая нагрузка |
GrantedEffects | EffectData[] | Эффект-нагрузка (трейты-триггеры) |
SelectionWeight | float | Вес в генерации «1 из 3» |
ExclusiveGroup | string | Взаимоисключение (пусто = нет); один трейт из группы на «Сосуда» |
InfoTags | TagData[] | Информационные теги |
Переименование Перк → Trait тянет обновление глоссария GDD 0.4 и терминов в GDD 4/9 — сделать одновременно, пока лок-ключей нет.
ConsequenceData — последствие боя: травма или закалка (объединяет бывш. Injury/Mettle; GDD 9)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | consequence.* |
Polarity | enum ConsequencePolarity | Injury (травма, снимается лечением) / Mettle (закалка, постоянна) |
Mods | StatModifier[] | Модификаторы «Сосуду» |
GrantedEffects | EffectData[] | Для сложных последствий (триггерные) |
Severity | int | Тяжесть/ступень |
HealCostGold | int | Цена снятия в «Лечении» (используется только при Injury) |
Weight | float | Вес при выдаче |
InfoTags | TagData[] | Информационные теги |
AIPresetData — пресет поведения (roadmap: «2–3 пресета на архетип»)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | ai_preset.* |
Profile | AIProfile | Существующий plain-класс (Filter/Score/Override/Retreat/Kite) — без изменений |
ArchetypeTags | TagData[] | Каким реликвиям подходит (для пикеров/валидации) |
Миграция: inline-
Aiсеми реликвий переезжает в 7 стартовыхAIPresetData; дефолт юнита —UnitData.Ai, альтернативы игрока —RelicData.AltAiPresets. Игрок в Prep выбирает пресет; кастомные профили игрока (редактор AI, post-festival) — та же структураAIProfile, но вRunState/файле, не SO.
GuildmasterData — гильдмастер (GDD 4)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | guildmaster.* |
Spells | AbilityData[] | Начальные заклинания ГМ (реюз AbilityData: кулдаун/стоимость/таргетинг есть) |
Mods | StatModifier[] | Модификаторы гильдии (пассивный бонус всей команде) |
UniqueEffects | EffectData[] | Уникальные особенности ГМ — эффект-нагрузка на забег/бой |
StartingRelicIds | string[] | Начальные реликвии (id, пикер из реестра) |
StartingGold | int | Стартовый ресурс забега |
Portrait | Sprite | Портрет для выбора/HUD |
InfoTags | TagData[] | Информационные теги (стиль ГМ) |
ItemData — предмет (два скоупа в одном типе)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | item.* |
Scope | enum ItemScope | Vessel (на персонажа, до N слотов — N в GameConfig, §3.4) / Party (на всю команду/бой) |
Mods | StatModifier[] | Статовая нагрузка (носителю при Vessel, всей команде при Party) |
GrantedEffects | EffectData[] | Эффект-нагрузка (аналогично по скоупу) |
ActiveAbility | AbilityData | Активка предмета (зелье лечения и т.п.); пусто = пассивный предмет |
Charges | int | Заряды активки на бой/забег (0 = без ограничения) |
Cost | int | Базовая цена в магазине |
ShopWeight | float | Вес в пуле магазина |
Icon | Sprite | Иконка |
InfoTags | TagData[] | Информационные теги |
Один SO-тип с
Scope, а не два: поля совпадают почти полностью, а UI/магазин/сейв работают с «предметом вообще». Разделим только если поля разъедутся (например, у Party-предметов появятся правила-условия боя через собственные[SerializeReference]-компоненты — тогда выделим). Нейминг Party-предметов игроку («Знамя»/Banner — предложение, гильдейская фантазия без конфликта с «Реликвией») — §11.
RunModifierData — модификатор забега (заготовка; ascension/pact-механика)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | run_mod.* |
Target | enum RunModTarget | Players / Enemies / Economy / Rules — на кого действует |
Mods | StatModifier[] | Статовая нагрузка на цель |
GrantedEffects | EffectData[] | Эффект-нагрузка на цель |
RewardMult | float | Множитель награды за взятый модификатор (если по дизайну) |
Tier | int | Ступень (кумулятивные уровни а-ля Ascension) |
InfoTags | TagData[] | Информационные теги |
Черновик: точная механика модификаторов забега не задизайнена — поля минимальны, применяются тем же resolve-механизмом при бейке, что и
DifficultyConfig. Расширение —[SerializeReference]-правила, когда появится дизайн.
3.3 Новые — структура забега (нужны Фазе 5; типы и id-домены закладываем сейчас, наполнение — по мере готовности флоу)
EncounterData — состав боя
| Поле | Тип | Смысл |
|---|---|---|
Id | string | encounter.* |
Tier | enum EncounterTier | Common / Elite / Finalist / Special. Special — только из ивентов, на карте сам не спавнится (валидация §8) |
Units | EncounterUnit[] | { EnemyId: string, Count: int, ZoneHint } — состав по строковым id enemy.* (§3.1) |
ArenaId | string | Арена боя (prefab-per-arena, вики 10) |
RewardTableId | string | Таблица наград |
(вычисляемое) DangerScore | editor-only | Превью опасности = Σ EnemyData.ThreatPoints × Count по составу; показывается в инспекторе/менеджере, в сим не идёт. Позже — вход для генерации боёв по бюджету |
ActMapConfig — карта акта: генерация и пулы (она же «MapData»)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | act.* (конфиг на акт) |
PathLength | int min/max | Длина пути по узлам |
Branching | int min/max | Ширина графа |
NodeWeights | { NodeType, float }[] | Шансы типов узлов (Бой/Элитка/Магазин/Лечение/Ивент/Турнир) |
EncounterPools | { Tier, string[] } | Пулы encounter.* id по тирам (Special сюда нельзя — валидация) |
EventPool | { EventId, Weight }[] | Пул текстовых ивентов акта с весами («какой шанс какой ивент выпадет») |
GuaranteedNodes | правила | Гарантии (магазин/лечение перед финалистом и т.п.) |
FinalistEncounterId | string | Бой-финалист акта |
TextEventData — текстовый ивент с выборами (СтС-стиль)
| Поле | Тип | Смысл |
|---|---|---|
Id | string | event.*; интро-текст — {id}.intro (конвенция) |
Image | Sprite | Иллюстрация ивента |
Choices | TextEventChoice[] | Inline [Serializable] (не отдельный SO — выборы не шарятся между ивентами) |
InfoTags | TagData[] | Информационные теги |
TextEventChoice: { Suffix (лок-ключи {id}.{suffix}/{id}.{suffix}.result), Requirements: [SerializeReference] IEventRequirement[] (золото ≥ N, есть реликвия X…), Outcomes: [SerializeReference] IEventOutcome[] (±золото, реликвия/предмет/трейт/последствие, запуск Special-EncounterData…) }. Полиморфия — тот же паттерн, что компоненты эффектов (+[MovedFrom]-дисциплина).
ShopConfig — магазин
| Поле | Тип | Смысл |
|---|---|---|
Id | string | shop.* |
Entries | { ContentId, Weight, PriceMin, PriceMax }[] | Пул товара (предметы/реликвии по id) |
SlotCount | int | Слоты витрины |
RerollCost | int[] (таблица) | Стоимость рероллов (кривая уже запечённая — §1 п.2) |
RewardTableData — награды
| Поле | Тип | Смысл |
|---|---|---|
Id | string | reward.* |
Entries | { Kind (Gold/Relic/Item/Trait), Weight, AmountTable }[] | Взвешенный лут |
DifficultyConfig — сложность
| Поле | Тип | Смысл |
|---|---|---|
Tiers | { HpMult, DamageMult, GoldMult, … }[] | Таблица множителей по тирам; ступени сверх этого — RunModifierData (§3.2), тот же resolve при бейке |
DossierPoolsConfig — пулы досье «Сосуда» (GDD 10)
| Поле | Тип | Смысл |
|---|---|---|
Dreams / Characters / Stories / Secrets | string[] | Лок-ключи вариантов; генератор тянет по сиду |
3.4 Конфиги (глобальные параметры) — детали в §4
StatsConfig — базовые значения юнита (расширение существующего)
| Поле | Статус | Смысл |
|---|---|---|
ArmorConstantK | есть, починить | Константа K формулы брони. Становится единственным источником: дублирующее поле _armorK на CombatLifetimeScope удаляется (сейчас SO-поле мёртвое — сим его не читает) |
AttackSpeedMin / AttackSpeedMax | есть | Клампы скорости атаки |
Defaults: StatDefault[] | есть, заполнить | Базовые значения статов юнита: обычные HP, скорость движения, дальность атаки и т.д. Реликвии задают отличия от базы своим стат-блоком |
SimTuningConfig — тюнинг симуляции (новый; переезд SimConstants + сбор магических чисел)
| Поле | Дефолт (из кода) | Смысл |
|---|---|---|
AiTickRate | 10 | Частота переоценки AI, Гц |
MaxAttackAnimTicks | 30 | Потолок длительности свинга автоатаки («хлёсткость») |
MinWindupTicks | 3 | Пол замаха (минимальный телеграф) |
BodyRadiusPerSize | 0.575 | Радиус тела = Size × это (сепарация) |
SeparationStrength | 0.5 | Доля перекрытия, устраняемая за тик |
SeparationIterations | 1 | Проходов сепарации за тик |
SeparationSameTeamScale | 0.35 | Множитель расталкивания союзников |
ProjectileHitRadiusFactor | 0.25 | Радиус коллизии снаряда/хил-снаряда = доля Size (сейчас магическое число в 2 местах) |
ProjectileDespawnMargin | — | Отступ деспавна снаряда за границами арены (замена несвязанного ±200) |
KiteFleeFactor | 0.6 | Фолбэк-полоса кайта: flee = range × это |
DefaultPassiveThresholdPct | 0.2 | Дефолт порога пассивок (сейчас захардкожен в 3 местах) |
GlobalSearchRadius | 500 | «Глобальный» радиус поиска целей (сейчас в 3 местах) |
TickRate = 30 остаётся константой в коде — это фундамент детерминизма и контракт реплеев, а не балансная ручка.
GameConfig — общие дефолты игры (новый)
| Поле | Тип | Смысл |
|---|---|---|
DefaultMasterVolume / DefaultMusicVolume / DefaultSfxVolume | float | Начальные громкости (первый запуск) |
DefaultLocale | string | Локаль по умолчанию (или auto из системы) |
VesselItemSlots | int = 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 — реестр всего контента
| Поле | Тип | Смысл |
|---|---|---|
SchemaVersion | int | Версия контент-схемы (общая с 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 на RootLifetimeScope → RegisterInstance<IContentDatabase> (официальный VContainer-паттерн для SO). Словарь id→def строится один раз при регистрации. Никакого Resources.Load, никаких статиков.
4. Конфиги: бейк, live-тюнинг, фикс-лист
4.1 Правила
- Бейк на старте боя:
RuntimeUnitFactory/системы снимают снапшот значений конфигов в plain-структуры сима (расширение уже существующего пути SO →RuntimeUnit). Из тика SO не читается. - Live-тюнинг = фича: правка конфиг-SO в play mode в редакторе персистится — это инструмент баланса. Применение к идущему бою — только явное (dev-команда re-bake / Odin-кнопка), при этом бой помечается tainted (реплей невалиден). В заголовок реплея —
SchemaVersion+ хеш баланс-значений. - Один источник правды: дефолт значения живёт в ассете. Инициализаторы полей в C# — только сид для свежесозданного ассета (изменение инициализатора НЕ обновляет существующие
.asset— известная ловушка сериализации). Фолбэки видаconfig == null ? 5f : config.Xзапрещены — отсутствие конфига падает громко при композиции контейнера.
4.2 Фикс-лист из инвентаризации (чинится при переносе, шаг 0 §10)
| # | Проблема | Где |
|---|---|---|
| 1 | StatsConfig.ArmorConstantK мёртв: сим читает CombatLifetimeScope._armorK со сцены — два источника правды | CombatLifetimeScope.cs:25 |
| 2 | PassiveThresholdPct = 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…) — отдельный источник правды рядом со StatsConfig | GuildmasterCommands.cs:503 |
| 7 | Проверить: DamageNumberSpawner/DamageNumber — легаси-дубль FloatingText? | Presentation/ |
| 8 | Рассинхрон код-дефолта и сцены: CombatFocusTarget._positionDamping 3 vs 4 | CombatFocusTarget.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в таблицах (какие суффиксы обязательны — по домену; уTextEventData—intro+ по выбору на каждый 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, не плейтест):
- Id: непустой, формат
domain.name, домен соответствует типу, без дубликатов по всей базе. ContentDatabase: каждый ассет контент-типа зарегистрирован; ссылки не-null.[SerializeReference]-списки: без null-элементов (следы missing types).- Локализация: обязательные ключи по конвенции присутствуют в таблицах EN и RU.
- Диапазоны и кросс-инварианты:
FleeAtHpPct < ReturnAtHpPct,AttackSpeedMin < Max, веса ≥ 0,Severityв диапазоне,Charges ≥ 0и т.п. - Конфиги: SO равен дефолтам на свежем домене (ловит утёкшие play-mode правки в коммитах — по diff’у).
- Ссылочная целостность по id:
EncounterData.Units[].EnemyIdсуществует,ShopConfig.Entries[].ContentIdсуществует,GuildmasterData.StartingRelicIdsсуществуют и т.д. UnitVisual: обязательные слоты (Idle/Run/Attack/Death) заполнены;AbilityData.VisualSlotуказывает на непустой слот; клипы Attack/Skill содержат ≥1 маркер-событие, время маркера в пределах клипа.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, база не шатается |
| 1 | ContentDefinition + жизненный цикл id §2.3 + Id всем существующим (миграция DisplayNameKey) + ContentDatabase/IContentDatabase в DI + валидация-тесты §8 (1–3, 5) | Каркас id/реестра; CI ловит дубликаты с первого дня |
| 2 | SimTuningConfig + расширение StatsConfig + GameConfig + бейк-путь + dev-команда re-bake | Все глобальные ручки — в конфиг-SO, тюнинг без перекомпиляции |
| 3 | Иерархия UnitData → RelicData/EnemyData (перенос кита в базу, миграция 7 ассетов) + TagData + новые мета-SO: TraitData, ConsequenceData, AIPresetData (+ миграция inline-AI), GuildmasterData, ItemData, RunModifierData; InfoTags-поля; обновление глоссария GDD 0.4 (Trait/Consequence) | Контент-типы Фазы 4 готовы к наполнению |
| 4 | UnitVisual → Unity Animation: клипы-слоты, базовый AnimatorController + override, бейк-ридер маркеров (clip.events → тики), fireEvents = false, конверсия 2 ассетов, AbilityData.VisualSlot | Нормальный анимационный авторинг + слоты под скиллы, детерминизм не тронут |
| 5 | Контент-менеджер §9 (+ Unity Search-сетап сразу) | Авторинг ускоряется до начала массового наполнения |
| 6 | Localization: таблицы EN/RU + сервис + editor-помощник Name/Desc + Addressables-плумбинг §5 + валидация ключей (правило 4) | Локализация перестаёт быть долгом |
| 7 | AudioCatalog + конвенция аудио-ключей (события уже летят; сами звуки — Фаза 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).
| # | Дизайн говорил | Сделано | Почему |
|---|---|---|---|
| 1 | ContentDatabase — реестр по доменам (§3.6) | Единый плоский список всего контента | Реестр/валидация/менеджер и так работают с любым наследником ContentDefinition единообразно; per-domain разбивка — преждевременная структура на 28 ассетах. Шов на дробление остаётся. |
| 2 | DisplayNameKey-поле на контенте | Убрано; имя/описание — только через лок-ключи {id}.name/{id}.desc (§6) | Одна конвенция ключей вместо двух источников имени. |
| 3 | StatsConfig — только глобальные константы | + база статов юнита (Defaults: MaxHP 120, AAD 12, AS 1, AR 1.5, MS 3, PhysArmor 4); 7 реликвий переписаны как Flat-диффы от базы (эффективные статы сохранены) | Реальная общая база вместо «каждая реликвия задаёт всё с нуля». Баланс-числа провизорные — Макс тюнит позже. |
| 4 | IAudioService в 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).