Files
Binding-Fignyaac/docs/ARCHITECTURE.md
T
mayatnikovandClaude Opus 4.8 7dabfd4fff docs: синхронизировать описание ассетов с реальностью (PNG из src/assets + фолбэк)
Ревью отметило устаревшие тексты после перехода на загрузку PNG:
- assets.ts: заголовок описывал «процедурно, без внешних файлов» — теперь PNG из
  src/assets/<ключ>.png с процедурным фолбэком.
- HOWTO §8: убран совет «заменить тело drawX() на TextureLoader» (уже сделано);
  актуальный путь — положить PNG с нужным именем в src/assets/.
- README/ARCHITECTURE/ASSET_BRIEF: статус «подключено», корректный механизм (рантайм-
  загрузка по пути, не импорт-бандлинг).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:15:36 +03:00

16 KiB
Raw Blame History

Архитектура

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

            ┌──────────────────────────────────────────┐
            │              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()).
  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/wonstep() ничего не делает.

Ввод (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. Логика игры при этом не меняется.