Единственный владелец правил написания кода. Как называть, что документировать, чего никогда не делать и почему. CLAUDE.md и .cursor/rules/project-context.mdc ссылаются сюда и не повторяют содержимое: правило, живущее в двух местах, расходится в третьем.

Связано: карта сборок (сами .asmdef), Journal - Library Picks And The Alternatives We Turned Down, Reference - Editor Tools.

1. Инварианты — чего не делать никогда

Короткий список. Всё остальное в этом документе — объяснение, почему он такой.

  • GetComponent, Find, FindObjectOfType в Update() / FixedUpdate().
  • Instantiate / Destroy в циклах и горячих путях — только ObjectPool<T>.
  • Аллокации в горячих путях: LINQ, конкатенация строк.
  • Хардкод игровых значений в C# — данные живут в ScriptableObject или конфиге.
  • UnityEngine.Random в игровой логике — только IRngService (seeded).
  • Physics2D / Rigidbody2D / Time.deltaTime в боевой симуляции — ломает детерминизм.
  • Синглтоны (static instance) вместо DI.
  • Корутины для нового time-based кода — только UniTask.
  • Legacy UnityEngine.Input — только Input System за IInputService.
  • [MenuItem] вне корня Alebardium/.
  • Дефолтный аргумент конструктора (IFoo foo = null) у типа, который приходит из DI.
  • Тихий фолбэк на наш собственный конфиг, ассет сцены или DI-регистрацию (см. §5).
  • Показ-рандом, утекающий в симуляцию, и свой шум там, где есть общий (см. §8).
  • Свободное число там, где величина обязана быть ступенью (см. §9).

2. Именование и структура

  • PascalCase — публичные члены, типы. _camelCase — приватные поля.
  • MonoBehaviour — логика GameObject, ScriptableObject — данные.
  • Ссылки на компоненты кэшируются в Awake().
  • Код пишется так, чтобы читаться как соседний: плотность комментариев, именование и идиомы берутся из окружающего файла, а не из общих привычек.

Карта сборок — карта сборок (сами .asmdef). Проверять перед созданием нового скрипта: в какую сборку он попадёт. Новый модуль — обновить и карту.

Игровой код и контент лежат под Assets/_Project/ (Scripts/, ScriptableObjects/, Prefabs/, Scenes/, Art/, UI/, Audio/), тесты — Assets/_Project/Tests/{EditMode,PlayMode}. Всё остальное под Assets/ — вендор.

3. Документирование

  • Публичные методы и свойства во всех сборках — XML Doc (///).
  • Поля ScriptableObject[Tooltip("...")], он виден в Inspector дизайнеру.
  • Приватные поля и внутренняя логика — комментарий только для неочевидного алгоритма.
  • Очевидное не документируется: // increment counter — шум.
  • <remarks> на типе — место для «почему». Неочевидное решение, отвергнутая альтернатива, условие, при котором класс сломается. <summary> отвечает «что делает», <remarks> — «почему так, а не иначе». Doxygen публикует оба.

Код — владелец правды о коде (CLAUDE.md, раздел «Где живёт правда»). Практически это значит:

  • Факт про один файл документируется в этом файле. Ссылаться на вики за объяснением того, что делает метод, — дефект: справочник вики заморожен и отстаёт молча.
  • Инвариант между файлами идёт в тест, а не в комментарий. Комментарий виден одной стороне шва, вторая нарушит и не узнает; тест падает. Пример — GameDataPathTests на кодовых именах GameDataPath.
  • Развилка «выбрали одно из двух» после коммита уезжает записью в 00-meta/journal/ — порог и шаблон в CLAUDE.md. В комментарий пишем только то, что нужно читателю этого файла.
/// <summary>Применяет урон с учётом брони и активных эффектов.</summary>
/// <param name="damage">Базовый урон до модификаторов.</param>
/// <param name="source">Источник урона — для триггеров мементо.</param>
/// <returns>Фактически нанесённый урон.</returns>
public int TakeDamage(int damage, IUnit source) { ... }
 
[Tooltip("Базовая скорость атаки: секунды между ударами.")]
[SerializeField] private float _attackInterval = 1.5f;

Отдельно: контентный класс объясняет не только что делает, но и что значит каждое его число — иначе баланс правится наугад.

4. Архитектура и детерминизм

  • DI вместо синглтонов. Зависимости — через VContainer; ни static instance, ни Find*ObjectOfType в рантайме.
  • Рандом — только IRngService. Забег воспроизводим по сиду, глобальный Random это ломает.
  • Боевая симуляция детерминирована: фиксированный тик, без физики Unity, детерминированный порядок итерации (не foreach по Dictionary/HashSet без сортировки), отделена от презентации.
  • Интерфейсы — на швах, а не для вида. IFoo оправдан при двух и более реализациях или как граница для теста, мока, подмены. IFooFoo один-к-одному без этого не заводить.
  • Кооперативная отмена в узловых флоу. Ждёшь чужой TaskCompletionSource — не await tcs.Task, а await tcs.Task.AttachExternalCancellation(ct) (UniTask): иначе токен отмены не доходит и ожидание висит вечно. При отмене бросает OperationCanceledException — это штатный путь выхода из флоу, а не ошибка. Паттерн всех узловых флоу забега после реворка UI-архитектуры.

5. Фолбэки: три полосы

Граница проходит не по важности кода, а по тому, откуда приходит отказ.

Фолбэк допустим только там, где отказ приходит извне игры — диск, ОС, локаль игрока, банк FMOD, пользовательский файл. Всё, что внутри нашего авторства — конфиг, ссылка в сцене, DI-регистрация, id контента, ключ локализации — фолбэка не имеет: значение либо есть, либо это баг разводки, и он обязан быть громким и пойманным guard-тестом до запуска.

Оставшийся фолбэк обязан деградировать в явно неправильно, а не в правдоподобно: выключенный джус, магента, #key, лог с причиной — да; похожее число, похожий цвет, «примерно такая» тряска — нет.

ПолосаКогдаЧто делать
УдалитьПодменяет авторское значение (второй владелец правды)Снести; отсутствие ловит guard-тест
Громкий отказПрикрывает неразведённую ссылку сцены или DIDebug.LogError с именем поля; ссылку — в SceneWiringTests
Честная деградацияОтказ пришёл извне игрыОставить, но с логом и видимым следом

Проверка перед тем, как написать фолбэк: сработает — узнаю ли я об этом, не открывая код? Нет — значит он маскирует. Помощники — ScopeWiring, MenuRouter.CannotShow. Разбор, которым правило добыто, лежал в docs/fallback-audit.md — журнал удалён 30.07.2026 как леса, само правило самодостаточно.

6. Меню редактора

Весь наш редакторный тулинг — под одним корнем Alebardium/. Не Tools/, не Tools/Guildmaster/, не свой корень: пункты уже расползались по четырём корням, и нужное искали перебором. Новый [MenuItem] заводить сразу под Alebardium/<Группа>/…, приоритеты — сотнями между группами.

Исключение: [CreateAssetMenu] остаётся под Guildmaster/… — это меню создания игрового контента, а не инструменты студии. Раскладка и таблица приоритетов — Reference - Editor Tools.

7. Тексты и локализация

Игровой текст закладывается через лок-ключи сразу, EN + RU, а не «потом»: ретрофит ключей по готовым экранам стоит дороже, чем их заведение, и до него никогда не доходят руки. Ключи контента — {id}.name / {id}.desc.

8. Процедурная генерация: считаем, собираем, рисуем

У нас нет художника по интерфейсу и нет художника по эффектам. Поэтому вопрос «рисовать или считать» встаёт в проекте постоянно, и отвечать на него каждый раз заново — значит отвечать по-разному. Граница проходит не по сложности картинки, а по тому, несёт ли она смысл.

ПолосаЧто сюда попадаетЧем делается
СчитаемПоверхности, фактуры, каймы, скосы, дуги, свет, сетки, разделители, формы удара, разлёт осколков, задник картыФормула: шейдер, Painter2D, Shapes, запекание в редакторе
СобираемЛицо Сосуда, силуэт, снаряжение — то, что комбинируется из авторских частей по сидуДетерминированная сборка из нарисованных человеком кусков
РисуемИконки, знаки, лого, портреты-персоналии, иллюстрацииЧеловек или PixelLab, никогда не формула

Средняя полоса — не компромисс, а отдельный способ: части рисует человек, комбинацию выбирает сид. Правило из двух полос («считаем или рисуем») ломалось на первом же нашем случае — face-DTO Сосуда, — поэтому полос три.

Граница между полосами зависит от ЯЗЫКА СТИЛЯ, а не от типа объекта. Одна и та же поверхность попадает в разные полосы при разной стилистике, и это не вкусовщина, а вопрос того, кто справится лучше. В пиксель-арте фактуру делает ручная работа — осмысленные кластеры на площади 32×32, — и формула там соревнуется с художником, проигрывая: поверхность уезжает в «рисуем». В плоском сторибуке (наш язык с 2026-08-01/14, для арены — с 2026-08-02/44) фактуры нет вовсе, стиль состоит из формы, её контура и света от движка — три считаемые вещи, и поверхность честно возвращается в «считаем». Прежде чем относить что-то к первой полосе, ответь: в этом языке у формулы есть конкурент-человек? Есть — полоса не та.

Иконки лежат в «рисуем» как текущее положение дел, а не как вердикт навсегда. Развилка открыта и живёт в gdd/00-meta/open-forks.md; до её закрытия иконки не генерируются.

Детерминизм — три ступени, а не две. Вопрос не «случайно или нет», а кто сверяет результат.

СтупеньКто сверяетОткуда берётся
Сим-рандомСимуляция: бой, забег, карта, награды, магазинТолько IRngService (§4). Детерминизм между машинами обязателен — расхождение в последнем бите разводит два клиента коопа
Показ по общему сидуДва игрока глазами: картинка обязана быть однойСид приходит из IRngService, дальше обычная шейдерная математика. Разойтись в последнем бите не страшно — страшно взять другой сид
Свободный показНиктоЛюбой дешёвый хеш от координаты, sin в том числе

Прецеденты в коде: неровность краёв в SH_Vfx_HitForm — вторая ступень (сид из симуляции, поэтому шума по времени там нет и быть не может); разброс осколков в SH_Sprite_Shatter — третья (результат не сверяет никто, и sin-хеш дешевле проброса числа из C#).

Средняя ступень — та, на которой легче всего ошибиться в обе стороны: тянуть её в IRngService целиком избыточно, а считать свободной — значит показать двум игрокам разные удары в одном бою.

Проверка, куда попадает число: читает ли его симуляция? Да — сим-рандом по определению, даже если выглядит визуальной мелочью. Нет — тогда второй вопрос: увидят ли это двое и должны ли увидеть одно и то же? Обратное течение — показ-рандом, попавший в симуляцию, — инвариант §1 и дефект.

Общие правила для всего, что считается:

  • Общая формула для шейдеров живёт в Art/Shaders/Lib/Procedural.hlsl, а не копируется в файл-потребитель. Матрица Байера и порог дизеринга уже стояли дословным дублем в двух местах. Речь именно про шейдеры: сводить к одной функции ещё и XorShiftRng с шумом запекателя незачем — это три разные задачи (воспроизводимость, дешевизна, фактура), похожие только на вид.
  • Цвет — из GuildmasterPalette по имени токена, и в запечённом ассете тоже. Иначе смена палитры перестаёт доезжать до сгенерированного и палитра теряет статус единственного владельца.
  • Запечённое воспроизводимо на своей машине: тот же сид и те же параметры дают тот же файл. Это нужно не ради межмашинной сходимости (текстуры печёт один человек, CI их не трогает, игрок получает готовый PNG), а ради истории: PNG жмётся целиком, поэтому любой сдвиг переписывает файл полностью и даёт диф там, где визуально ничего не изменилось.
  • Размер и вес не гуляют случайно. Вариация допустима в том, что несёт характер (прогиб, зерно, неровность края), и запрещена в том, что несёт информацию, — иначе случайность отнимает у игрока канал считывания. Прецедент: _Seed в SH_Vfx_HitForm гуляет по форме, но никогда по размеру.

9. Ступенчатые величины: лестница вместо россыпи чисел

HARD (принято 2026-08-06). Величина, которая читается как категория и сравнивается с соседями, объявляется ступенью из закрытого именованного списка. Число за ступенью живёт в одном конфиге, а персональное отклонение задаётся долей от ступени, а не своим числом.

Правило существует потому, что свободное число у такой величины не имеет ни словаря, ни владельца. Оба его отказа уже случались:

  • Незаданное поле молча уезжает на дефолт, и никто не узнаёт. Кровомант — дальник со снарядом — дрался вплотную, потому что его дальность осталась единицей из конфига. Ступень отвечает на вопрос «кто он по дистанции» ДО того, как кто-то назначит число.
  • Величина расползается в россыпь несопоставимых чисел. Яркость свечения жила семью множителями (1.8 / 2.0 / 2.2 / 2.5 / 2.6 / 2.75 / 3.2) в двух конфигах: ни одно не объявлено базой, ни одно не объясняет, чем оно отличается от соседнего, а весь их разброс уложился меньше чем в полтора стопа — то есть язык света оказался плоским, и самое значимое событие боя (вспышка смерти, ×1.8) светило тусклее обычного удара.

Из чего состоит ступенчатая величина

  1. Закрытый список с именами (enum). Имя отвечает на вопрос «кто это по такой-то оси», а не «сколько тут». Номер ступени именем не является: +1 не говорит ничего, Событие говорит.
  2. Ровно одно место, где ступень превращается в число — конфиг. Больше нигде число не живёт.
  3. Отклонение — долей или множителем от ступени, и намеренно мелкое. Поправка в абсолютных единицах ломает главное свойство лестницы: сдвинули ступень — поехали все её носители, а носитель с «+1» остался на прежнем месте.

Шаг лестницы берётся в единицах восприятия величины, а не в единицах хранения. Для дальности это метр арены, для яркости свечения — стоп (вдвое), и мерить его надо по той части, которая реально доходит до глаза: у bloom это превышение над порогом, поэтому стоп считается от яркость − порог, а не от числа в поле. Проценты от числа в поле в таких величинах врут: при пороге 1.0 «+20% к множителю» (×2.0 → ×2.4) даёт +40% свечения.

Форма контрола — доля его размера, а не число

HARD (принято 2026-08-22). Всё, что элемент рисует ВНУТРИ себя как часть силуэта — скол угла, конец пластины, скос чипа, — задаётся долей его высоты, и контрол пересчитывает форму сам при отрисовке. Числа в теме на каждый размер запрещены.

Слова Макса: «Мы не можем автоматизировать подсчет значение сколов и тп, просто скейлить как и кнопки? Чтобы не заниматься математикой каждый раз» и следом «Тоже самое и с другими элементами. Возьми как хард правило».

Отказ, из-за которого правило заведено: скол 10 и конец 9 стояли числами и попадали на кнопки любого роста. На эталонном пункте меню (высота 64) это 15.6% и 14%, на мелкой кнопке 43 — уже 23% и 21%: одна и та же деталь читалась разной, а каждая новая ступень размера требовала пересчёта руками.

  • Доли живут в примитивах (--gm-plate-chamfer-ratio, --gm-plate-cap-ratio, --gm-chip-slant-ratio, --gm-slant-ratio) — по одному числу на всю игру, снятому с эталона.
  • Ноль разрешён: это выключение детали, а не её размер (у видов со значком концов нет вовсе).
  • Абсолютное значение остаётся дорогой для исключений — там, где деталь принадлежит не силуэту, а рисунку. Пример: угловая накладка PanelFrame — оковка предмета, а оковка не растёт вместе с доской; у неё постоянный размер, но объявленный ОДНИМ токеном темы, а не числом в каждой разметке.
  • Держит гейт ControlSizeGateTests.Форму_контрола_задаёт_доля_его_размера.

Где ступень не нужна

Признак — честный ответ на вопрос «почему тут именно столько»:

ОтветВердикт
«потому что это важнее обычного удара»ступень; число за ним случайное
«так посчиталось»свободное число; ступень только помешает

Не ступень: результат каскада статов, урон после множителей, тайминг, замеренный из клипа, — всё, что вычисляется, а не назначается автором. Не ступень и то, что не с чем сравнивать: величина в единственном экземпляре словаря не образует. Если список ступеней перевалил за семь — это уже не словарь, а шкала, и её место в кривой или формуле.

Прецеденты: AttackRangeBand и CastRangeBand (шесть ступеней дальности, число — в StatsConfig, поправка долевая), лестница яркости свечения в CombatFeelConfig.