16 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()). Цикл знает только маленький интерфейсpoll(): InputState, а не конкретную клавиатуру. - Однократные действия (смена оружия, рестарт) —
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 — псевдо-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. Логика игры при этом не меняется.