Files
Binding-Fignyaac/docs/ARCHITECTURE.md
mayatnikovandClaude Opus 4.8 0684368ac7 refactor: рабочая база на three.js + меню/правила уровней + доки
Полный рефакторинг проекта в стабильную расширяемую базу рогалика.

Почему: после разнесения single-file на модули потерялся вызов генерации
карты (RoomMap не генерировал комнаты) → игра падала на старте; баг скрывался
тем, что Bun-бандлер не проверяет типы.

Архитектура:
- Логика игры (src/core/) полностью отделена от рендера: без three.js и DOM,
  тестируется без браузера.
- Рендер мира на three.js с ортокамерой (2D-вид); HUD/миникарта — 2D-канвас поверх.
- Фиксированный игровой цикл 60 Гц + интерполяция (раньше скорость зависела
  от частоты кадров).
- Seeded-RNG, ввод через абстрактные «намерения» (InputState).

Возможности:
- Стартовое меню с выбором уровня (Esc → меню).
- Конфигуратор уровней: LevelRules + 5 пресетов (размер карты, плотность/сила
  врагов, HP, фиксированный seed).
- Тема внешнего вида (render/theme.ts) — задел под кастомные ассеты.

Качество:
- 27 юнит-тестов ядра (генерация, симметрия дверей, коллизии, спавн, правила).
- Два круга adversarial-ревью; исправлено 6 реальных багов
  (кнокбэк сквозь стены → софт-лок; незакрываемая сокровищница; фикс-сид после
  рестарта; перенос ввода между забегами; нет source maps; неточности в доках).
- Документация: README, docs/ARCHITECTURE.md, CLAUDE.md, docs/HOWTO.md.
- dist/ исключён из гита; bun.lock зафиксирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:07:06 +03:00

13 KiB
Raw Permalink Blame History

Архитектура

Главный принцип: логика отдельно от рендера

            ┌──────────────────────────────────────────┐
            │              GameLoop (engine/)            │
            │   фиксированный шаг 60 Гц + интерполяция    │
            └───────┬───────────────┬──────────────┬─────┘
                    │ poll()        │ step(input)  │ render(game, alpha)
                    ▼               ▼              ▼
          KeyboardController     Game (core/)    Renderer (render/)
          клавиши → InputState   состояние +     ЧИТАЕТ состояние,
                                 логика игры      рисует three.js + HUD

Три части не знают лишнего друг о друге:

  • core/ — игровая логика. Не импортирует ни three, ни DOM (window, document, canvas). Получает на вход абстрактный InputState, меняет своё состояние. Поэтому ядро тестируется без браузера (bun test) и не зависит от того, чем мы рисуем.
  • render/ — рендер. Только читает Game и рисует. Менять состояние игры рендеру запрещено.
  • input/ — превращает устройство ввода (клавиатуру) в абстрактные «намерения» (InputState). Захочешь геймпад — добавишь ещё один контроллер.

main.ts — единственное место, где всё это соединяется.

Почему так строго. Этот проект уже один раз сломался, когда логику, рендер и состояние перемешали и расползлись по модулям. Граница «логика ↔ рендер» — то, что не даёт повторить ту историю. Не протаскивай three в core/ и не лезь в игровое состояние из рендера.

Игровой цикл с фиксированным шагом (engine/GameLoop.ts)

Старый код двигал всё прямо в requestAnimationFrame, поэтому скорость игры зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:

  1. Раз в кадр опрашиваем ввод (controller.poll()).
  2. Однократные действия (смена оружия, рестарт) — game.consumeActions(input).
  3. Копим реальное время в аккумуляторе и вызываем game.step(input) фиксированными порциями по 1/60 секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
  4. Рисуем с коэффициентом интерполяции alpha (доля до следующего шага), чтобы движение было плавным и на 144 Гц.

Единица времени везде — шаг (= 1/60 c). Скорости заданы «пикселей за шаг», перезарядки — «в шагах».

Шаг симуляции (core/Game.ts → step)

Каждый шаг по порядку:

  1. Снимок prev-позицийprevX/prevY всех подвижных сущностей (для интерполяции).
  2. ТаймерыinvTimer, atkCD, transCD.
  3. Движение игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
  4. Атака — стрелки/пробел: выстрел (Projectile) или взмах (MeleeSwing).
  5. Ближний бой — урон+отбрасывание по врагам в хитбоксе взмаха.
  6. Снаряды — полёт, попадание в стену/врага.
  7. ИИ врагов — преследование игрока, контактный урон с перезарядкой.
  8. Зачистка — если врагов было >0 и все мертвы → cleared=true, открыть двери.
  9. Переход — стоя на двери и нажимая в её сторону → соседняя комната.
  10. Победа — если комната-босс зачищена → won=true.

Смерть (hp<=0) ставит gameOver=true; пока gameOver/wonstep() ничего не делает.

Ввод (input/)

InputState — снимок намерений: оси движения, направление прицела, флаги удержания и однократные «edge»-действия. Делится на:

  • удерживаемые (moveX/Y, aimDir, attackHeld) — читаются каждый шаг;
  • однократные (toggleWeapon, restart) — срабатывают один раз на нажатие; поэтому они обрабатываются в consumeActions() раз в кадр, а не в step().

Мир: комнаты и генерация (core/world/)

  • Комната — сетка COLS×ROWS тайлов (стены по краю, пол внутри). Двери прорезаются в тайлах только когда комната cleared (или это spawn).
  • RoomMap — связный набор комнат на сетке (2·MAP_RADIUS+1)², генерируется случайным блужданием прямо в конструкторе. Двери ставятся парами: у текущей комнаты в сторону соседа и встречная у соседа (через OPP).

Исторический баг №1. При разнесении на модули у RoomMap потеряли вызов генерации → карта была пустой → игра падала на старте. Теперь генерация в конструкторе, а тест tests/roomMap.test.ts это стережёт.

Исторический баг №2. OPP была {up:'bottom', down:'top'} (несуществующие ключи дверей) — встречные двери не ставились. Сейчас {up:'down', down:'up'}, симметрия дверей покрыта тестом.

Коллизии (core/systems/collision.ts)

  • isBlocked(room, col, row) — тайл-стена или выход за границы блокируют, кроме дверных проёмов (там граница «прозрачна», чтобы встать на дверь и перейти).
  • collidesWall(box, room) — перебирает тайлы под хитбоксом.
  • Движение разрешается по осям раздельно: применяем X (откат при коллизии), затем Y. Это даёт «скольжение» вдоль стен.

Рендер (render/)

Два наложенных холста (см. index.html):

  • #game — WebGL, мир рисует ThreeRenderer.
  • #hud — 2D-канвас поверх, HudOverlay рисует HP, индикатор режима, счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.

ThreeRenderer — ортокамера и «плоское 2D»

  • OrthographicCamera(0, CW, 0, CH, …) отображает мировые координаты один в один в пиксельные (x вправо, y вниз). Поэтому вся математика ядра валидна без пересчётов. Слои по z (пол < стены < сущности < снаряды).
  • Камера переворачивает ось Y → инвертируется порядок вершин → при обычном отсечении задних граней плоскости были бы невидимы. Поэтому все материалы — DoubleSide (правильный выбор для плоских спрайтов).

Исторический баг №3. Именно из-за инверсии Y и отсечения граней мир рисовался «в пустоту» (чёрный экран при работающих draw-call). Лечится DoubleSide.

Управление ресурсами GPU (важно — иначе утечки)

  • Общие геометрии-«единицы» (unitPlane, unitCircle) масштабируются под размер сущности — не плодим геометрии.
  • Тайлы комнаты пересобираются только при смене комнаты.
  • Меши сущностей создаются/удаляются по факту появления/исчезновения (mark-and-sweep в sync*), их персональные материалы корректно dispose()-ятся.
  • Общие ресурсы освобождаются один раз в dispose().

HiDPI

ThreeRenderer и HudOverlay увеличивают буфер под devicePixelRatio (до 2×), а рисуют в логических координатах CW×CH. CSS-размер холстов фиксирован (width/height: 100% внутри #stage 880×660) — без этого canvas как replaced-элемент показался бы в размер HiDPI-буфера и вылез бы за рамки.

Детерминизм

Вся случайность идёт через один экземпляр Rng (seed). Один и тот же seed даёт одну и ту же карту/спавн — удобно для отладки и обязательно для тестов. Никаких Math.random() в core/ — только rng.

Меню, правила уровня и кастомные ассеты

Поток запуска

main.ts показывает стартовое меню (ui/StartMenu.ts, DOM поверх холстов). По клику на уровень создаётся Game(rules) и запускается GameLoop. Esc во время игры останавливает цикл и снова показывает меню (фон меню перекрывает последний кадр). Рендер (ThreeRenderer, HudOverlay) и ввод создаются один раз и переиспользуются между забегами; на каждый забег пересоздаётся только Game и GameLoop. Рендер сам подхватывает смену игры: новая комната ≠ отрисованной → геометрия комнаты пересобирается, меши старых сущностей убираются mark-and-sweep.

Правила уровня (core/rules.ts)

LevelRules — данные, параметризующие забег: размер карты, плотность/сила врагов, HP/скорость игрока, опциональный фиксированный seed. PRESETS — список для меню (добавил объект → пункт появился). Правила протекают так:

LevelRules ──► Game(rules) ──► RoomMap(rng, rules)   // размер карты
                          └──► Player(rules.player)    // HP/скорость
                          └──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)

Граница: геометрия движка (размер тайла/комнаты, геометрия дверей) живёт в config.ts и не меняется от уровня к уровню; правила забега — в rules.ts. config задаёт базовые значения, rules — поверх (например, множители HP врагов).

Тема / ассеты (render/theme.ts)

Внешний вид мира вынесен в Theme (сейчас — только цвета примитивов). ThreeRenderer берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику. Это задел под кастомные ассеты: чтобы перейти на спрайты/текстуры, расширь Theme полями с путями к изображениям, загрузи их THREE.TextureLoader и положи в material.map. Логика игры при этом не меняется.