Полный рефакторинг проекта в стабильную расширяемую базу рогалика. Почему: после разнесения 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>
177 lines
13 KiB
Markdown
177 lines
13 KiB
Markdown
# Архитектура
|
||
|
||
## Главный принцип: логика отдельно от рендера
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ 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`. Логика игры при этом не меняется.
|