Полный рефакторинг проекта в стабильную расширяемую базу рогалика. Почему: после разнесения 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>
13 KiB
Архитектура
Главный принцип: логика отдельно от рендера
┌──────────────────────────────────────────┐
│ 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× быстрее). Теперь:
- Раз в кадр опрашиваем ввод (
controller.poll()). - Однократные действия (смена оружия, рестарт) —
game.consumeActions(input). - Копим реальное время в аккумуляторе и вызываем
game.step(input)фиксированными порциями по1/60секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с. - Рисуем с коэффициентом интерполяции
alpha(доля до следующего шага), чтобы движение было плавным и на 144 Гц.
Единица времени везде — шаг (= 1/60 c). Скорости заданы «пикселей за шаг», перезарядки — «в шагах».
Шаг симуляции (core/Game.ts → step)
Каждый шаг по порядку:
- Снимок prev-позиций —
prevX/prevYвсех подвижных сущностей (для интерполяции). - Таймеры —
invTimer,atkCD,transCD. - Движение игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
- Атака — стрелки/пробел: выстрел (
Projectile) или взмах (MeleeSwing). - Ближний бой — урон+отбрасывание по врагам в хитбоксе взмаха.
- Снаряды — полёт, попадание в стену/врага.
- ИИ врагов — преследование игрока, контактный урон с перезарядкой.
- Зачистка — если врагов было >0 и все мертвы →
cleared=true, открыть двери. - Переход — стоя на двери и нажимая в её сторону → соседняя комната.
- Победа — если комната-босс зачищена →
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. Логика игры при этом не меняется.