squad-proto/docs/02-architecture.md
z.kirill 745cb2a3fd Стихии и вторая поверхность инструментов (M21, E2)
СТИХИИ (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>
2026-08-15 05:25:44 +03:00

15 KiB
Raw Blame History

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      оверлей F1F4 поверх кадра (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.

Правила зависимостей

  1. RenderGame принимает Game по константной ссылке. Поэтому свет, трассеры и тряска камеры физически не могут изменить симуляцию — это проверяет компилятор, а не внимательность. Всё render-only состояние живёт в View, которое headless-прогоны не создают вообще.
  2. ai/ не знает ничего про рендер, проекцию и ввод. Только core/, sim/ и тайлмап движка.
  3. sim/ и тайлмап работают в декартовом мире, тайл = 1.0 юнита. Про то, ромбами картинка или квадратами, знает одна engine/projection.h; всё остальное считает через неё (18-engine.md).
  4. Ввод в экранных осях переводится в мировое направление ровно в одном месте — Game::Step через v2d::ScreenDirToWorld. Дальше по симуляции экранных осей нет.
  5. tuning.h — числа ИГРЫ, и от движка он зависит ровно одним включением engine/config.h. Обратной стрелки нет: движок не включает из игры ничего, и это проверяет компилятор — src/ просто не лежит в путях включения tile2d.
  6. Боевой режим не касается приёмки. Твари, снаряжение и урон по бойцам живут под Game::combatMode; --accept и --headless его не включают (13-combat.md). Мишень EnemyKind::DUMMY ведёт себя ровно как до этой вехи.
  7. Движок не знает про правила боя. engine/ и editor/ не включают ничего из sim/, ai/ и ui/, и это проверяет компоновщик: редактор просто не линкуется с ними. Содержимое игры доходит до редактора данными — каталог сущностей и файлы комнат (15-engine-and-editor.md).
  8. Формат .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.