СТИХИИ (src/sim/element.h) Семь элементов, сопротивления свойством ВИДА, одна формула урона на пулю и на клинок. Зачем они в игре про бесконечный спуск: сила отряда растёт медленнее угрозы (закон масштаба, docs/20-gdd.md), и разрыв обязан закрываться знанием. Стихии — первый такой канал: верное оружие убивает втрое быстрее, и цифра эта не растёт от прокачки. Ручных действий не заводится: стихия — свойство оружия, а не кнопка. Всё решается тем, что отряд взял вниз. Стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и сабля остаются чистой кинетикой, потому что на них меряется приёмка. ИНСТРУМЕНТЫ БАЛАНСА (src/debug/tools.cpp) Вторая поверхность: `squad_proto --tool weapons|enemies|ttk|economy`. Отдельная от редакторской не по вкусу — редактор линкуется только с движком и правил боя не видит, а всё это считает урон. Инструменты делятся по ПРЕДМЕТУ: содержимое у редактора, баланс у игры. ЧТО НАШЛА ПРОВЕРКА Первая версия --tool ttk считала разброс по времени убийства и показывала уверенные 2.8x на каждой твари. Это была разница МОЩНОСТИ стволов: магнум лучше пистолета против всех, стихии ни при чём. С вынесенной за скобки мощностью оружия правда оказалась другой: 1.04-1.36x, то есть состав удара не значил почти ничего. Механика была декоративной ровно в том же смысле, в каком когда-то был вес. Чтобы разброс появился, у твари должны быть И уязвимость, И сопротивление среди тех стихий, которые вообще носит оружие: одной уязвимости мало, если ни один ствол не бьёт тем, что она держит. После правки содержимого все пять видов в коридоре 1.90-3.06x, и картина читается — дробовик жжёт бредущих, дуга бьёт плевков, лук разъедает громилу, карабин против громилы бесполезен. Коридор в инструменте 1.8-8.0, а не заявленные в документе 3-6: до тройки дотянут почти чистые стихийные сборки, которые приходят с аффиксами. Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
02 — Архитектура
Карта модулей
Целей сборки три, и граница между ними — линковка, а не договорённость (15-engine-and-editor.md):
tile2d (STATIC) engine/ — окно и цикл, кадр, проекции 2D, тайлы, комнаты, проекты
├── squad_proto.exe игра Descent: src/ + projects/descent/
├── tile2d_editor.exe редактор комнат: editor/, инструмент ДВИЖКА
└── tile2d_walk.exe пример: samples/walk/, второй потребитель движка
Движок лежит в своём каталоге не для красоты: состав tile2d определяется тем,
куда положен файл, и попасть в движок случайно нельзя
(18-engine.md).
engine/ ДВИЖОК Tile2D: про правила боя не знает ничего
config.h размеры кадра, тайла и карты — то, что нельзя менять без пересборки
app.h/.cpp окно, кадр, fixed timestep. Цикл остаётся у приложения
math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only
hash.h детерминированные хеши: один и тот же пол в каждом прогоне
projection.h/.cpp ЕДИНСТВЕННОЕ место, где движок знает про изометрию
view2d.h камера, список отрисовки, сортировка по глубине
framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы
pixel.h/.cpp ромб, обводка силуэта, ShadeColor
microfont.h/.cpp растровый шрифт 3x5: номер этажа формой не покажешь
theme.h сколько тем отделки и как они называются
tileset.h/.cpp пол и стены по темам; ими рисуют и игра, и редактор
catalog.h/.cpp каталог размещаемых сущностей (данные, не enum)
room.h/.cpp формат комнаты-шаблона, набор комнат
level_gen.h/.cpp сборка этажа из комнат по сиду. ЕДИНСТВЕННЫЙ RNG
progression.h/.cpp что означает глубина N; коэффициенты приходят из проекта
project.h/.cpp манифест проекта: пути, вид отображения, кривая
tilemap.h/.cpp карта, DDA-рейкаст, circle-vs-AABB
tilemap_draw.h/.cpp показать карту тайлсетом; дефолт для проектов без света
sfa_library.h/.cpp чтение библиотеки SpriteForge (см. docs/12-assets.md)
text_parse.h разбор строчных текстовых форматов движка
editor/ РЕДАКТОР: отдельный exe, линкуется ТОЛЬКО с движком
main.cpp окно, ключи, --check
editor.h/.cpp холст, кисти, палитра, таблицы весов, предпросмотр
samples/walk/ ПРИМЕР: минимальное приложение на движке (docs/18-engine.md)
projects/ СОДЕРЖИМОЕ. Игра — один из проектов, а не единственный
descent/ project.txt, rooms/, catalog/ — эта игра
hollow/ второй проект: вид сверху, своя кривая
src/
main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет
game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага
tuning.h ВСЕ числа ИГРЫ + таблица runtime-подкрутки
core/
input.h InputState — ввод, влияющий на симуляцию
render/
view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...)
sprites.h/.cpp процедурная генерация всех спрайтов при старте
hud.h/.cpp полосы отряда поверх кадра: ни одной буквы текста
scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой
lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md)
fx.h/.cpp трассеры, вспышки дула, искры попаданий
post.h/.cpp напряжение (Dread) и слитый пост-проход
noise.h единственное место render-only псевдослучайности
sim/
loadout.h класс оружия, параметры ствола и клинка, фазы удара
agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid)
squad.h/.cpp якорь и поводок, строй, ADVANCING/HOLDING, сборка слоёв
target.h/.cpp мишени, респавн, отдача от удара и возврат на место
bullet.h/.cpp снаряды, коллизии с тайлами и капсулами
events.h кольцевой журнал боевых событий: единственный канал к эффектам
items.h каталог предметов и инвентарь бойца (docs/13-combat.md)
element.h стихии, сопротивления, единственная формула урона
enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар
spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры
ai/
firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций
lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания
melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора
ui/
menu.h/.cpp меню, пауза, экран отряда, поражение (второе место с raylib)
debug/
tools.cpp балансовые инструменты: squad_proto --tool
overlay.h/.cpp оверлей F1–F4 поверх кадра (raylib-примитивы)
metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим
harness.h/.cpp прогоны --headless и --accept
Против структуры из спека добавлены: game.h/.cpp, core/input.h,
render/scene.h/.cpp, debug/harness.h/.cpp, хоррор-слой
(render/view, lighting, fx, post, noise, sim/events.h) и ближний бой
(sim/loadout.h, ai/melee.h/.cpp). Зачем — 08-decisions.md,
10-atmosphere.md и 11-melee.md.
Правила зависимостей
RenderGameпринимаетGameпо константной ссылке. Поэтому свет, трассеры и тряска камеры физически не могут изменить симуляцию — это проверяет компилятор, а не внимательность. Всё render-only состояние живёт вView, которое headless-прогоны не создают вообще.ai/не знает ничего про рендер, проекцию и ввод. Толькоcore/,sim/и тайлмап движка.sim/и тайлмап работают в декартовом мире, тайл = 1.0 юнита. Про то, ромбами картинка или квадратами, знает однаengine/projection.h; всё остальное считает через неё (18-engine.md).- Ввод в экранных осях переводится в мировое направление ровно в одном месте —
Game::Stepчерезv2d::ScreenDirToWorld. Дальше по симуляции экранных осей нет. tuning.h— числа ИГРЫ, и от движка он зависит ровно одним включениемengine/config.h. Обратной стрелки нет: движок не включает из игры ничего, и это проверяет компилятор —src/просто не лежит в путях включенияtile2d.- Боевой режим не касается приёмки. Твари, снаряжение и урон по бойцам
живут под
Game::combatMode;--acceptи--headlessего не включают (13-combat.md). МишеньEnemyKind::DUMMYведёт себя ровно как до этой вехи. - Движок не знает про правила боя.
engine/иeditor/не включают ничего изsim/,ai/иui/, и это проверяет компоновщик: редактор просто не линкуется с ними. Содержимое игры доходит до редактора данными — каталог сущностей и файлы комнат (15-engine-and-editor.md). - Формат
.sfaзнает толькоengine/sfa_library. Остальной рендер видит обычныйSprite, и ему безразлично, процедурный тот или собранный. Игра без собранных ассетов запускается и считает те же цифры: базовый набор спрайтов процедурный и ни от одного файла на диске не зависит (12-assets.md).
Порядок одного шага симуляции
Game::Step(dt) — фиксированный порядок, важен именно он:
| # | Что | Почему здесь |
|---|---|---|
| 1 | пресет строя из ввода | до всего остального |
| 2 | мишени: респавн, отдача, возврат на место | состояние мира на начало шага |
| 3 | якорь + поводок + состояние отряда (ADVANCING/HOLDING) |
движение зависит от состояния |
| 4 | движение агентов (слои 6.7) | реестр линий берётся с прошлого шага, задержка 1/60 с |
| 5 | ручное прицеливание, назначенная цель | до выбора целей |
| 6 | оценка линий огня и выбор цели клинка по новым позициям | целеуказание, hasLane, melee.inReach |
| 7 | пересборка реестра: линии огня + секторы клинков | для расступания и оценки кандидатов |
| 8 | решатель огневых позиций / подход клинка вплотную | решатель — только в HOLDING и только без линии |
| 9 | доворот ствола (у клинка на замахе ось зафиксирована) | визуальная часть |
| 10 | атака: огонь, затем взмах | позиции те же, что в п.6 |
| 11 | пули | коллизии со стенами и капсулами |
| 12 | метрики | снимок конца шага |
Ключевое: пункты 6 и 10 разделены только доворотом ствола, позиции между ними
не меняются. Поэтому отрезок, который проверил LaneClear, и отрезок, по которому
полетела пуля, — это буквально один и тот же отрезок. Для клинка то же самое
верно про сектор: проверенный в п.6/8 и рассечённый в п.10 — один и тот же.
Детерминизм
- Фиксированный шаг 1/60, накопитель времени, максимум 5 шагов за кадр.
- Ни одного вызова RNG в симуляции. Уровень и расстановка мишеней захардкожены.
Уровень, собранный из комнат (
--rooms), этого не меняет: RNG крутится один раз, вengine/level_gen.cpp, до первогоGame::Step, и полностью определён сидом.--acceptи--headlessнабор комнат не подключают вообще. - Вырожденные случаи разрешаются по индексу слота, а не случайно (например, расталкивание двух агентов, стоящих в одной точке).
- Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет.
Поэтому --headless воспроизводит один и тот же прогон и годится как тест.
Рендер и симуляция
RenderGame(const Game&, View&, ...) не может изменить симуляцию — это гарантирует
подпись. Позиции агентов и пуль интерполируются Lerp(prevPos, pos, alpha), камера
сглаживается по frameDt и округляется до целого пикселя, чтобы картинка не дрожала.
Единственный канал в обратную сторону — кольцевой журнал sim/events.h: симуляция
пишет в него боевые события, эффекты читают. Правила его не читают, в headless
передаётся nullptr. Подробности — 10-atmosphere.md.