Files
Binding-Fignyaac/docs/ARCHITECTURE.md

204 lines
16 KiB
Markdown
Raw Permalink 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()`). Цикл знает только
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
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 — псевдо-3D (наклонный вид, как в Isaac)
Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
- **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
— пол это плоскость `Y=0`, вверх это `+Y`, «низ» игры (`y`) идёт в глубину (`Z`).
Поэтому вся математика и **коллизии ядра без изменений** — псевдо-3D чисто визуальный.
- **Камера** — `PerspectiveCamera`, наклонная и фиксированная на комнату (приподнята и
отодвинута на «юг», смотрит вниз-вперёд ≈50°). Кадрирует комнату целиком.
- **Пол** — одна горизонтальная плоскость с бесшовной текстурой (повтор по сетке).
⚠️ Плоскость кладём ПЛАШМЯ через `flatMesh()` (поворот −90° вокруг X). Если забыть
поворот — пол встанет вертикально и перспектива «вывернется» (был такой баг).
- **Стены** — вертикальные плоскости по периметру с проёмами под двери.
- **Персонажи/враги/двери/снаряды** — ВЕРТИКАЛЬНЫЕ спрайты-биллборды (плоскости в XY,
нормаль +Z), стоящие на полу: центр на `y = высота/2`, низ на полу. Текстуры — из
`assets.ts` (см. ниже). Материалы — `DoubleSide` (наша камера переворачивает Y, иначе
грани отсеклись бы) + `alphaTest` для чёткого контура спрайта.
- **Двери всегда видимы:** рисуются из `room.doors` независимо от зачистки — закрытые
(засов) пока `!cleared`, открытый проём после. Группа комнаты пересобирается при смене
комнаты ИЛИ смене флага `cleared`.
- **Тени** — отдельный плоский спрайт-«пятно» под каждой сущностью.
### Ассеты (render/assets.ts)
`Assets` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
`docs/HOWTO.md` и `docs/ASSET_BRIEF.md`. Ключи ассетов едины в рендере, брифе и именах файлов.
### Эффекты оружия
Рендер сам распознаёт события по состоянию (ядро не трогаем):
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
- искра — когда у врага вырос `hitTimer` (попадание);
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
### Управление ресурсами GPU (важно — иначе утечки)
- Общие геометрии переиспользуются масштабированием — не плодим геометрии.
- Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
персональные материалы дверей `dispose()`-ятся при пересборке.
- Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
(mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
- Общие материалы/геометрии и `Assets` освобождаются один раз в `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`. Логика игры при этом не меняется.