Стихии сами по себе были только множителем урона: «верный ствол бьёт втрое больнее». Этого мало, чтобы они стали ЗНАНИЕМ, ради которого заводились — множитель одинаково работает у того, кто понимает систему, и у того, кто взял ствол посильнее. Статусы и реакции добавляют слой, который работает только у первого: наложить ДВЕ разные стихии на одну цель — это решение о составе отряда, а не о характеристиках оружия. sim/status.h правила, потолки стаков, реакции, числа sim/reactions.* наложение и запуск, единственное место UpdateStatuses тик горения и сгорание стаков, из UpdateTargets СИЛА РЕАКЦИИ СЧИТАЕТСЯ ОТ ГЛУБИНЫ, А НЕ ОТ УРОНА ОРУЖИЯ. Считай она от оружия — на сотом этаже стала бы округлением, и весь слой стихий превратился бы в украшение. От глубины она растёт сама, но только у того, кто умеет её запускать. ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ПРОВЕРЯЮТСЯ потолок стаков один стак не значит ничего, полный набор меняет бой откат на цели вторая пара внутри 1.2 c даёт 0.0 урона запрет рекурсии урон ОТ реакции не кладёт статусов Третий не теоретический: без него цепочка «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция» уходит в самоповтор на первом же скоплении. Именно так ломаются все игры с триггерами. Четыре реакции из десяти. Остальные ждут систем, которых нет (крит, воскрешение, притяжение), и дописывать их «пока не работает» значило бы завести шесть мёртвых веток. Два отступления от документа, оба сознательные: кислота и дуга увеличивают получаемый урон вместо снятия брони и метки крита (у тварей нет ни брони как числа, ни крита), SUPERCOND кладёт стаки пустоты вместо отдельного среза сопротивлений — пустота в этой игре и означает «защита не работает». Шаг симуляции 2.9 мкс из бюджета 16666: статусы на 64 целях ничего не стоят. 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 стихии, сопротивления, единственная формула урона
status.h статусы стихий, реакции, три ограничителя
reactions.h/.cpp наложение стихий и запуск реакций
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.