Files
Binding-Fignyaac/docs/ARCHITECTURE.md
T
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

177 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
## Главный принцип: логика отдельно от рендера
```
┌──────────────────────────────────────────┐
│ 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`/`won``step()` ничего не делает.
## Ввод (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`. Логика игры при этом не меняется.