209 lines
16 KiB
Markdown
209 lines
16 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()`). Цикл знает только
|
||
маленький интерфейс `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) // число/тип/сила врагов (множители)
|
||
```
|
||
|
||
В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а
|
||
`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и
|
||
спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но
|
||
враги остаются с балансом первого этажа.
|
||
|
||
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
|
||
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
|
||
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
|
||
|
||
### Тема / ассеты (render/theme.ts)
|
||
|
||
Внешний вид мира вынесен в `Theme` (сейчас — только цвета примитивов). `ThreeRenderer`
|
||
берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику.
|
||
Это **задел под кастомные ассеты**: чтобы перейти на спрайты/текстуры, расширь `Theme`
|
||
полями с путями к изображениям, загрузи их `THREE.TextureLoader` и положи в
|
||
`material.map`. Логика игры при этом не меняется.
|