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