Files
Binding-Fignyaac/docs/ARCHITECTURE.md
T
mayatnikov a72172df72 refactor: комплексный рефакторинг + Isaac-like геймплей
Баги и гигиена (Wave 1):
- Player.addWeapon использует MODE_MELEE вместо литерала 1
- Фильтрация мёртвых врагов из room.enemies (раньше массив рос в долгих боях)
- Кап миньёнов босса (BOSS.maxMinions=4) — иначе комната могла не зачиститься
- aliveCount корректно считает живых в knockback-фазе
- equipSlot валидирует диапазон слота
- Магические числа range=70/spread=0.15 вынесены в WeaponDef (beamRange/spread)
- Регрессия cleared-flag: после фильтрации массива длина 0, но cleared должен стать true

Распил Game.ts 683→465 строк (Wave 2):
- systems/movement.ts: moveEntity(e, dx, dy, room) — убрал 5 копий коллизионного паттерна
- systems/projectiles.ts: applyWeaponProjectileStats, explodeBomb, projectileHitWall
- systems/ai.ts: runAI через диспетчер-таблицу (вместо if/else-if каскада)
- Переходы/этажи оставлены в Game.ts (тесно завязаны на cc/cr/player)

Тесты 33→72 (Wave 3):
- movement.test.ts, projectiles.test.ts, combat.test.ts, regressions.test.ts, items.test.ts
- Покрытие: ближний/дальний бой, огнемёт, бомба, лазер, splitter, сундук→пикап,
  переход комнат, фазы босса + кап миньёнов, фильтрация мёртвых, секретка

Isaac-like геймплей (Wave 4):
- Статы игрока: damageMul/fireRateMul/rangeMul/shotSpeedMul (мультипликативно
  поверх WeaponDef), effectiveDamage/Cooldown, отображение в HUD
- 8-направленный прицел: aimDir: Dir → aimVec: {x,y}, стрелки дают диагонали
- Пассивные предметы (items.ts, 6 штук): сундук дропает 50/50 оружие/предмет
- Новый тип врага splitter: при смерти распадается на двух fast
- Новый тип комнаты secret: +1 max HP один раз при первом входе
2026-06-19 16:50:15 +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()). Цикл знает только маленький интерфейс 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/wonstep() ничего не делает.

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