# Архитектура

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

```
            ┌──────────────────────────────────────────┐
            │              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`, `aimVec`, `attackHeld`) — читаются каждый шаг;
  `aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали);
- **однократные** (`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`. Логика игры при этом не меняется.
