diff --git a/.gitignore b/.gitignore
index f460e7d..dd6e803 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
node_modules/
-dist/*.map
+dist/
*.log
.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a1897b0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,103 @@
+# CLAUDE.md — как работать с этим проектом
+
+Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
+Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
+работает; добавлять — по правилам ниже.
+
+## Что это
+
+Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
+(`src/core/`), рендер — three.js с ортокамерой (`src/render/`), сборка/тесты — Bun.
+Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
+рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
+
+## 🔑 Золотые правила (нарушать = ломать архитектуру)
+
+1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
+ обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
+ `InputState` (вход) и публичные поля `Game` (для чтения рендером).
+2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
+ рисует. Любая мутация состояния из рендера — баг.
+3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
+ сохраняет детерминизм (тесты, отладка по seed).
+4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
+ перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
+5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
+ баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
+ врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
+ «магические числа» по логике.
+6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
+ обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
+7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
+
+## Команды
+
+```bash
+bun run dev # дев-сервер + watch → http://localhost:3000
+bun run build # прод-сборка в dist/
+bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
+bun test # юнит-тесты ядра
+bun run check # typecheck + test
+```
+
+> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
+> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
+
+## Definition of Done (для любой правки)
+
+1. `bun run check` зелёный (типы + тесты).
+2. Если менял логику — **добавил/обновил тест** в `tests/`.
+3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
+ страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
+ рендер — визуальную проверку не пропускать.
+4. Обновил доки, если поменялось поведение или структура.
+
+## Где что лежит (карта для навигации)
+
+| Хочешь поменять… | Иди в… |
+|---|---|
+| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
+| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
+| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
+| данные сущности | `src/core/entities/*` |
+| генерацию карты | `src/core/world/RoomMap.ts` |
+| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
+| коллизии | `src/core/systems/collision.ts` |
+| расстановку врагов | `src/core/systems/spawner.ts` |
+| как рисуется мир | `src/render/ThreeRenderer.ts` |
+| цвета/внешний вид/ассеты мира | `src/render/theme.ts` |
+| HUD/миникарту | `src/render/HudOverlay.ts` |
+| стартовое меню | `src/ui/StartMenu.ts` |
+| раскладку клавиш | `src/input/KeyboardController.ts` |
+| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
+
+Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
+рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
+
+## Грабли, на которые уже наступали (не повторять)
+
+- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
+ Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
+- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
+ Любая другая раскладка ломает встречные двери и связность карты.
+- **Чёрный экран при работающем рендере.** Ортокамера инвертирует Y → нужен
+ `DoubleSide` на материалах, иначе грани отсекаются.
+- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
+ иначе они показываются в размер HiDPI-буфера.
+- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
+ она должна стать `cleared` сразу при входе, иначе двери не появятся.
+
+## Стиль кода
+
+- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
+- Маленькие чистые функции для логики; классы — для сущностей/состояния.
+- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
+- Перед коммитом — `bun run check`.
+
+## Чего НЕ делать без явной просьбы
+
+- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
+- Не переписывать архитектуру «ядро ↔ рендер».
+- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
+- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
+ готов — он изолирован, — но это отдельная большая задача).
diff --git a/README.md b/README.md
index ccb27d3..c4b6c2a 100644
--- a/README.md
+++ b/README.md
@@ -1,94 +1,109 @@
-# Dungeon Crawl — Ranged / Melee
+# Dungeon Crawl — рогалик в духе The Binding of Isaac
-A dark fantasy dungeon crawler inspired by *The Binding of Isaac*.
-Built with TypeScript and Canvas 2D, bundled with **Bun**.
+Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
+враги, босс. Логика на чистом TypeScript, рендер — на **three.js** (ортографическая
+камера, картинка плоская 2D-сверху). Сборка — **Bun**.
-## Quick Start
+Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
+забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
+Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры).
+
+> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
+> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
+> Перед доработкой прочитай [`CLAUDE.md`](./CLAUDE.md) и [`docs/HOWTO.md`](./docs/HOWTO.md).
+
+## Быстрый старт
```bash
-bun install # install deps (none currently required)
-bun start # production build → dist/
-open dist/index.html
+bun install # поставить зависимости (three, typescript)
+bun run dev # дев-сервер с авто-пересборкой → http://localhost:3000
```
-Or for development with live reload:
+Продакшн-сборка:
```bash
-bun run dev # starts dev server at http://localhost:3000 + watch mode
+bun run build # минифицированный бандл → dist/ (открой dist/index.html)
```
-## Controls
+Проверки (запускай перед любым коммитом):
-| Key | Action |
+```bash
+bun run typecheck # tsc --noEmit, строгий режим
+bun test # юнит-тесты ядра
+bun run check # и то, и другое разом
+```
+
+## Управление
+
+| Клавиша | Действие |
|---|---|
-| WASD | Move |
-| Arrow keys | Attack in direction |
-| Space | Attack in facing direction |
-| Tab / Q | Switch weapon (Ranged ↔ Melee) |
-| R | Restart (on Game Over / Victory) |
+| WASD | движение |
+| Стрелки | прицельная стрельба/удар в направлении |
+| Пробел | атака по ходу движения |
+| Tab / Q | сменить оружие (Дальний ↔ Ближний) |
+| R | заново (на экране Game Over / Victory) |
+| Esc | вернуться в меню выбора уровня |
-## Weapons
+Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
+дверь, нажимая в её сторону.
-| Mode | Weapon | Speed | Damage | Effect |
+## Режимы боя
+
+| Режим | Оружие | Скорость | Урон | Особенность |
|---|---|---|---|---|
-| Ranged | Pistol | Fast (10cd) | 1 per shot | Ranged projectiles |
-| Melee | Knife | Slow (22cd) | 2 + knockback | Wide swing arc |
+| Дальний | пистолет | быстро (cd 10) | 1 за выстрел | снаряды летят по прямой |
+| Ближний | нож | медленно (cd 22) | 2 + отбрасывание | широкий взмах |
-## Room Types
+## Типы комнат
-| Type | Description |
+| Тип | Описание |
|---|---|
-| Spawn | Starting room, no enemies |
-| Normal | 2–4 enemies |
-| Treasure | No enemies, loot room |
-| Boss | 1 boss enemy, clearing wins the game |
+| spawn | старт, врагов нет |
+| normal | 2–4 врага |
+| treasure | без врагов (комната-награда), сразу открыта |
+| boss | 1 босс; его зачистка = победа |
-## Project Structure
+## Стек и устройство
+
+- **Bun** — рантайм, бандлер и тест-раннер.
+- **three.js** — WebGL-рендер мира через ортокамеру.
+- **TypeScript (strict)** — весь код.
+- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
+ не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
+ желании можно подменить рендер, не трогая игру.
+
+Подробности — в [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
+
+## Структура проекта
```
src/
-├── main.ts Entry point
-├── constants.ts Grid, canvas, tile/door constants
-├── types.ts Shared TypeScript types
-├── math.ts Utility: shuffle, rand, dist, overlap
-├── input.ts Keyboard state (global KEYS map)
-├── doors.ts Door geometry helpers
-├── room/
-│ ├── Room.ts Room class (tiles, enemies, tears)
-│ ├── RoomMap.ts Map generation (connected 7×7 grid)
-│ └── tiles.ts Tile builders (wall/floor/door placement)
-├── entities/
-│ ├── Player.ts Player stat block
-│ ├── Enemy.ts Enemy stat block + enemy type logic
-│ ├── Tear.ts Ranged projectile
-│ └── MeleeSwing.ts Melee hitbox
-├── game/
-│ ├── Game.ts Main orchestrator (loop, tick, render)
-│ ├── collision.ts Wall collision (isBlocked, collidesWall)
-│ ├── transitions.ts Room transition detection
-│ └── spawner.ts Enemy placement logic
-└── render/
- ├── roomRenderer.ts Tile rendering
- ├── entityRenderer.ts Player, enemy, tear, melee drawing
- ├── hudRenderer.ts HP bar, mode indicator, HUD
- └── minimapRenderer.ts Minimap drawing
+├── config.ts ⭐ КОНСТАНТЫ движка: размеры, геометрия дверей, базовый баланс
+├── main.ts точка входа: меню → игра, «склейка» логики/рендера/ввода
+├── core/ ── ИГРОВАЯ ЛОГИКА (без DOM и three.js) ──
+│ ├── Game.ts «мозг»: состояние + один шаг симуляции step()
+│ ├── rules.ts ⭐ ПРАВИЛА УРОВНЯ (пресеты для меню: размер, враги, HP, seed)
+│ ├── types.ts общие типы (Dir, RoomType, Box, …)
+│ ├── rng.ts ГПСЧ с seed (детерминизм для тестов/отладки)
+│ ├── util.ts мат-утилиты (dist, overlap, clamp, lerp)
+│ ├── entities/ Player, Enemy, Projectile, MeleeSwing
+│ ├── world/ Room, RoomMap (генерация), tiles
+│ └── systems/ collision, spawner (чистые функции)
+├── input/ ── ВВОД ──
+│ ├── InputState.ts абстрактные «намерения» (не сырые клавиши)
+│ └── KeyboardController.ts клавиатура → InputState
+├── render/ ── РЕНДЕР (читает состояние, рисует) ──
+│ ├── Renderer.ts интерфейс рендера
+│ ├── ThreeRenderer.ts мир на three.js (ортокамера)
+│ ├── theme.ts ⭐ ВНЕШНИЙ ВИД (цвета; задел под спрайты/текстуры)
+│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
+├── ui/
+│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
+└── engine/
+ └── GameLoop.ts игровой цикл с фиксированным шагом (60 Гц)
+
+tests/ юнит-тесты ядра (bun test)
+docs/ARCHITECTURE.md архитектура и потоки данных
+CLAUDE.md правила для ИИ-агентов и разработчика
+docs/HOWTO.md рецепты: как добавить врага/оружие/комнату
```
-
-## Build System
-
-- **Bun** — runtime + bundler
-- `bun build` — bundles `src/main.ts` → `dist/main.js`
-- `build.ts` — production build with minification + HTML copy
-- No external libraries required (pure Canvas 2D)
-
-## Architecture
-
-- **Game** owns the game loop (`requestAnimationFrame`), the world
- (`RoomMap`, `Player`), and dispatches to render functions.
-- **Tick** processes input → movement → attack → enemy AI → room
- clear check → transitions → win condition.
-- **Transitions** check player tile position + keypress against door
- geometry; call `Game.enterRoom()` which resets room state.
-- **Collision** allows bounding-box overlap at door openings (row/col
- outside normal bounds).
-- **Rendering** is split into 4 pure functions that receive `ctx` + data.
diff --git a/build.ts b/build.ts
index 9aa1180..6c2fd7e 100644
--- a/build.ts
+++ b/build.ts
@@ -1,35 +1,22 @@
-import { build } from "bun";
+/**
+ * Продакшн-сборка: минифицированный бандл src/main.ts → dist/main.js
+ * плюс копия index.html. Открывай dist/index.html.
+ */
+import { build } from 'bun';
const result = await build({
- entrypoints: ["./src/main.ts"],
- outdir: "./dist",
- target: "browser",
+ entrypoints: ['./src/main.ts'],
+ outdir: './dist',
+ target: 'browser',
minify: true,
- sourcemap: "external",
+ sourcemap: 'external',
});
if (!result.success) {
- console.error("Build failed", result.logs);
+ console.error('Сборка упала:');
+ for (const log of result.logs) console.error(log);
process.exit(1);
}
-// Copy HTML template with correct script reference
-const html = `
-
-
-
-Dungeon Crawl — Ranged / Melee
-
-
-
-
-
-
-`;
-
-Bun.write("./dist/index.html", html);
-console.log("Build complete → dist/");
+await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
+console.log('Сборка готова → dist/ (открой dist/index.html)');
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..d37fc52
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,34 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "dungeon-crawl",
+ "dependencies": {
+ "three": "^0.184.0",
+ },
+ "devDependencies": {
+ "@types/three": "^0.184.1",
+ "typescript": "^6.0.3",
+ },
+ },
+ },
+ "packages": {
+ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
+
+ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
+
+ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
+
+ "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
+
+ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
+
+ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
+
+ "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
+
+ "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+ }
+}
diff --git a/dev.ts b/dev.ts
index 2e2f00f..310f3da 100644
--- a/dev.ts
+++ b/dev.ts
@@ -1,50 +1,37 @@
-import { spawn } from "bun";
+/**
+ * Дев-сервер: бандлит src/main.ts → dist/main.js в watch-режиме и раздаёт
+ * dist на http://localhost:3000. index.html копируется из корня (единый
+ * источник правды) — правь его там, не в dist.
+ */
+import { spawn } from 'bun';
-// Ensure dist/ has the HTML shell
-await Bun.write(
- "./dist/index.html",
- `
-
-
-
-
-Dungeon Crawl — Ranged / Melee
-
-
-
-
-
-
-`
+const builder = spawn(
+ // --sourcemap=linked: в DevTools брейкпоинты/стек указывают на твой src/*.ts,
+ // а не на собранный main.js. .map отдаётся из dist тем же сервером.
+ ['bun', 'build', 'src/main.ts', '--outdir', 'dist', '--target', 'browser', '--sourcemap=linked', '--watch'],
+ { stdout: 'inherit', stderr: 'inherit' },
);
-// Start build watcher
-const builder = spawn(["bun", "build", "src/main.ts", "--outdir", "dist", "--target", "browser", "--watch"], {
- stdout: "inherit",
- stderr: "inherit",
-});
-
-// Start dev server
const port = 3000;
const server = Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
- let path = url.pathname === "/" ? "/index.html" : url.pathname;
- const file = Bun.file("./dist" + path);
- return new Response(file);
+ // index.html отдаём прямо из корня — правки CSS/разметки сразу живые;
+ // бандл main.js и карты — из dist (их пишет watch-сборщик).
+ if (url.pathname === '/' || url.pathname === '/index.html') {
+ return new Response(Bun.file('./index.html'));
+ }
+ const file = Bun.file('./dist' + url.pathname);
+ if (await file.exists()) return new Response(file);
+ return new Response('Not found', { status: 404 });
},
});
-console.log(`\n Dev server: http://localhost:${port}`);
-console.log(` Watching: src/\n`);
+console.log(`\n Dev server: http://localhost:${server.port}`);
+console.log(' Watching: src/\n');
-// Graceful shutdown
-process.on("SIGINT", () => {
+process.on('SIGINT', () => {
builder.kill();
server.stop();
process.exit(0);
diff --git a/dist/index.html b/dist/index.html
deleted file mode 100644
index eb11217..0000000
--- a/dist/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-Dungeon Crawl — Ranged / Melee
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/dist/main.js b/dist/main.js
deleted file mode 100644
index dfb0bfe..0000000
--- a/dist/main.js
+++ /dev/null
@@ -1,1091 +0,0 @@
-// src/input.ts
-var KEYS = {};
-function setupInput() {
- window.addEventListener("keydown", (e) => {
- KEYS[e.key] = true;
- if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(e.key)) {
- e.preventDefault();
- }
- });
- window.addEventListener("keyup", (e) => {
- KEYS[e.key] = false;
- });
- window.addEventListener("blur", () => {
- for (const k in KEYS)
- delete KEYS[k];
- });
-}
-
-// src/constants.ts
-var CW = 880;
-var CH = 660;
-var TILE = 44;
-var COLS = 15;
-var ROWS = 11;
-var RW = COLS * TILE;
-var RH = ROWS * TILE;
-var OX = (CW - RW) / 2;
-var OY = 80;
-var T_WALL = 0;
-var T_FLOOR = 1;
-var T_DOOR = 2;
-var MODE_RANGED = 0;
-var MODE_MELEE = 1;
-var DIR = {
- up: [0, -1],
- down: [0, 1],
- left: [-1, 0],
- right: [1, 0]
-};
-var OPP = {
- up: "bottom",
- down: "top",
- left: "right",
- right: "left"
-};
-var DOOR = {
- up: { cols: [6, 7, 8], row: 0, cx: 7, cy: 0 },
- down: { cols: [6, 7, 8], row: 10, cx: 7, cy: 10 },
- left: { col: 0, rows: [4, 5, 6], cx: 0, cy: 5 },
- right: { col: 14, rows: [4, 5, 6], cx: 14, cy: 5 }
-};
-var MAP_RADIUS = 3;
-var MIN_ROOMS = 8;
-var EXTRA_ROOMS = 4;
-
-// src/room/tiles.ts
-function buildTiles(doorState) {
- const tiles = [];
- for (let r = 0;r < ROWS; r++) {
- tiles[r] = [];
- for (let c = 0;c < COLS; c++) {
- tiles[r][c] = r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1 ? T_WALL : T_FLOOR;
- }
- }
- if (doorState)
- placeDoors(tiles, doorState);
- return tiles;
-}
-function placeDoors(tiles, doors) {
- if (doors.up)
- for (const c of DOOR.up.cols)
- tiles[DOOR.up.row][c] = T_DOOR;
- if (doors.down)
- for (const c of DOOR.down.cols)
- tiles[DOOR.down.row][c] = T_DOOR;
- if (doors.left)
- for (const r of DOOR.left.rows)
- tiles[r][DOOR.left.col] = T_DOOR;
- if (doors.right)
- for (const r of DOOR.right.rows)
- tiles[r][DOOR.right.col] = T_DOOR;
-}
-
-// src/math.ts
-function shuffle(a) {
- for (let i = a.length - 1;i > 0; i--) {
- const j = Math.random() * i | 0;
- [a[i], a[j]] = [a[j], a[i]];
- }
- return a;
-}
-function rand(a, b) {
- return Math.random() * (b - a) + a;
-}
-function ri(a, b) {
- return Math.floor(rand(a, b + 1));
-}
-function overlap(a, b) {
- return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
-}
-
-// src/room/Room.ts
-class Room {
- c;
- r;
- type;
- doors = { up: false, down: false, left: false, right: false };
- visited = false;
- cleared = false;
- enemies = [];
- tears = [];
- tiles;
- constructor(c, r, type) {
- this.c = c;
- this.r = r;
- this.type = type;
- this.tiles = buildTiles();
- }
- buildTiles() {
- this.tiles = buildTiles(this.cleared || this.type === "spawn" ? this.doors : undefined);
- }
-}
-
-// src/room/RoomMap.ts
-class RoomMap {
- rooms = new Map;
- key(c, r) {
- return c + "," + r;
- }
- get(c, r) {
- return this.rooms.get(this.key(c, r));
- }
- has(c, r) {
- return this.rooms.has(this.key(c, r));
- }
- add(c, r, type) {
- const room = new Room(c, r, type);
- this.rooms.set(this.key(c, r), room);
- return room;
- }
- hasBoss() {
- for (const room of this.rooms.values()) {
- if (room.type === "boss")
- return true;
- }
- return false;
- }
- generate() {
- this.add(0, 0, "spawn");
- const frontier = [[0, 0]];
- let count = 1;
- const target = MIN_ROOMS + ri(0, EXTRA_ROOMS);
- const dirs = [
- ["up", 0, -1],
- ["down", 0, 1],
- ["left", -1, 0],
- ["right", 1, 0]
- ];
- while (frontier.length > 0 && count < target) {
- const idx = ri(0, frontier.length - 1);
- const [cr, cc] = frontier[idx];
- shuffle(dirs);
- let added = false;
- for (const [_d, dc, dr] of dirs) {
- if (count >= target)
- break;
- const nc = cr + dc;
- const nr = cc + dr;
- if (Math.abs(nc) > MAP_RADIUS || Math.abs(nr) > MAP_RADIUS)
- continue;
- if (this.has(nc, nr))
- continue;
- let type = "normal";
- if (!this.hasBoss() && (count === target - 1 || Math.random() < 0.2 && count >= 3)) {
- type = "boss";
- } else if (Math.random() < 0.12 && count >= 2) {
- type = "treasure";
- }
- this.add(nc, nr, type);
- const dir = _d;
- this.get(cr, cc).doors[dir] = true;
- this.get(nc, nr).doors[OPP[dir]] = true;
- frontier.push([nc, nr]);
- count++;
- added = true;
- }
- if (!added)
- frontier.splice(idx, 1);
- }
- if (!this.hasBoss()) {
- const normals = [...this.rooms.values()].filter((r) => r.type === "normal");
- if (normals.length > 0) {
- normals[ri(0, normals.length - 1)].type = "boss";
- }
- }
- }
-}
-
-// src/entities/Player.ts
-class Player {
- x = 0;
- y = 0;
- w = 26;
- h = 26;
- speed = 3.2;
- hp = 6;
- maxHp = 6;
- mode = MODE_RANGED;
- facing = "up";
- moveDir = "up";
- atkCD = 0;
- invTimer = 0;
- transCD = 0;
- get box() {
- return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
- }
-}
-
-// src/entities/Enemy.ts
-var STATS = {
- normal: { w: 32, hp: 3, speed: 1.15, damage: 1 },
- fast: { w: 26, hp: 2, speed: 1.9, damage: 1 },
- boss: { w: 46, hp: 10, speed: 0.9, damage: 2 }
-};
-
-class Enemy {
- x;
- y;
- type;
- w;
- h;
- hp;
- maxHp;
- speed;
- damage;
- knx = 0;
- kny = 0;
- hitTimer = 0;
- atkTimer = 0;
- constructor(x, y, type) {
- this.x = x;
- this.y = y;
- this.type = type;
- const s = STATS[type];
- this.w = s.w;
- this.h = s.w;
- this.hp = s.hp;
- this.maxHp = s.hp;
- this.speed = s.speed;
- this.damage = s.damage;
- }
- get box() {
- return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
- }
- get alive() {
- return this.hp > 0;
- }
-}
-
-// src/entities/Tear.ts
-class Tear {
- x;
- y;
- dx;
- dy;
- r = 5;
- speed = 7;
- damage = 1;
- life = 80;
- constructor(x, y, dx, dy) {
- this.x = x;
- this.y = y;
- this.dx = dx;
- this.dy = dy;
- }
- get alive() {
- return this.life > 0;
- }
-}
-
-// src/entities/MeleeSwing.ts
-class MeleeSwing {
- dir;
- life = 10;
- damage = 2;
- kb = 10;
- box;
- constructor(x, y, dir) {
- this.dir = dir;
- const d = 22;
- const s = 50;
- const [dx, dy] = DIR[dir];
- this.box = {
- x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
- y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2),
- w: s,
- h: s
- };
- }
- get alive() {
- return this.life > 0;
- }
-}
-
-// src/game/collision.ts
-function isBlocked(room, col, row) {
- if (row < 0 && room.doors.up && DOOR.up.cols.includes(col))
- return false;
- if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col))
- return false;
- if (col < 0 && room.doors.left && DOOR.left.rows.includes(row))
- return false;
- if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row))
- return false;
- if (row < 0 || row >= ROWS || col < 0 || col >= COLS)
- return true;
- return room.tiles[row][col] === T_WALL;
-}
-function collidesWall(box, room, ox, oy) {
- const l = Math.floor((box.x - ox) / TILE_SIZE);
- const r = Math.floor((box.x + box.w - ox) / TILE_SIZE);
- const t = Math.floor((box.y - oy) / TILE_SIZE);
- const b = Math.floor((box.y + box.h - oy) / TILE_SIZE);
- for (let row = t;row <= b; row++) {
- for (let col = l;col <= r; col++) {
- if (isBlocked(room, col, row))
- return true;
- }
- }
- return false;
-}
-var TILE_SIZE = 44;
-
-// src/game/transitions.ts
-var ENTRY_KEYS = {
- up: ["w", "W", "ArrowUp"],
- down: ["s", "S", "ArrowDown"],
- left: ["a", "A", "ArrowLeft"],
- right: ["d", "D", "ArrowRight"]
-};
-function keyPressed(dir) {
- for (const k of ENTRY_KEYS[dir]) {
- if (KEYS[k])
- return true;
- }
- return false;
-}
-function checkTransition(game) {
- if (game.gameOver || game.won)
- return;
- if (game.player.transCD > 0)
- return;
- const p = game.player;
- const room = game.curRoom;
- if (!room.cleared)
- return;
- const col = Math.floor((p.x - OX) / TILE);
- const row = Math.floor((p.y - OY) / TILE);
- if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && keyPressed("up")) {
- if (game.roomMap.has(game.cc, game.cr - 1)) {
- game.cr--;
- game.enterRoom("down");
- return;
- }
- }
- if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && keyPressed("down")) {
- if (game.roomMap.has(game.cc, game.cr + 1)) {
- game.cr++;
- game.enterRoom("up");
- return;
- }
- }
- if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && keyPressed("left")) {
- if (game.roomMap.has(game.cc - 1, game.cr)) {
- game.cc--;
- game.enterRoom("right");
- return;
- }
- }
- if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && keyPressed("right")) {
- if (game.roomMap.has(game.cc + 1, game.cr)) {
- game.cc++;
- game.enterRoom("left");
- return;
- }
- }
-}
-
-// src/render/roomRenderer.ts
-function drawRoom(ctx, room) {
- for (let r = 0;r < ROWS; r++) {
- for (let c = 0;c < COLS; c++) {
- const x = OX + c * TILE;
- const y = OY + r * TILE;
- const t = room.tiles[r][c];
- if (t === T_WALL) {
- ctx.fillStyle = "#1a1a24";
- ctx.fillRect(x, y, TILE, TILE);
- ctx.fillStyle = "#242436";
- ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4);
- ctx.fillStyle = "#1e1e2c";
- ctx.fillRect(x + 4, y + 4, TILE - 8, TILE - 8);
- ctx.strokeStyle = "#161620";
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x, y + TILE / 2);
- ctx.lineTo(x + TILE, y + TILE / 2);
- ctx.stroke();
- ctx.beginPath();
- ctx.moveTo(x + TILE / 2, y);
- ctx.lineTo(x + TILE / 2, y + TILE / 2);
- ctx.stroke();
- } else if (t === T_DOOR) {
- ctx.fillStyle = "#0d0d14";
- ctx.fillRect(x, y, TILE, TILE);
- ctx.fillStyle = "#2a1e0e";
- ctx.fillRect(x + 6, y + 6, TILE - 12, TILE - 12);
- ctx.fillStyle = "#3a2e14";
- ctx.fillRect(x + 10, y + 10, TILE - 20, TILE - 20);
- } else {
- const dark = (r + c) % 2 === 0;
- ctx.fillStyle = dark ? "#2e2e24" : "#353528";
- ctx.fillRect(x, y, TILE, TILE);
- }
- }
- }
- ctx.strokeStyle = "rgba(0,0,0,0.3)";
- ctx.lineWidth = 2;
- ctx.strokeRect(OX, OY, RW, RH);
-}
-
-// src/render/entityRenderer.ts
-function drawEntities(ctx, room, player, meleeSwing) {
- for (const e of room.enemies) {
- if (!e.alive)
- continue;
- const flash = e.hitTimer > 0 && e.hitTimer % 4 < 2;
- ctx.save();
- ctx.fillStyle = "rgba(0,0,0,0.3)";
- ctx.beginPath();
- ctx.ellipse(e.x + 2, e.y + e.h / 4, e.w / 3, 4, 0, 0, Math.PI * 2);
- ctx.fill();
- if (e.type === "boss") {
- drawBoss(ctx, e, flash);
- } else if (e.type === "fast") {
- drawFastEnemy(ctx, e, flash);
- } else {
- drawNormalEnemy(ctx, e, flash);
- }
- ctx.restore();
- }
- drawPlayer(ctx, player);
- for (const t of room.tears) {
- if (!t.alive)
- continue;
- ctx.save();
- ctx.fillStyle = "#6699cc";
- ctx.beginPath();
- ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#99bbee";
- ctx.beginPath();
- ctx.arc(t.x - 1.5, t.y - 1.5, t.r - 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
- }
- if (meleeSwing && meleeSwing.alive) {
- drawMeleeSwing(ctx, meleeSwing);
- }
-}
-function drawBoss(ctx, e, flash) {
- ctx.fillStyle = flash ? "#ddd" : "#5a0a0a";
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#4a0808";
- ctx.beginPath();
- ctx.arc(e.x - 3, e.y - 3, e.w / 2 - 4, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = flash ? "#000" : "#ff3333";
- ctx.beginPath();
- ctx.arc(e.x - 8, e.y - 8, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 8, e.y - 8, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#000";
- ctx.beginPath();
- ctx.arc(e.x - 8, e.y - 8, 2.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 8, e.y - 8, 2.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = flash ? "#bbb" : "#3a0505";
- ctx.beginPath();
- ctx.moveTo(e.x - 16, e.y - e.w / 2 + 4);
- ctx.lineTo(e.x - 8, e.y - e.w / 2 - 16);
- ctx.lineTo(e.x, e.y - e.w / 2 + 4);
- ctx.fill();
- ctx.beginPath();
- ctx.moveTo(e.x - 4, e.y - e.w / 2 + 4);
- ctx.lineTo(e.x + 4, e.y - e.w / 2 - 16);
- ctx.lineTo(e.x + 12, e.y - e.w / 2 + 4);
- ctx.fill();
- if (e.hp < e.maxHp) {
- ctx.fillStyle = "#222";
- ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44, 4);
- ctx.fillStyle = "#c33";
- ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44 * (e.hp / e.maxHp), 4);
- }
-}
-function drawFastEnemy(ctx, e, flash) {
- ctx.fillStyle = flash ? "#ddd" : "#992222";
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#771111";
- ctx.beginPath();
- ctx.arc(e.x - 1, e.y - 1, e.w / 2 - 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#ff4444";
- ctx.beginPath();
- ctx.arc(e.x - 5, e.y - 4, 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 5, e.y - 4, 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "#000";
- ctx.beginPath();
- ctx.arc(e.x - 5, e.y - 5, 1.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 5, e.y - 5, 1.5, 0, Math.PI * 2);
- ctx.fill();
-}
-function drawNormalEnemy(ctx, e, flash) {
- ctx.fillStyle = flash ? "#ccc" : "#5a4a2e";
- ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
- ctx.fillStyle = "#4a3a1e";
- ctx.fillRect(e.x - e.w / 2 + 3, e.y - e.h / 2 + 3, e.w - 6, e.h - 6);
- ctx.fillStyle = "#332816";
- ctx.fillRect(e.x - e.w / 2 + 6, e.y - e.h / 2 + 6, e.w - 12, e.h - 12);
- ctx.fillStyle = "#ffcc66";
- ctx.fillRect(e.x - 7, e.y - 5, 5, 5);
- ctx.fillRect(e.x + 2, e.y - 5, 5, 5);
- ctx.fillStyle = "#000";
- ctx.fillRect(e.x - 6, e.y - 4, 3, 3);
- ctx.fillRect(e.x + 3, e.y - 4, 3, 3);
-}
-function drawPlayer(ctx, p) {
- ctx.save();
- const flash = p.invTimer > 0 && p.invTimer % 6 < 3;
- const bodyColor = p.mode === 0 ? "#2a6a9a" : "#9a3a2a";
- ctx.fillStyle = flash ? "#ddd" : bodyColor;
- ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h);
- ctx.fillStyle = flash ? "#ccc" : "rgba(0,0,0,0.3)";
- ctx.fillRect(p.x - p.w / 2 + 3, p.y - p.h / 2 + 3, p.w - 6, p.h - 6);
- const [fx, fy] = DIR[p.facing];
- const wx = p.x + fx * (p.w / 2 + 4);
- const wy = p.y + fy * (p.h / 2 + 4);
- if (p.mode === 0) {
- drawPistol(ctx, p, wx, wy, fx, fy, flash);
- } else {
- drawKnife(ctx, wx, wy, fx, fy, flash);
- }
- ctx.fillStyle = "#fff";
- const ex = p.x + fx * 5;
- const ey = p.y + fy * 5;
- ctx.fillRect(ex - 5, ey - 4, 4, 5);
- ctx.fillRect(ex + 1, ey - 4, 4, 5);
- ctx.fillStyle = "#111";
- ctx.fillRect(ex - 4 + fx, ey - 3 + fy, 2, 3);
- ctx.fillRect(ex + 2 + fx, ey - 3 + fy, 2, 3);
- ctx.restore();
-}
-function drawPistol(ctx, p, wx, wy, fx, fy, flash) {
- ctx.strokeStyle = flash ? "#999" : "#555";
- ctx.lineWidth = 3;
- ctx.lineCap = "round";
- ctx.beginPath();
- ctx.moveTo(wx, wy);
- ctx.lineTo(wx + fx * 14 + fy * 2, wy + fy * 14 + fx * 2);
- ctx.stroke();
- ctx.fillStyle = flash ? "#aaa" : "#444";
- ctx.save();
- const angle = fy !== 0 ? Math.PI / 2 * (fy < 0 ? -1 : 1) : fx < 0 ? Math.PI : 0;
- ctx.translate(p.x + fx * 8, p.y + fy * 8);
- ctx.rotate(angle);
- ctx.fillRect(-7, -4, 14, 8);
- ctx.restore();
- if (p.atkCD > 8 && p.mode === 0) {
- ctx.fillStyle = "rgba(255,200,50,0.6)";
- ctx.beginPath();
- ctx.arc(wx + fx * 16, wy + fy * 16, 6, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = "rgba(255,255,200,0.4)";
- ctx.beginPath();
- ctx.arc(wx + fx * 18, wy + fy * 18, 8, 0, Math.PI * 2);
- ctx.fill();
- }
-}
-function drawKnife(ctx, wx, wy, fx, fy, flash) {
- ctx.strokeStyle = flash ? "#bbb" : "#ccc";
- ctx.lineWidth = 2;
- const kx = wx + fx * 6;
- const ky = wy + fy * 6;
- ctx.beginPath();
- ctx.moveTo(kx, ky);
- ctx.lineTo(kx + fx * 16 - fy * 6, ky + fy * 16 + fx * 6);
- ctx.lineTo(kx + fx * 16 + fy * 6, ky + fy * 16 - fx * 6);
- ctx.closePath();
- ctx.fillStyle = flash ? "#ddd" : "#d4d4d4";
- ctx.fill();
- ctx.stroke();
- ctx.fillStyle = flash ? "#a99" : "#5a3a1a";
- ctx.fillRect(kx - fx * 3 - fy * 3, ky - fy * 3 - fx * 3, 8, 8);
- ctx.fillStyle = flash ? "#bbb" : "#888";
- ctx.fillRect(kx - fx * 2 - fy * 5, ky - fy * 2 - fx * 5, 5, 12);
-}
-function drawMeleeSwing(ctx, s) {
- const alpha = s.life / 10;
- ctx.save();
- ctx.globalAlpha = alpha * 0.35;
- ctx.fillStyle = "#cc8844";
- ctx.fillRect(s.box.x, s.box.y, s.box.w, s.box.h);
- ctx.globalAlpha = alpha;
- ctx.strokeStyle = "#ddbb88";
- ctx.lineWidth = 2;
- ctx.strokeRect(s.box.x, s.box.y, s.box.w, s.box.h);
- ctx.globalAlpha = alpha * 0.8;
- ctx.strokeStyle = "#ffcc88";
- ctx.lineWidth = 3;
- const [dx, dy] = DIR[s.dir];
- ctx.beginPath();
- ctx.moveTo(s.box.x + s.box.w / 2 - dx * 18, s.box.y + s.box.h / 2 - dy * 18);
- ctx.lineTo(s.box.x + s.box.w / 2 + dx * 18, s.box.y + s.box.h / 2 + dy * 18);
- ctx.stroke();
- ctx.restore();
-}
-
-// src/render/hudRenderer.ts
-function drawHUD(ctx, player, room) {
- drawHPBar(ctx, player);
- drawModeIndicator(ctx, player);
- drawEnemyCount(ctx, room);
- drawRoomLabel(ctx, room);
-}
-function drawHPBar(ctx, p) {
- const bx = 20, by = 20, bw = 140, bh = 14;
- ctx.fillStyle = "#111";
- ctx.fillRect(bx, by, bw, bh);
- ctx.fillStyle = "#2a0a0a";
- ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
- const ratio = Math.max(0, p.hp / p.maxHp);
- const color = ratio > 0.5 ? "#993333" : ratio > 0.25 ? "#994422" : "#663322";
- ctx.fillStyle = color;
- ctx.fillRect(bx + 2, by + 2, (bw - 4) * ratio, bh - 4);
- ctx.strokeStyle = "#333";
- ctx.lineWidth = 1;
- ctx.strokeRect(bx, by, bw, bh);
- ctx.fillStyle = "#bbb";
- ctx.font = "10px monospace";
- ctx.textAlign = "center";
- ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3);
-}
-function drawModeIndicator(ctx, p) {
- const my = CH - 46;
- ctx.textAlign = "center";
- const label = p.mode === MODE_RANGED ? "RANGED" : "MELEE";
- const color = p.mode === MODE_RANGED ? "#4488cc" : "#cc6644";
- ctx.fillStyle = "#0d0d0d";
- ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
- ctx.strokeStyle = color;
- ctx.lineWidth = 2;
- ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
- ctx.fillStyle = color;
- ctx.font = "bold 17px monospace";
- ctx.fillText(`[ ${label} ]`, CW / 2, my + 8);
- ctx.fillStyle = "#555";
- ctx.font = "11px monospace";
- ctx.fillText("[Tab/Q] switch", CW / 2, my - 26);
- if (p.mode === MODE_RANGED) {
- ctx.strokeStyle = "#88bbdd";
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.moveTo(CW / 2 - 82, my - 4);
- ctx.lineTo(CW / 2 - 72, my - 4);
- ctx.stroke();
- ctx.fillStyle = "#88bbdd";
- ctx.fillRect(CW / 2 - 82, my - 8, 10, 8);
- } else {
- ctx.fillStyle = "#ddbb88";
- ctx.beginPath();
- ctx.moveTo(CW / 2 - 82, my - 10);
- ctx.lineTo(CW / 2 - 74, my - 2);
- ctx.lineTo(CW / 2 - 82, my + 4);
- ctx.fill();
- }
-}
-function drawEnemyCount(ctx, room) {
- const alive = room.enemies.filter((e) => e.alive).length;
- ctx.textAlign = "left";
- if (alive > 0) {
- ctx.fillStyle = "#aa4444";
- ctx.font = "13px monospace";
- ctx.fillText(`▶ ${alive}`, 20, CH - 18);
- } else if (!room.cleared && room.type !== "spawn") {
- ctx.fillStyle = "#886633";
- ctx.font = "13px monospace";
- ctx.fillText("Clear the room", 20, CH - 18);
- }
-}
-function drawRoomLabel(ctx, room) {
- if (!room.visited)
- return;
- ctx.textAlign = "right";
- const labels = { spawn: "START", normal: "", treasure: "TREASURE", boss: "BOSS" };
- const label = labels[room.type];
- if (label) {
- ctx.fillStyle = "#555";
- ctx.font = "11px monospace";
- ctx.fillText(label, CW - 20, OY + RH + 30);
- }
-}
-
-// src/render/minimapRenderer.ts
-var CELL = 14;
-var GAP = 2;
-function drawMinimap(ctx, map, cc, cr) {
- const cs = CELL + GAP;
- const mx = CW - 180;
- const my = 12;
- ctx.fillStyle = "rgba(0,0,0,0.75)";
- ctx.fillRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
- ctx.strokeStyle = "#333";
- ctx.lineWidth = 1;
- ctx.strokeRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
- for (let r = -3;r <= 3; r++) {
- for (let c = -3;c <= 3; c++) {
- const room = map.get(cc + c, cr + r);
- if (!room)
- continue;
- const x = mx + (c + 3) * cs;
- const y = my + (r + 3) * cs;
- let color = "#141414";
- if (room.visited) {
- color = room.type === "spawn" ? "#2a5a2a" : room.type === "boss" ? "#5a1a1a" : room.type === "treasure" ? "#5a5a1a" : "#555";
- }
- ctx.fillStyle = color;
- ctx.fillRect(x, y, CELL, CELL);
- if (room.visited) {
- ctx.strokeStyle = "rgba(255,255,255,0.12)";
- ctx.lineWidth = 1;
- if (room.doors.up)
- ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3);
- if (room.doors.down)
- ctx.fillRect(x + cs / 2 - 2, y + CELL - 1, 4, 3);
- if (room.doors.left)
- ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4);
- if (room.doors.right)
- ctx.fillRect(x + CELL - 1, y + cs / 2 - 2, 3, 4);
- }
- if (c === 0 && r === 0) {
- ctx.strokeStyle = "#ddd";
- ctx.lineWidth = 2;
- ctx.strokeRect(x - 1.5, y - 1.5, CELL + 3, CELL + 3);
- }
- }
- }
-}
-
-// src/game/Game.ts
-class Game {
- canvas;
- ctx;
- roomMap = new RoomMap;
- player = new Player;
- cc = 0;
- cr = 0;
- meleeSwing = null;
- gameOver = false;
- won = false;
- constructor(canvas) {
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d");
- this.enterRoom("up");
- this.loop();
- }
- get curRoom() {
- return this.roomMap.get(this.cc, this.cr);
- }
- toggleMode() {
- this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
- }
- restart() {
- this.gameOver = false;
- this.won = false;
- this.roomMap = new RoomMap;
- this.player = new Player;
- this.cc = 0;
- this.cr = 0;
- this.meleeSwing = null;
- this.enterRoom("up");
- }
- enterRoom(fromDir) {
- const room = this.curRoom;
- room.visited = true;
- const d = DOOR[fromDir];
- const [ddc, ddr] = DIR[fromDir];
- this.player.x = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
- this.player.y = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
- this.player.facing = fromDir;
- this.player.invTimer = 20;
- this.player.transCD = 15;
- this.meleeSwing = null;
- room.buildTiles();
- room.enemies = [];
- room.tears = [];
- if (!room.cleared && room.type !== "spawn") {
- this.spawnEnemies(room, fromDir);
- } else {
- room.cleared = true;
- room.buildTiles();
- }
- }
- spawnEnemies(room, entryDir) {
- const count = room.type === "boss" ? 1 : room.type === "treasure" ? 0 : 2 + Math.floor(Math.random() * 3);
- for (let i = 0;i < count; i++) {
- let tries = 0;
- let x, y, ok;
- const type = room.type === "boss" ? "boss" : Math.random() < 0.3 ? "fast" : "normal";
- do {
- x = OX + 2 * TILE + Math.random() * (COLS - 4) * TILE;
- y = OY + 2 * TILE + Math.random() * (ROWS - 4) * TILE;
- ok = true;
- const ed = DOOR[entryDir];
- const dx = OX + ed.cx * TILE + TILE / 2;
- const dy = OY + ed.cy * TILE + TILE / 2;
- if (Math.hypot(x - dx, y - dy) < 180)
- ok = false;
- for (const e of room.enemies) {
- if (Math.hypot(x - e.x, y - e.y) < 60) {
- ok = false;
- break;
- }
- }
- if (Math.hypot(x - this.player.x, y - this.player.y) < 150)
- ok = false;
- tries++;
- } while (!ok && tries < 100);
- room.enemies.push(new Enemy(x, y, type));
- }
- }
- loop() {
- if (!this.gameOver && !this.won)
- this.tick();
- this.render();
- requestAnimationFrame(() => this.loop());
- }
- tick() {
- const room = this.curRoom;
- const p = this.player;
- if (p.invTimer > 0)
- p.invTimer--;
- if (p.atkCD > 0)
- p.atkCD--;
- if (p.transCD > 0)
- p.transCD--;
- this.processMovement(p);
- this.processAttack(room, p);
- this.updateMelee(room);
- this.updateTears(room);
- const aliveCount = this.updateEnemies(room, p);
- if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
- room.cleared = true;
- room.buildTiles();
- }
- checkTransition(this);
- if (!this.gameOver) {
- const bossRoom = [...this.roomMap.rooms.values()].find((r) => r.type === "boss");
- if (bossRoom?.cleared)
- this.won = true;
- }
- }
- processMovement(p) {
- let mx = 0, my = 0;
- if (KEYS["w"] || KEYS["W"])
- my = -1;
- if (KEYS["s"] || KEYS["S"])
- my = 1;
- if (KEYS["a"] || KEYS["A"])
- mx = -1;
- if (KEYS["d"] || KEYS["D"])
- mx = 1;
- if (mx !== 0 || my !== 0) {
- const len = Math.hypot(mx, my);
- mx /= len;
- my /= len;
- if (my < 0)
- p.moveDir = "up";
- else if (my > 0)
- p.moveDir = "down";
- if (mx < 0)
- p.moveDir = "left";
- else if (mx > 0)
- p.moveDir = "right";
- const dx = mx * p.speed;
- const dy = my * p.speed;
- p.x += dx;
- if (collidesWall(p.box, this.curRoom, OX, OY))
- p.x -= dx;
- p.y += dy;
- if (collidesWall(p.box, this.curRoom, OX, OY))
- p.y -= dy;
- }
- }
- processAttack(room, p) {
- let ax = 0, ay = 0;
- if (KEYS["ArrowUp"]) {
- ax = 0;
- ay = -1;
- } else if (KEYS["ArrowDown"]) {
- ax = 0;
- ay = 1;
- } else if (KEYS["ArrowLeft"]) {
- ax = -1;
- ay = 0;
- } else if (KEYS["ArrowRight"]) {
- ax = 1;
- ay = 0;
- } else if (KEYS[" "] || KEYS["Space"]) {
- [ax, ay] = DIR[p.moveDir];
- }
- if ((ax !== 0 || ay !== 0) && p.atkCD <= 0) {
- const len = Math.hypot(ax, ay);
- ax /= len;
- ay /= len;
- const dn = ay < 0 ? "up" : ay > 0 ? "down" : ax < 0 ? "left" : "right";
- p.facing = dn;
- p.atkCD = p.mode === MODE_RANGED ? 10 : 22;
- if (p.mode === MODE_RANGED) {
- room.tears.push(new Tear(p.x, p.y, ax, ay));
- } else {
- this.meleeSwing = new MeleeSwing(p.x, p.y, dn);
- }
- }
- }
- updateMelee(room) {
- if (this.meleeSwing && !this.meleeSwing.alive)
- this.meleeSwing = null;
- if (!this.meleeSwing)
- return;
- this.meleeSwing.life--;
- for (const e of room.enemies) {
- if (!e.alive || e.hitTimer > 0)
- continue;
- if (overlap(e.box, this.meleeSwing.box)) {
- e.hp -= this.meleeSwing.damage;
- e.hitTimer = 10;
- const [dx, dy] = DIR[this.meleeSwing.dir];
- e.knx = dx * this.meleeSwing.kb;
- e.kny = dy * this.meleeSwing.kb;
- }
- }
- }
- updateTears(room) {
- for (const t of room.tears) {
- if (!t.alive)
- continue;
- t.x += t.dx * t.speed;
- t.y += t.dy * t.speed;
- t.life--;
- const col = Math.floor((t.x - OX) / TILE);
- const row = Math.floor((t.y - OY) / TILE);
- if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
- t.life = 0;
- continue;
- }
- if (room.tiles[row][col] === T_WALL) {
- t.life = 0;
- continue;
- }
- for (const e of room.enemies) {
- if (!e.alive)
- continue;
- if (Math.hypot(t.x - e.x, t.y - e.y) < e.w / 2 + t.r) {
- e.hp -= t.damage;
- e.hitTimer = 8;
- t.life = 0;
- break;
- }
- }
- }
- room.tears = room.tears.filter((t) => t.alive);
- }
- updateEnemies(room, p) {
- let aliveCount = 0;
- for (const e of room.enemies) {
- if (!e.alive)
- continue;
- aliveCount++;
- if (e.hitTimer > 0)
- e.hitTimer--;
- if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
- e.x += e.knx * 3;
- e.y += e.kny * 3;
- e.knx *= 0.85;
- e.kny *= 0.85;
- continue;
- }
- e.knx = 0;
- e.kny = 0;
- const dx = p.x - e.x;
- const dy = p.y - e.y;
- const d = Math.hypot(dx, dy);
- if (d > 0 && d < 500) {
- const s = e.speed;
- const mx = dx / d * s;
- const my = dy / d * s;
- e.x += mx;
- if (collidesWall(e.box, room, OX, OY))
- e.x -= mx;
- e.y += my;
- if (collidesWall(e.box, room, OX, OY))
- e.y -= my;
- }
- if (e.atkTimer > 0)
- e.atkTimer--;
- if (Math.hypot(e.x - p.x, e.y - p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
- p.hp -= e.damage;
- p.invTimer = 60;
- e.atkTimer = 30;
- if (p.hp <= 0) {
- this.gameOver = true;
- return aliveCount;
- }
- }
- }
- return aliveCount;
- }
- render() {
- const ctx = this.ctx;
- ctx.fillStyle = "#0a0a0f";
- ctx.fillRect(0, 0, CW, CH);
- drawRoom(ctx, this.curRoom);
- drawEntities(ctx, this.curRoom, this.player, this.meleeSwing);
- drawHUD(ctx, this.player, this.curRoom);
- drawMinimap(ctx, this.roomMap, this.cc, this.cr);
- if (this.gameOver)
- this.drawOverlay("#c33", "GAME OVER");
- else if (this.won)
- this.drawOverlay("#3c3", "VICTORY");
- }
- drawOverlay(color, text) {
- const ctx = this.ctx;
- ctx.fillStyle = "rgba(0,0,0,0.8)";
- ctx.fillRect(0, 0, CW, CH);
- ctx.fillStyle = color;
- ctx.font = "bold 56px monospace";
- ctx.textAlign = "center";
- ctx.fillText(text, CW / 2, CH / 2 - 20);
- ctx.fillStyle = "#888";
- ctx.font = "18px monospace";
- ctx.fillText("[R] restart", CW / 2, CH / 2 + 40);
- }
-}
-
-// src/main.ts
-setupInput();
-window.addEventListener("keydown", (e) => {
- const game = window.__game;
- if (!game)
- return;
- if ((e.key === "Tab" || e.key === "q" || e.key === "Q") && !game.gameOver && !game.won) {
- e.preventDefault();
- game.toggleMode();
- }
- if (e.key === "r" || e.key === "R") {
- if (game.gameOver || game.won)
- game.restart();
- }
-});
-window.addEventListener("load", () => {
- const canvas = document.getElementById("game");
- if (!canvas) {
- document.body.innerHTML = 'Error: canvas element not found
';
- return;
- }
- const game = new Game(canvas);
- window.__game = game;
-});
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index cb6adb5..9c2f805 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,131 +1,176 @@
-# Architecture
+# Архитектура
-## Overview
-
-The game is a single-page Canvas 2D application. The source is written
-in TypeScript and split into ~19 modules under `src/`, bundled by Bun
-into a single `dist/main.js`. The HTML shell in `dist/index.html` loads
-the bundle.
-
-## Module Dependency Graph
+## Главный принцип: логика отдельно от рендера
```
-main.ts
- ├── input.ts (global KEYS, setupInput)
- └── Game.ts
- ├── constants.ts (all shared numeric constants)
- ├── types.ts (Dir, RoomType, CombatMode, Box, Doors)
- ├── math.ts (shuffle, rand, ri, dist, overlap)
- ├── RoomMap.ts → Room.ts, tiles.ts, doors.ts
- ├── Player.ts
- ├── Enemy.ts
- ├── Tear.ts
- ├── MeleeSwing.ts
- ├── collision.ts → Room.ts, tiles.ts
- ├── transitions.ts → Game.ts (type only), input.ts
- ├── spawner.ts → Room.ts, Enemy.ts
- └── render/*
- → roomRenderer.ts, entityRenderer.ts,
- hudRenderer.ts, minimapRenderer.ts
+ ┌──────────────────────────────────────────┐
+ │ GameLoop (engine/) │
+ │ фиксированный шаг 60 Гц + интерполяция │
+ └───────┬───────────────┬──────────────┬─────┘
+ │ poll() │ step(input) │ render(game, alpha)
+ ▼ ▼ ▼
+ KeyboardController Game (core/) Renderer (render/)
+ клавиши → InputState состояние + ЧИТАЕТ состояние,
+ логика игры рисует three.js + HUD
```
-## Game Loop
+Три части не знают лишнего друг о друге:
-Every frame (`requestAnimationFrame`):
+- **`core/`** — игровая логика. Не импортирует ни `three`, ни DOM (`window`,
+ `document`, `canvas`). Получает на вход абстрактный `InputState`, меняет своё
+ состояние. Поэтому ядро **тестируется без браузера** (`bun test`) и не зависит
+ от того, чем мы рисуем.
+- **`render/`** — рендер. Только **читает** `Game` и рисует. Менять состояние
+ игры рендеру запрещено.
+- **`input/`** — превращает устройство ввода (клавиатуру) в абстрактные
+ «намерения» (`InputState`). Захочешь геймпад — добавишь ещё один контроллер.
-1. **Timers** — decrement `invTimer`, `atkCD`, `transCD`
-2. **Movement** — read WASD, apply velocity, resolve wall collisions
-3. **Attack** — read arrow keys / Space, fire tear or create melee swing
-4. **Melee Update** — decrement swing life, apply damage + knockback
-5. **Tear Update** — move projectiles, check wall/enemy collisions
-6. **Enemy AI** — chase player, contact damage with cooldown
-7. **Room Clear** — if all enemies dead → `room.cleared = true`, rebuild tiles with doors
-8. **Transition Check** — if player on door tile + key pressed → load adjacent room
-9. **Win Check** — if boss room cleared → `won = true`
-10. **Render** — draw room tiles → entities → HUD → minimap → overlays
+`main.ts` — единственное место, где всё это соединяется.
-## Transition System
+> **Почему так строго.** Этот проект уже один раз сломался, когда логику,
+> рендер и состояние перемешали и расползлись по модулям. Граница «логика ↔
+> рендер» — то, что не даёт повторить ту историю. Не протаскивай `three` в
+> `core/` и не лезь в игровое состояние из рендера.
-Room transitions are the most complex subsystem. Key design:
+## Игровой цикл с фиксированным шагом (engine/GameLoop.ts)
-- **Door geometry**: `DOOR` constant defines 3-tile-wide openings at
- each cardinal edge (top: cols 6-8, row 0; bottom: cols 6-8, row 10;
- left: rows 4-6, col 0; right: rows 4-6, col 14).
-- **Detection**: `checkTransition()` computes the player's tile position
- (`col, row`). If they're on a door tile AND pressing the matching
- movement key, the transition fires.
-- **Cooldown**: `transCD = 15` prevents re-entry within 15 frames.
-- **Movement lock**: Transition only works in cleared rooms
- (`room.cleared === true`).
-- **Collision bypass**: `isBlocked()` returns `false` for out-of-bounds
- tiles at door openings, letting the player's bounding box extend
- beyond the room boundary.
+Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
+зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
-## Collision System
+1. Раз в кадр опрашиваем ввод (`controller.poll()`).
+2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
+3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
+ порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
+4. Рисуем с коэффициентом интерполяции `alpha` (доля до следующего шага), чтобы
+ движение было плавным и на 144 Гц.
-- `isBlocked(room, col, row)` — per-tile check. Returns `true` for
- wall tiles and out-of-bounds positions, except at door openings.
-- `collidesWall(box, room, ox, oy)` — iterates all tiles covered by
- the entity's bounding box. If any tile is blocked, returns `true`.
-- Movement resolves axis-independently: apply X, revert if collision;
- apply Y, revert if collision.
+Единица времени везде — **шаг** (= 1/60 c). Скорости заданы «пикселей за шаг»,
+перезарядки — «в шагах».
-## Room Generation
+## Шаг симуляции (core/Game.ts → step)
-`RoomMap.generate()` uses a random walk:
+Каждый шаг по порядку:
-1. Start at (0,0) with a spawn room.
-2. Maintain a frontier list of rooms that have room to expand.
-3. Each step, pick a random frontier room, shuffle directions, and
- attempt to add a new room in an unoccupied adjacent cell within
- the 7×7 grid.
-4. Room types are assigned probabilistically (boss at ~20%, treasure
- at ~12%, rest normal).
-5. If no boss room was generated, one normal room is promoted.
+1. **Снимок prev-позиций** — `prevX/prevY` всех подвижных сущностей (для интерполяции).
+2. **Таймеры** — `invTimer`, `atkCD`, `transCD`.
+3. **Движение** игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
+4. **Атака** — стрелки/пробел: выстрел (`Projectile`) или взмах (`MeleeSwing`).
+5. **Ближний бой** — урон+отбрасывание по врагам в хитбоксе взмаха.
+6. **Снаряды** — полёт, попадание в стену/врага.
+7. **ИИ врагов** — преследование игрока, контактный урон с перезарядкой.
+8. **Зачистка** — если врагов было >0 и все мертвы → `cleared=true`, открыть двери.
+9. **Переход** — стоя на двери и нажимая в её сторону → соседняя комната.
+10. **Победа** — если комната-босс зачищена → `won=true`.
-## Rendering
+Смерть (`hp<=0`) ставит `gameOver=true`; пока `gameOver`/`won` — `step()` ничего не делает.
-Rendering is split into 4 stateless functions, each taking
-`CanvasRenderingContext2D` as the first argument:
+## Ввод (input/)
-- **roomRenderer** — tile loop with wall/door/floor styles
-- **entityRenderer** — enemies (3 types), player (body + weapon),
- tears, melee swing arc
-- **hudRenderer** — HP bar, mode indicator, enemy count, room label
-- **minimapRenderer** — 7×7 grid with visited/current room highlights
+`InputState` — снимок намерений: оси движения, направление прицела, флаги
+удержания и однократные «edge»-действия. Делится на:
-## Weapon Visuals
+- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
+- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
+ поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
-- **Pistol (ranged)**: Barrel line + body rect, rotated toward facing
- direction. Muzzle flash circle at `atkCD > 8`.
-- **Knife (melee)**: Triangular blade + handle + guard rects, offset
- in facing direction.
+## Мир: комнаты и генерация (core/world/)
-## Enemy Types
+- **Комната** — сетка `COLS×ROWS` тайлов (стены по краю, пол внутри). Двери
+ прорезаются в тайлах только когда комната `cleared` (или это `spawn`).
+- **RoomMap** — связный набор комнат на сетке `(2·MAP_RADIUS+1)²`, генерируется
+ **случайным блужданием прямо в конструкторе**. Двери ставятся парами: у текущей
+ комнаты в сторону соседа и встречная у соседа (через `OPP`).
-| Type | Size | HP | Speed | Damage | Visual |
-|--------|------|----|-------|--------|--------|
-| Normal | 32px | 3 | 1.15 | 1 | Brown block, yellow eyes |
-| Fast | 26px | 2 | 1.9 | 1 | Red circle, small eyes |
-| Boss | 46px | 10 | 0.9 | 2 | Large red circle, horns, HP bar |
+> **Исторический баг №1.** При разнесении на модули у `RoomMap` потеряли вызов
+> генерации → карта была пустой → игра падала на старте. Теперь генерация в
+> конструкторе, а тест `tests/roomMap.test.ts` это стережёт.
+>
+> **Исторический баг №2.** `OPP` была `{up:'bottom', down:'top'}` (несуществующие
+> ключи дверей) — встречные двери не ставились. Сейчас `{up:'down', down:'up'}`,
+> симметрия дверей покрыта тестом.
-## State Management
+## Коллизии (core/systems/collision.ts)
-- `GAME_OVER` / `WON` — boolean flags checked in loop and render.
-- `KEYS` — global mutable map, reset on window blur.
-- Room state (`cleared`, `visited`, `enemies[]`, `tears[]`) — per-room.
-- Player state (`hp`, `mode`, `facing`, `transCD`, etc.) — single
- `Player` instance.
-- Cooldowns (`invTimer`, `atkCD`, `transCD`) decrement each frame.
+- `isBlocked(room, col, row)` — тайл-стена или выход за границы блокируют, **кроме**
+ дверных проёмов (там граница «прозрачна», чтобы встать на дверь и перейти).
+- `collidesWall(box, room)` — перебирает тайлы под хитбоксом.
+- Движение разрешается по осям раздельно: применяем X (откат при коллизии),
+ затем Y. Это даёт «скольжение» вдоль стен.
-## Build Pipeline
+## Рендер (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` — список для меню
+(добавил объект → пункт появился). Правила протекают так:
```
-bun build src/main.ts --outdir dist --target browser --minify
-cp src/index.html dist/
+LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер карты
+ └──► Player(rules.player) // HP/скорость
+ └──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
```
-- Bundler: Bun's native bundler (esbuild under the hood).
-- Target: browser (ES module → IIFE/global wrapper).
-- Result: single `dist/main.js` (~30 KB) + `dist/index.html`.
-- Open `dist/index.html` directly in any modern browser.
+Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
+`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
+`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
+
+### Тема / ассеты (render/theme.ts)
+
+Внешний вид мира вынесен в `Theme` (сейчас — только цвета примитивов). `ThreeRenderer`
+берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику.
+Это **задел под кастомные ассеты**: чтобы перейти на спрайты/текстуры, расширь `Theme`
+полями с путями к изображениям, загрузи их `THREE.TextureLoader` и положи в
+`material.map`. Логика игры при этом не меняется.
diff --git a/docs/HOWTO.md b/docs/HOWTO.md
new file mode 100644
index 0000000..c53300b
--- /dev/null
+++ b/docs/HOWTO.md
@@ -0,0 +1,161 @@
+# HOWTO — рецепты доработки
+
+Практические инструкции «как сделать X». Каждый рецепт перечисляет все файлы,
+которые нужно тронуть. После любой правки — `bun run check` и проверка в браузере
+(`bun run dev`). Общие правила — в [`CLAUDE.md`](../CLAUDE.md).
+
+---
+
+## 1. Покрутить баланс (скорость, HP, урон, число врагов)
+
+Всё в одном файле — **`src/config.ts`**. Например:
+
+- игрок быстрее: `PLAYER.speed`;
+- больше HP у босса: `ENEMY_STATS.boss.hp`;
+- больше врагов в комнате: `SPAWN.normalMin` / `SPAWN.normalExtra`;
+- крупнее данжен: `MIN_ROOMS` / `EXTRA_ROOMS` / `MAP_RADIUS`.
+
+Код менять не нужно. Это и есть смысл `config.ts`.
+
+---
+
+## 2. Добавить новый тип врага (например, `tank`)
+
+1. **`src/core/types.ts`** — добавь в union: `export type EnemyType = 'normal' | 'fast' | 'boss' | 'tank';`
+2. **`src/config.ts`** — строка характеристик в `ENEMY_STATS`:
+ ```ts
+ tank: { size: 40, hp: 8, speed: 0.7, damage: 2 },
+ ```
+3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
+ в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
+4. **`src/render/ThreeRenderer.ts`** — цвет в `COLOR` и выбор цвета/геометрии в
+ `syncEnemies` (квадрат `unitPlane` или круг `unitCircle`).
+
+ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
+Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
+
+---
+
+## 3. Добавить новое поведение врага (особый ИИ)
+
+Сейчас все враги ведут себя одинаково (преследование игрока) в
+`Game.updateEnemies` (`src/core/Game.ts`). Чтобы развести поведение:
+
+- по `e.type` внутри `updateEnemies` развилкой (просто, для 2–3 типов), **или**
+- вынеси ИИ в `src/core/systems/ai.ts` как функции `update(enemy, player, room)`
+ и диспетчеризуй по типу (чище, когда типов много).
+
+Держи это в `core/` (без рендера) и старайся писать чистыми функциями — их легко
+покрыть тестом.
+
+---
+
+## 4. Добавить оружие / третий режим боя
+
+Режимы — это `MODE_RANGED = 0` / `MODE_MELEE = 1` и тип `CombatMode = 0 | 1`.
+Для третьего режима:
+
+1. **`src/config.ts`** — константа `MODE_X = 2` и блок баланса.
+2. **`src/core/types.ts`** — расширь `CombatMode` (`0 | 1 | 2`).
+3. **`src/core/Game.ts`** — в `handleAttack` добавь ветку создания нужного снаряда/
+ эффекта; в `consumeActions` смена оружия циклом по всем режимам.
+4. **`src/render/HudOverlay.ts`** — подпись/иконка режима.
+5. Если это новый вид снаряда — заведи сущность в `src/core/entities/` и обновляй
+ её в `Game.step` (по образцу `Projectile`/`updateTears`), а в `ThreeRenderer`
+ добавь её отрисовку.
+
+---
+
+## 5. Добавить тип комнаты (например, `shop`)
+
+1. **`src/core/types.ts`** — добавь в `RoomType`.
+2. **`src/core/world/RoomMap.ts`** — правило назначения типа в `generate()`.
+3. **`src/core/systems/spawner.ts`** — сколько врагов (часто 0).
+4. **`src/core/Game.ts`** — если врагов 0, комната уже авто-зачищается при входе
+ (см. `enterRoom`); особая логика комнаты — здесь же.
+5. **`src/render/HudOverlay.ts`** — цвет на миникарте и подпись (`drawMinimap`/`drawHud`).
+
+---
+
+## 6. Поменять управление
+
+**`src/input/KeyboardController.ts`** — метод `poll()` (маппинг клавиш на
+`InputState`) и набор `PREVENT` (клавиши, у которых гасим поведение браузера).
+Логику игры это не затрагивает — она читает только `InputState`.
+
+Геймпад/тач: сделай новый контроллер с тем же `poll(): InputState` и подставь его
+в `main.ts`.
+
+---
+
+## 7. Добавить уровень (пресет правил) в меню
+
+**`src/core/rules.ts`** — добавь объект в массив `PRESETS`. Он сразу появится
+кнопкой в стартовом меню (меню строится из `PRESETS`). Например:
+
+```ts
+{
+ id: 'swarm',
+ name: 'Орда',
+ description: 'Очень много слабых быстрых врагов.',
+ map: { minRooms: 10, extraRooms: 4, mapRadius: 4 },
+ player: { maxHp: 6, speed: 3.2 },
+ enemies: { densityMul: 2.5, fastChance: 0.9, hpMul: 0.5, speedMul: 1.2, bossHpMul: 1 },
+},
+```
+
+- `seed` (необязательный) фиксирует данжен — одинаковый забег каждый раз.
+- Нужен новый «рычаг» (например, шанс сокровищниц)? Добавь поле в `LevelRules` и
+ читай его там, где раньше брал константу из `config.ts` (генерация — `RoomMap`,
+ спавн — `spawner`). Тест на влияние правил — в `tests/game.test.ts`.
+
+---
+
+## 8. Кастомные ассеты (своя тема / текстуры)
+
+Сейчас вид мира — это цвета в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
+
+- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
+ `new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
+ раз при старте с темой по умолчанию и переиспользуется между забегами, поэтому
+ чтобы **выбирать тему по правилам уровня**, нужно либо перенести
+ `new ThreeRenderer(...)` внутрь `startGame(rules)` (и `dispose()` предыдущий),
+ либо добавить рендеру метод `setTheme(theme)`.
+- **Перейти на спрайты/текстуры:** расширь `Theme` полями с путями к картинкам
+ (см. комментарий-задел в `theme.ts`), загрузи их через `THREE.TextureLoader` в
+ `ThreeRenderer` и положи текстуру в `material.map` соответствующих мешей вместо
+ (или вместе с) `color`. Логика игры при этом НЕ меняется — это чисто рендер.
+
+---
+
+## 9. Поменять или нарастить рендер (вплоть до 3D)
+
+Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
++ `dispose()`). Варианты:
+
+- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer`.
+- **Сделать 3D** — поменяй `OrthographicCamera` на `PerspectiveCamera`, добавь
+ свет и 3D-меши. Мир рисуется по тем же координатам сущностей из `Game` — логику
+ менять не нужно.
+- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
+ и подставь в `main.ts`. Ядро не трогается вообще.
+
+Помни про управление ресурсами (правило 6 в `CLAUDE.md`).
+
+---
+
+## 10. Отладка
+
+- В консоли браузера доступен `game` — текущий `Game`. Примеры:
+ ```js
+ game.player.hp = 99 // бессмертие на тест
+ game.curRoom.enemies.length // сколько врагов в комнате
+ game.reset() // новая карта
+ ```
+- **Детерминизм:** чтобы воспроизвести конкретную карту, задай `seed` правилам —
+ проще всего поставить `seed: 42` нужному пресету в `core/rules.ts` (см. пресет
+ `daily`). Тот же seed — та же генерация и спавн, в т.ч. после рестарта.
+ В тестах можно передать свой ГПСЧ **вторым** аргументом: `new Game(rules, new Rng(42))`
+ (первый аргумент — правила, не ГПСЧ).
+- **Тесты ядра** гоняются мгновенно и без браузера: `bun test`. Логику отлаживай
+ тестом, а не кликами.
diff --git a/index.html b/index.html
index 21de224..2c1d8b7 100644
--- a/index.html
+++ b/index.html
@@ -1,702 +1,66 @@
-
-Dungeon Crawl — Ranged / Melee
-
+
+
+ Dungeon Crawl — three.js
+
-
-
+
+
+
+
diff --git a/package.json b/package.json
index 321b2c1..ccc239f 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,16 @@
"scripts": {
"dev": "bun run dev.ts",
"build": "bun run build.ts",
- "start": "bun run build && echo 'open dist/index.html'"
+ "typecheck": "tsc --noEmit",
+ "test": "bun test",
+ "check": "bun run typecheck && bun test",
+ "start": "bun run build && echo 'Готово — открой dist/index.html'"
+ },
+ "dependencies": {
+ "three": "^0.184.0"
+ },
+ "devDependencies": {
+ "@types/three": "^0.184.1",
+ "typescript": "^6.0.3"
}
}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..4f57033
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,142 @@
+/**
+ * config.ts — ВСЕ настройки игры в одном месте.
+ *
+ * Здесь сосредоточены: размеры мира, геометрия комнат/дверей и баланс
+ * (скорости, здоровье, перезарядки). Меняй числа тут, чтобы «крутить»
+ * игру — больше ничего трогать не нужно.
+ *
+ * ВАЖНО: единица времени — один «шаг» симуляции (= 1/60 секунды), а НЕ
+ * кадр браузера. Цикл игры (см. engine/GameLoop.ts) гарантирует ровно
+ * 60 шагов в секунду на любом мониторе, поэтому скорости заданы «пикселей
+ * за шаг», а перезарядки — «в шагах».
+ */
+
+// ─────────────────────────────────────────────────────────────
+// Холст и сетка
+// ─────────────────────────────────────────────────────────────
+export const CW = 880; // ширина области отрисовки (px)
+export const CH = 660; // высота области отрисовки (px)
+export const TILE = 44; // размер одного тайла (px)
+export const COLS = 15; // тайлов по горизонтали в комнате
+export const ROWS = 11; // тайлов по вертикали в комнате
+export const RW = COLS * TILE; // ширина комнаты в пикселях
+export const RH = ROWS * TILE; // высота комнаты в пикселях
+export const OX = (CW - RW) / 2; // отступ комнаты слева
+export const OY = 80; // отступ комнаты сверху (место под HUD)
+
+// Частота симуляции. Логика всегда обновляется с этим шагом.
+export const FIXED_FPS = 60;
+export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
+
+// ─────────────────────────────────────────────────────────────
+// Типы тайлов
+// ─────────────────────────────────────────────────────────────
+export const T_WALL = 0;
+export const T_FLOOR = 1;
+export const T_DOOR = 2;
+
+// ─────────────────────────────────────────────────────────────
+// Режимы боя
+// ─────────────────────────────────────────────────────────────
+export const MODE_RANGED = 0;
+export const MODE_MELEE = 1;
+
+// ─────────────────────────────────────────────────────────────
+// Направления
+// ─────────────────────────────────────────────────────────────
+import type { Dir } from './core/types';
+
+/** Единичный вектор смещения для каждого направления (x вправо, y вниз). */
+export const DIR: Record = {
+ up: [0, -1],
+ down: [0, 1],
+ left: [-1, 0],
+ right: [1, 0],
+};
+
+/** Противоположное направление. Используется при простановке дверей соседей. */
+export const OPP: Record = {
+ up: 'down',
+ down: 'up',
+ left: 'right',
+ right: 'left',
+};
+
+/**
+ * Геометрия дверей: проём шириной в 3 тайла по центру каждой стороны.
+ * cx/cy — координата тайла-центра двери (для спавна игрока и расчётов).
+ */
+export const DOOR = {
+ up: { cols: [6, 7, 8] as number[], row: 0, cx: 7, cy: 0 },
+ down: { cols: [6, 7, 8] as number[], row: ROWS - 1, cx: 7, cy: ROWS - 1 },
+ left: { col: 0, rows: [4, 5, 6] as number[], cx: 0, cy: 5 },
+ right: { col: COLS - 1, rows: [4, 5, 6] as number[], cx: COLS - 1, cy: 5 },
+};
+
+// ─────────────────────────────────────────────────────────────
+// Генерация карты (случайное блуждание)
+// ─────────────────────────────────────────────────────────────
+export const MAP_RADIUS = 3; // карта вмещается в сетку (2*R+1)²
+export const MIN_ROOMS = 8; // минимум комнат
+export const EXTRA_ROOMS = 4; // + случайно до этого числа
+
+// ─────────────────────────────────────────────────────────────
+// Баланс: игрок
+// ─────────────────────────────────────────────────────────────
+export const PLAYER = {
+ size: 26,
+ speed: 3.2, // px за шаг
+ maxHp: 6,
+ invFrames: 60, // неуязвимость после удара, в шагах
+ rangedCooldown: 10, // перезарядка выстрела, в шагах
+ meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
+ transitionLock: 15, // блок повторного перехода между комнатами, в шагах
+};
+
+// ─────────────────────────────────────────────────────────────
+// Баланс: снаряд (слеза) и ближний бой
+// ─────────────────────────────────────────────────────────────
+export const PROJECTILE = {
+ radius: 5,
+ speed: 7, // px за шаг
+ damage: 1,
+ life: 80, // время жизни в шагах
+};
+
+export const MELEE = {
+ reach: 22, // отступ хитбокса от центра игрока
+ size: 50, // сторона квадратного хитбокса
+ life: 10, // длительность взмаха в шагах
+ damage: 2,
+ knockback: 10,
+};
+
+// ─────────────────────────────────────────────────────────────
+// Баланс: враги (таблица характеристик по типу)
+// ─────────────────────────────────────────────────────────────
+export const ENEMY_STATS = {
+ normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
+ fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
+ boss: { size: 46, hp: 10, speed: 0.9, damage: 2 },
+} as const;
+
+export const ENEMY = {
+ aggroRange: 500, // дистанция, с которой враг начинает преследование
+ attackCooldown: 30, // пауза между контактными ударами, в шагах
+ hitFlash: 8, // длительность «мигания» при попадании, в шагах
+ knockbackDecay: 0.85,
+ fastChance: 0.3, // доля быстрых врагов в обычной комнате
+};
+
+// ─────────────────────────────────────────────────────────────
+// Спавн врагов
+// ─────────────────────────────────────────────────────────────
+export const SPAWN = {
+ normalMin: 2, // минимум врагов в обычной комнате
+ normalExtra: 2, // + случайно до этого числа
+ minDistFromDoor: 180, // не спавнить ближе к двери входа
+ minDistFromPlayer: 150,
+ minDistBetween: 60,
+ treasureChance: 0.12, // шанс комнаты-сокровищницы
+ bossChance: 0.2, // шанс назначить комнату боссом
+};
diff --git a/src/constants.ts b/src/constants.ts
deleted file mode 100644
index e13d96c..0000000
--- a/src/constants.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-// Canvas & grid dimensions
-export const CW = 880;
-export const CH = 660;
-export const TILE = 44;
-export const COLS = 15;
-export const ROWS = 11;
-export const RW = COLS * TILE;
-export const RH = ROWS * TILE;
-export const OX = (CW - RW) / 2;
-export const OY = 80;
-
-// Tile types
-export const T_WALL = 0;
-export const T_FLOOR = 1;
-export const T_DOOR = 2;
-
-// Combat modes
-export const MODE_RANGED = 0;
-export const MODE_MELEE = 1;
-
-// Direction vectors
-export const DIR: Record = {
- up: [0, -1],
- down: [0, 1],
- left: [-1, 0],
- right: [1, 0],
-};
-
-// Opposite direction lookup
-export const OPP: Record = {
- up: 'bottom',
- down: 'top',
- left: 'right',
- right: 'left',
-};
-
-// Door opening geometry (3 tiles wide at each cardinal edge)
-export const DOOR = {
- up: { cols: [6, 7, 8], row: 0, cx: 7, cy: 0 },
- down: { cols: [6, 7, 8], row: 10, cx: 7, cy: 10 },
- left: { col: 0, rows: [4, 5, 6], cx: 0, cy: 5 },
- right: { col: 14, rows: [4, 5, 6], cx: 14, cy: 5 },
-} as const;
-
-// Grid generation bounds
-export const MAP_RADIUS = 3;
-export const MIN_ROOMS = 8;
-export const EXTRA_ROOMS = 4;
diff --git a/src/core/Game.ts b/src/core/Game.ts
new file mode 100644
index 0000000..ff2a4f4
--- /dev/null
+++ b/src/core/Game.ts
@@ -0,0 +1,326 @@
+import {
+ DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL,
+ MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE,
+} from '../config';
+import type { Dir } from './types';
+import { Rng } from './rng';
+import { dist, overlap } from './util';
+import { Player } from './entities/Player';
+import { Projectile } from './entities/Projectile';
+import { MeleeSwing } from './entities/MeleeSwing';
+import { RoomMap } from './world/RoomMap';
+import type { Room } from './world/Room';
+import { collidesWall } from './systems/collision';
+import { spawnEnemies } from './systems/spawner';
+import { DEFAULT_RULES, type LevelRules } from './rules';
+import type { InputState } from '../input/InputState';
+import { pressingDir } from '../input/InputState';
+
+/**
+ * Game — «мозг» игры. Полностью независим от рендера и DOM: ничего не
+ * рисует и не знает про three.js/canvas. Хранит всё изменяемое состояние
+ * и продвигает симуляцию ровно на один фиксированный шаг в step().
+ *
+ * Контракт с внешним миром:
+ * • consumeActions(input) — один раз за кадр: смена оружия, рестарт;
+ * • step(input) — один фиксированный шаг физики/логики;
+ * • публичные геттеры/поля — читает рендер.
+ */
+export class Game {
+ readonly rules: LevelRules;
+ rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
+ roomMap: RoomMap;
+ player: Player;
+ cc = 0; // координаты текущей комнаты на карте
+ cr = 0;
+ meleeSwing: MeleeSwing | null = null;
+ gameOver = false;
+ won = false;
+
+ /**
+ * @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
+ * @param rng опционально свой ГПСЧ; иначе берётся seed из правил (или случайный).
+ */
+ constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
+ this.rules = rules;
+ this.rng = rng ?? new Rng(rules.seed);
+ this.player = new Player(rules.player);
+ this.roomMap = new RoomMap(this.rng, rules);
+ this.enterRoom('up');
+ }
+
+ /** Текущая комната (всегда существует: карта связна и переходы — только в имеющиеся комнаты). */
+ get curRoom(): Room {
+ return this.roomMap.get(this.cc, this.cr)!;
+ }
+
+ // ── Публичный контракт цикла ──────────────────────────────
+
+ /** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */
+ consumeActions(input: InputState): void {
+ if (input.toggleWeapon && !this.gameOver && !this.won) {
+ this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
+ }
+ if (input.restart && (this.gameOver || this.won)) {
+ this.reset();
+ }
+ }
+
+ /** Один фиксированный шаг симуляции (= 1/60 c). */
+ step(input: InputState): void {
+ if (this.gameOver || this.won) return;
+
+ const room = this.curRoom;
+ const p = this.player;
+
+ // Запоминаем позиции для плавной интерполяции при рендере.
+ p.prevX = p.x; p.prevY = p.y;
+ for (const e of room.enemies) { e.prevX = e.x; e.prevY = e.y; }
+ for (const t of room.tears) { t.prevX = t.x; t.prevY = t.y; }
+
+ // Таймеры.
+ if (p.invTimer > 0) p.invTimer--;
+ if (p.atkCD > 0) p.atkCD--;
+ if (p.transCD > 0) p.transCD--;
+
+ this.movePlayer(input, room, p);
+ this.handleAttack(input, room, p);
+ this.updateMelee(room);
+ this.updateTears(room);
+ const aliveCount = this.updateEnemies(room, p);
+ if (this.gameOver) return;
+
+ // Комната зачищена: открываем двери.
+ if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
+ room.cleared = true;
+ room.rebuildTiles();
+ }
+
+ this.checkTransition(input);
+ this.checkWin();
+ }
+
+ /** Полный сброс — новая карта, новый игрок (рестарт после конца игры). */
+ reset(): void {
+ this.gameOver = false;
+ this.won = false;
+ // Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
+ this.rng = new Rng(this.rules.seed);
+ this.roomMap = new RoomMap(this.rng, this.rules);
+ this.player = new Player(this.rules.player);
+ this.cc = 0;
+ this.cr = 0;
+ this.meleeSwing = null;
+ this.enterRoom('up');
+ }
+
+ // ── Переход между комнатами ───────────────────────────────
+
+ /** Расставляет игрока внутри текущей комнаты у двери fromDir и (при нужде) спавнит врагов. */
+ enterRoom(fromDir: Dir): void {
+ const room = this.curRoom;
+ room.visited = true;
+
+ const d = DOOR[fromDir];
+ const [ddc, ddr] = DIR[fromDir];
+ // Ставим игрока на один тайл внутрь от центра двери.
+ const px = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
+ const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
+ this.player.place(px, py);
+ this.player.facing = fromDir;
+ this.player.invTimer = 20; // короткая неуязвимость на входе
+ this.player.transCD = PLAYER.transitionLock;
+
+ this.meleeSwing = null;
+ room.tears = [];
+
+ if (!room.cleared && room.type !== 'spawn') {
+ room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules);
+ // Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
+ // иначе двери никогда не появятся и игрок застрянет.
+ if (room.enemies.length === 0) room.cleared = true;
+ room.rebuildTiles();
+ } else {
+ room.cleared = true;
+ room.enemies = [];
+ room.rebuildTiles();
+ }
+ }
+
+ // ── Системы (по одному шагу) ──────────────────────────────
+
+ private movePlayer(input: InputState, room: Room, p: Player): void {
+ let mx = input.moveX;
+ let my = input.moveY;
+ if (mx === 0 && my === 0) return;
+
+ const len = Math.hypot(mx, my);
+ mx /= len;
+ my /= len;
+
+ if (input.moveY < 0) p.moveDir = 'up';
+ else if (input.moveY > 0) p.moveDir = 'down';
+ if (input.moveX < 0) p.moveDir = 'left';
+ else if (input.moveX > 0) p.moveDir = 'right';
+
+ const dx = mx * p.speed;
+ const dy = my * p.speed;
+ // Раздельное разрешение коллизий по осям: позволяет «скользить» вдоль стен.
+ p.x += dx;
+ if (collidesWall(p.box, room)) p.x -= dx;
+ p.y += dy;
+ if (collidesWall(p.box, room)) p.y -= dy;
+ }
+
+ private handleAttack(input: InputState, room: Room, p: Player): void {
+ let dir: Dir | null = null;
+ if (input.aimDir) dir = input.aimDir; // прицельная стрельба стрелками
+ else if (input.attackHeld) dir = p.moveDir; // пробел — по ходу движения
+
+ if (!dir || p.atkCD > 0) return;
+
+ p.facing = dir;
+ p.atkCD = p.mode === MODE_RANGED ? PLAYER.rangedCooldown : PLAYER.meleeCooldown;
+ const [nx, ny] = DIR[dir];
+ if (p.mode === MODE_RANGED) {
+ room.tears.push(new Projectile(p.x, p.y, nx, ny));
+ } else {
+ this.meleeSwing = new MeleeSwing(p.x, p.y, dir);
+ }
+ }
+
+ private updateMelee(room: Room): void {
+ if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null;
+ if (!this.meleeSwing) return;
+
+ this.meleeSwing.life--;
+ for (const e of room.enemies) {
+ if (!e.alive || e.hitTimer > 0) continue;
+ if (overlap(e.box, this.meleeSwing.box)) {
+ e.hp -= this.meleeSwing.damage;
+ e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом
+ const [dx, dy] = DIR[this.meleeSwing.dir];
+ e.knx = dx * this.meleeSwing.kb;
+ e.kny = dy * this.meleeSwing.kb;
+ }
+ }
+ }
+
+ private updateTears(room: Room): void {
+ for (const t of room.tears) {
+ if (!t.alive) continue;
+ t.x += t.dx * t.speed;
+ t.y += t.dy * t.speed;
+ t.life--;
+
+ const col = Math.floor((t.x - OX) / TILE);
+ const row = Math.floor((t.y - OY) / TILE);
+ if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
+ t.life = 0;
+ continue;
+ }
+ if (room.tiles[row][col] === T_WALL) {
+ t.life = 0;
+ continue;
+ }
+ for (const e of room.enemies) {
+ if (!e.alive) continue;
+ if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
+ e.hp -= t.damage;
+ e.hitTimer = ENEMY.hitFlash;
+ t.life = 0;
+ break;
+ }
+ }
+ }
+ room.tears = room.tears.filter((t) => t.alive);
+ }
+
+ private updateEnemies(room: Room, p: Player): number {
+ let aliveCount = 0;
+
+ for (const e of room.enemies) {
+ if (!e.alive) continue;
+ aliveCount++;
+
+ if (e.hitTimer > 0) e.hitTimer--;
+
+ // Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии
+ // проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла)
+ // пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок).
+ if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
+ e.x += e.knx * 3;
+ if (collidesWall(e.box, room)) e.x -= e.knx * 3;
+ e.y += e.kny * 3;
+ if (collidesWall(e.box, room)) e.y -= e.kny * 3;
+ e.knx *= ENEMY.knockbackDecay;
+ e.kny *= ENEMY.knockbackDecay;
+ continue;
+ }
+ e.knx = 0;
+ e.kny = 0;
+
+ // Преследование игрока.
+ const dx = p.x - e.x;
+ const dy = p.y - e.y;
+ const d = Math.hypot(dx, dy);
+ if (d > 0 && d < ENEMY.aggroRange) {
+ const mx = (dx / d) * e.speed;
+ const my = (dy / d) * e.speed;
+ e.x += mx;
+ if (collidesWall(e.box, room)) e.x -= mx;
+ e.y += my;
+ if (collidesWall(e.box, room)) e.y -= my;
+ }
+
+ // Контактный урон по игроку.
+ if (e.atkTimer > 0) e.atkTimer--;
+ if (dist(e.x, e.y, p.x, p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
+ p.hp -= e.damage;
+ p.invTimer = PLAYER.invFrames;
+ e.atkTimer = ENEMY.attackCooldown;
+ if (p.hp <= 0) {
+ p.hp = 0;
+ this.gameOver = true;
+ return aliveCount;
+ }
+ }
+ }
+
+ return aliveCount;
+ }
+
+ // ── Переходы и победа ─────────────────────────────────────
+
+ private checkTransition(input: InputState): void {
+ const p = this.player;
+ if (p.transCD > 0) return;
+ const room = this.curRoom;
+ if (!room.cleared) return;
+
+ const col = Math.floor((p.x - OX) / TILE);
+ const row = Math.floor((p.y - OY) / TILE);
+
+ if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && pressingDir(input, 'up')) {
+ if (this.roomMap.has(this.cc, this.cr - 1)) { this.cr--; this.enterRoom('down'); return; }
+ }
+ if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && pressingDir(input, 'down')) {
+ if (this.roomMap.has(this.cc, this.cr + 1)) { this.cr++; this.enterRoom('up'); return; }
+ }
+ if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && pressingDir(input, 'left')) {
+ if (this.roomMap.has(this.cc - 1, this.cr)) { this.cc--; this.enterRoom('right'); return; }
+ }
+ if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && pressingDir(input, 'right')) {
+ if (this.roomMap.has(this.cc + 1, this.cr)) { this.cc++; this.enterRoom('left'); return; }
+ }
+ }
+
+ private checkWin(): void {
+ for (const room of this.roomMap.rooms.values()) {
+ if (room.type === 'boss' && room.cleared) {
+ this.won = true;
+ return;
+ }
+ }
+ }
+}
diff --git a/src/core/entities/Enemy.ts b/src/core/entities/Enemy.ts
new file mode 100644
index 0000000..7ac5ff8
--- /dev/null
+++ b/src/core/entities/Enemy.ts
@@ -0,0 +1,50 @@
+import { ENEMY_STATS } from '../../config';
+import type { Box, EnemyType } from '../types';
+
+/**
+ * Враг. Характеристики берутся из таблицы ENEMY_STATS по типу.
+ * Чтобы добавить новый тип врага — допиши строку в ENEMY_STATS (config.ts)
+ * и тип в EnemyType (core/types.ts). Логика и спавн подхватят автоматически.
+ */
+export class Enemy {
+ x: number;
+ y: number;
+ prevX: number;
+ prevY: number;
+ readonly type: EnemyType;
+ readonly w: number;
+ readonly h: number;
+ hp: number;
+ readonly maxHp: number;
+ readonly speed: number;
+ readonly damage: number;
+ knx = 0; // отбрасывание по X
+ kny = 0; // отбрасывание по Y
+ hitTimer = 0; // мигание при попадании (шаги)
+ atkTimer = 0; // перезарядка контактного удара (шаги)
+
+ /**
+ * mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
+ * поэтому `new Enemy(x, y, type)` даёт базовый баланс из ENEMY_STATS.
+ */
+ constructor(x: number, y: number, type: EnemyType, mods: { hpMul?: number; speedMul?: number } = {}) {
+ this.x = this.prevX = x;
+ this.y = this.prevY = y;
+ this.type = type;
+ const s = ENEMY_STATS[type];
+ this.w = s.size;
+ this.h = s.size;
+ this.maxHp = Math.max(1, Math.round(s.hp * (mods.hpMul ?? 1)));
+ this.hp = this.maxHp;
+ this.speed = s.speed * (mods.speedMul ?? 1);
+ this.damage = s.damage;
+ }
+
+ get box(): Box {
+ return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
+ }
+
+ get alive(): boolean {
+ return this.hp > 0;
+ }
+}
diff --git a/src/entities/MeleeSwing.ts b/src/core/entities/MeleeSwing.ts
similarity index 52%
rename from src/entities/MeleeSwing.ts
rename to src/core/entities/MeleeSwing.ts
index d6f9401..777a70e 100644
--- a/src/entities/MeleeSwing.ts
+++ b/src/core/entities/MeleeSwing.ts
@@ -1,17 +1,17 @@
+import { MELEE, DIR } from '../../config';
import type { Dir, Box } from '../types';
-import { DIR } from '../constants';
+/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
export class MeleeSwing {
- dir: Dir;
- life = 10;
- damage = 2;
- kb = 10;
- box: Box;
+ readonly dir: Dir;
+ life = MELEE.life;
+ readonly damage = MELEE.damage;
+ readonly kb = MELEE.knockback;
+ readonly box: Box;
constructor(x: number, y: number, dir: Dir) {
this.dir = dir;
- const d = 22;
- const s = 50;
+ const { reach: d, size: s } = MELEE;
const [dx, dy] = DIR[dir];
this.box = {
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
diff --git a/src/core/entities/Player.ts b/src/core/entities/Player.ts
new file mode 100644
index 0000000..c8a2d12
--- /dev/null
+++ b/src/core/entities/Player.ts
@@ -0,0 +1,43 @@
+import { PLAYER, MODE_RANGED } from '../../config';
+import type { CombatMode, Box, Dir } from '../types';
+
+/**
+ * Игрок. Только данные и геометрия — никакой отрисовки.
+ * prevX/prevY хранят позицию на прошлом шаге для плавной интерполяции
+ * при рендере (см. render/).
+ */
+export class Player {
+ x = 0;
+ y = 0;
+ prevX = 0;
+ prevY = 0;
+ readonly w = PLAYER.size;
+ readonly h = PLAYER.size;
+ readonly speed: number;
+ hp: number;
+ readonly maxHp: number;
+ mode: CombatMode = MODE_RANGED;
+ facing: Dir = 'up'; // куда смотрит/целится
+ moveDir: Dir = 'up'; // последнее направление движения
+ atkCD = 0; // перезарядка атаки (шаги)
+ invTimer = 0; // неуязвимость (шаги)
+ transCD = 0; // блок перехода между комнатами (шаги)
+
+ /** Переопределения из правил уровня; по умолчанию — баланс из config. */
+ constructor(rules: { maxHp?: number; speed?: number } = {}) {
+ this.maxHp = rules.maxHp ?? PLAYER.maxHp;
+ this.hp = this.maxHp;
+ this.speed = rules.speed ?? PLAYER.speed;
+ }
+
+ /** Хитбокс с центром в (x, y). */
+ get box(): Box {
+ return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
+ }
+
+ /** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
+ place(x: number, y: number): void {
+ this.x = this.prevX = x;
+ this.y = this.prevY = y;
+ }
+}
diff --git a/src/core/entities/Projectile.ts b/src/core/entities/Projectile.ts
new file mode 100644
index 0000000..053417b
--- /dev/null
+++ b/src/core/entities/Projectile.ts
@@ -0,0 +1,26 @@
+import { PROJECTILE } from '../../config';
+
+/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */
+export class Projectile {
+ x: number;
+ y: number;
+ prevX: number;
+ prevY: number;
+ dx: number;
+ dy: number;
+ readonly r = PROJECTILE.radius;
+ readonly speed = PROJECTILE.speed;
+ readonly damage = PROJECTILE.damage;
+ life = PROJECTILE.life;
+
+ constructor(x: number, y: number, dx: number, dy: number) {
+ this.x = this.prevX = x;
+ this.y = this.prevY = y;
+ this.dx = dx;
+ this.dy = dy;
+ }
+
+ get alive(): boolean {
+ return this.life > 0;
+ }
+}
diff --git a/src/core/rng.ts b/src/core/rng.ts
new file mode 100644
index 0000000..ce1a74d
--- /dev/null
+++ b/src/core/rng.ts
@@ -0,0 +1,57 @@
+/**
+ * rng.ts — генератор случайных чисел с поддержкой seed.
+ *
+ * Зачем не просто Math.random(): с фиксированным seed карта и спавн
+ * становятся воспроизводимыми. Это бесценно для отладки («дай мне ту же
+ * самую кривую генерацию») и для тестов (см. tests/). Вся игровая логика
+ * получает один экземпляр Rng и использует только его — никаких прямых
+ * вызовов Math.random() в ядре.
+ */
+
+export class Rng {
+ private state: number;
+
+ /** Без seed — случайный старт; с seed — детерминированная цепочка. */
+ constructor(seed?: number) {
+ // 0 — валидный seed, поэтому проверяем именно на undefined.
+ this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0;
+ }
+
+ /** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
+ next(): number {
+ this.state = (this.state + 0x6d2b79f5) >>> 0;
+ let t = this.state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ }
+
+ /** Случайное вещественное в [a, b). */
+ float(a: number, b: number): number {
+ return this.next() * (b - a) + a;
+ }
+
+ /** Случайное целое в [a, b] включительно. */
+ int(a: number, b: number): number {
+ return Math.floor(this.float(a, b + 1));
+ }
+
+ /** true с вероятностью p (0..1). */
+ chance(p: number): boolean {
+ return this.next() < p;
+ }
+
+ /** Случайный элемент массива. */
+ pick(arr: readonly T[]): T {
+ return arr[this.int(0, arr.length - 1)];
+ }
+
+ /** Перемешивание Фишера–Йейтса на месте. */
+ shuffle(arr: T[]): T[] {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = this.int(0, i);
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ return arr;
+ }
+}
diff --git a/src/core/rules.ts b/src/core/rules.ts
new file mode 100644
index 0000000..6ae1b51
--- /dev/null
+++ b/src/core/rules.ts
@@ -0,0 +1,94 @@
+/**
+ * rules.ts — ПРАВИЛА УРОВНЯ (конфигурация забега).
+ *
+ * Мир по-прежнему генерируется процедурно (каждый забег — новый), но теперь
+ * параметризуется набором правил: размер данжена, плотность и сила врагов,
+ * здоровье игрока, фиксированный seed. На старте игрок выбирает один из
+ * пресетов (меню), и `Game` создаётся с этими правилами.
+ *
+ * Как добавить свой уровень: допиши объект в PRESETS — он сразу появится в меню.
+ * Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
+ * не «правила уровня», а константы движка.
+ */
+import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS } from '../config';
+
+export interface LevelRules {
+ /** Машинный id (для сохранений/выбора). */
+ id: string;
+ /** Название для меню. */
+ name: string;
+ /** Короткое описание для меню. */
+ description: string;
+ /** Фиксированный seed генерации. undefined → случайный каждый забег. */
+ seed?: number;
+
+ /** Параметры генерации карты. */
+ map: {
+ minRooms: number;
+ extraRooms: number;
+ mapRadius: number;
+ };
+
+ /** Параметры игрока. */
+ player: {
+ maxHp: number;
+ speed: number;
+ };
+
+ /** Параметры врагов (множители поверх базовых из config.ENEMY_STATS). */
+ enemies: {
+ densityMul: number; // множитель числа врагов в обычной комнате
+ fastChance: number; // доля быстрых врагов
+ hpMul: number; // множитель HP всех врагов
+ speedMul: number; // множитель скорости
+ bossHpMul: number; // отдельный множитель HP босса
+ };
+}
+
+/** Базовые правила = текущий «ванильный» баланс из config. */
+export const DEFAULT_RULES: LevelRules = {
+ id: 'standard',
+ name: 'Стандарт',
+ description: 'Классический забег. Сбалансированный данжен.',
+ map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
+ player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
+ enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
+};
+
+/** Пресеты для меню. Первый — по умолчанию. */
+export const PRESETS: LevelRules[] = [
+ DEFAULT_RULES,
+ {
+ id: 'big',
+ name: 'Большой данжен',
+ description: 'Больше комнат — длиннее забег.',
+ map: { minRooms: 14, extraRooms: 6, mapRadius: 4 },
+ player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
+ enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
+ },
+ {
+ id: 'hardcore',
+ name: 'Хардкор',
+ description: 'Мало HP, больше быстрых и живучих врагов.',
+ map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
+ player: { maxHp: 3, speed: PLAYER.speed },
+ enemies: { densityMul: 1.5, fastChance: 0.55, hpMul: 1.4, speedMul: 1.15, bossHpMul: 1.5 },
+ },
+ {
+ id: 'explorer',
+ name: 'Исследователь',
+ description: 'Мирно: много HP, мало слабых врагов — просто ходить и изучать.',
+ map: { minRooms: 12, extraRooms: 4, mapRadius: 4 },
+ player: { maxHp: 10, speed: PLAYER.speed * 1.1 },
+ enemies: { densityMul: 0.5, fastChance: 0.15, hpMul: 0.7, speedMul: 0.9, bossHpMul: 0.8 },
+ },
+ {
+ id: 'daily',
+ name: 'Фикс-сид',
+ description: 'Один и тот же данжен каждый раз (seed=2026) — удобно тренироваться.',
+ seed: 2026,
+ map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
+ player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
+ enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
+ },
+];
diff --git a/src/core/systems/collision.ts b/src/core/systems/collision.ts
new file mode 100644
index 0000000..08548de
--- /dev/null
+++ b/src/core/systems/collision.ts
@@ -0,0 +1,33 @@
+import { ROWS, COLS, DOOR, TILE, OX, OY, T_WALL } from '../../config';
+import type { Room } from '../world/Room';
+import type { Box } from '../types';
+
+/**
+ * Заблокирован ли тайл (col, row) для движения.
+ * В дверных проёмах граница комнаты «прозрачна» — это позволяет хитбоксу
+ * заехать за край и встать на дверь для перехода в соседнюю комнату.
+ */
+export function isBlocked(room: Room, col: number, row: number): boolean {
+ if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false;
+ if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false;
+ if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false;
+ if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false;
+
+ if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true;
+ return room.tiles[row][col] === T_WALL;
+}
+
+/** Пересекает ли хитбокс хотя бы один заблокированный тайл. */
+export function collidesWall(box: Box, room: Room): boolean {
+ const left = Math.floor((box.x - OX) / TILE);
+ const right = Math.floor((box.x + box.w - OX) / TILE);
+ const top = Math.floor((box.y - OY) / TILE);
+ const bottom = Math.floor((box.y + box.h - OY) / TILE);
+
+ for (let row = top; row <= bottom; row++) {
+ for (let col = left; col <= right; col++) {
+ if (isBlocked(room, col, row)) return true;
+ }
+ }
+ return false;
+}
diff --git a/src/core/systems/spawner.ts b/src/core/systems/spawner.ts
new file mode 100644
index 0000000..3bc7c8d
--- /dev/null
+++ b/src/core/systems/spawner.ts
@@ -0,0 +1,64 @@
+import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
+import { Enemy } from '../entities/Enemy';
+import { dist } from '../util';
+import type { Room } from '../world/Room';
+import type { Dir, EnemyType } from '../types';
+import type { Rng } from '../rng';
+import { DEFAULT_RULES, type LevelRules } from '../rules';
+
+/**
+ * Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
+ * вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
+ * берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
+ * его в room.enemies.
+ */
+export function spawnEnemies(
+ room: Room,
+ entryDir: Dir,
+ playerX: number,
+ playerY: number,
+ rng: Rng,
+ rules: LevelRules = DEFAULT_RULES,
+): Enemy[] {
+ const enemies: Enemy[] = [];
+ const er = rules.enemies;
+
+ const count =
+ room.type === 'boss' ? 1 :
+ room.type === 'treasure' ? 0 :
+ Math.max(1, Math.round((SPAWN.normalMin + rng.int(0, SPAWN.normalExtra)) * er.densityMul));
+
+ const door = DOOR[entryDir];
+ const doorX = OX + door.cx * TILE + TILE / 2;
+ const doorY = OY + door.cy * TILE + TILE / 2;
+
+ for (let i = 0; i < count; i++) {
+ const type: EnemyType =
+ room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal';
+ const mods = {
+ hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
+ speedMul: er.speedMul,
+ };
+
+ let x = 0;
+ let y = 0;
+ let ok = false;
+ for (let tries = 0; tries < 100 && !ok; tries++) {
+ x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
+ y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
+ ok = true;
+
+ if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
+ else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
+ else {
+ for (const e of enemies) {
+ if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
+ }
+ }
+ }
+
+ enemies.push(new Enemy(x, y, type, mods));
+ }
+
+ return enemies;
+}
diff --git a/src/types.ts b/src/core/types.ts
similarity index 52%
rename from src/types.ts
rename to src/core/types.ts
index 961eff7..4f29d43 100644
--- a/src/types.ts
+++ b/src/core/types.ts
@@ -1,7 +1,11 @@
+/** Общие типы данных, на которые опирается вся игра. */
+
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
export type Dir = 'up' | 'down' | 'left' | 'right';
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
+export type EnemyType = 'normal' | 'fast' | 'boss';
+/** Прямоугольник (axis-aligned bounding box) для коллизий. */
export interface Box {
x: number;
y: number;
@@ -9,6 +13,7 @@ export interface Box {
h: number;
}
+/** Какие из четырёх дверей есть у комнаты. */
export interface Doors {
up: boolean;
down: boolean;
diff --git a/src/core/util.ts b/src/core/util.ts
new file mode 100644
index 0000000..9a6c093
--- /dev/null
+++ b/src/core/util.ts
@@ -0,0 +1,28 @@
+/** Маленькие чистые математические утилиты без состояния. */
+
+import type { Box } from './types';
+
+/** Евклидова дистанция между точками. */
+export function dist(x1: number, y1: number, x2: number, y2: number): number {
+ return Math.hypot(x2 - x1, y2 - y1);
+}
+
+/** Пересекаются ли два прямоугольника (AABB). */
+export function overlap(a: Box, b: Box): boolean {
+ return (
+ a.x < b.x + b.w &&
+ a.x + a.w > b.x &&
+ a.y < b.y + b.h &&
+ a.y + a.h > b.y
+ );
+}
+
+/** Ограничить значение отрезком [min, max]. */
+export function clamp(v: number, min: number, max: number): number {
+ return v < min ? min : v > max ? max : v;
+}
+
+/** Линейная интерполяция (для плавной отрисовки между шагами). */
+export function lerp(a: number, b: number, t: number): number {
+ return a + (b - a) * t;
+}
diff --git a/src/core/world/Room.ts b/src/core/world/Room.ts
new file mode 100644
index 0000000..e74c174
--- /dev/null
+++ b/src/core/world/Room.ts
@@ -0,0 +1,34 @@
+import type { RoomType, Doors } from '../types';
+import { buildTiles } from './tiles';
+import type { Enemy } from '../entities/Enemy';
+import type { Projectile } from '../entities/Projectile';
+
+/**
+ * Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
+ * посещена» и живущие в ней сущности. Двери в тайлах появляются только
+ * когда комната зачищена (или это спавн) — пока враги живы, выходы закрыты.
+ */
+export class Room {
+ readonly c: number;
+ readonly r: number;
+ type: RoomType;
+ doors: Doors = { up: false, down: false, left: false, right: false };
+ visited = false;
+ cleared = false;
+ enemies: Enemy[] = [];
+ tears: Projectile[] = [];
+ tiles: number[][];
+
+ constructor(c: number, r: number, type: RoomType) {
+ this.c = c;
+ this.r = r;
+ this.type = type;
+ this.tiles = buildTiles();
+ }
+
+ /** Перестроить тайлы; двери прорезаются, если комната зачищена или это спавн. */
+ rebuildTiles(): void {
+ const showDoors = this.cleared || this.type === 'spawn';
+ this.tiles = buildTiles(showDoors ? this.doors : undefined);
+ }
+}
diff --git a/src/core/world/RoomMap.ts b/src/core/world/RoomMap.ts
new file mode 100644
index 0000000..db4042c
--- /dev/null
+++ b/src/core/world/RoomMap.ts
@@ -0,0 +1,103 @@
+import { OPP, SPAWN } from '../../config';
+import { Room } from './Room';
+import type { Dir, RoomType } from '../types';
+import type { Rng } from '../rng';
+import { DEFAULT_RULES, type LevelRules } from '../rules';
+
+/**
+ * Карта комнат: связный набор комнат на сетке (2*MAP_RADIUS+1)².
+ * Генерируется случайным блужданием СРАЗУ в конструкторе — поэтому
+ * `new RoomMap(rng)` всегда даёт готовую карту (раньше тут терялся вызов
+ * generate(), и игра падала на пустой карте).
+ */
+export class RoomMap {
+ readonly rooms = new Map();
+
+ constructor(rng: Rng, private readonly rules: LevelRules = DEFAULT_RULES) {
+ this.generate(rng);
+ }
+
+ private key(c: number, r: number): string {
+ return c + ',' + r;
+ }
+
+ get(c: number, r: number): Room | undefined {
+ return this.rooms.get(this.key(c, r));
+ }
+
+ has(c: number, r: number): boolean {
+ return this.rooms.has(this.key(c, r));
+ }
+
+ private add(c: number, r: number, type: RoomType): Room {
+ const room = new Room(c, r, type);
+ this.rooms.set(this.key(c, r), room);
+ return room;
+ }
+
+ private hasBoss(): boolean {
+ for (const room of this.rooms.values()) {
+ if (room.type === 'boss') return true;
+ }
+ return false;
+ }
+
+ private generate(rng: Rng): void {
+ this.add(0, 0, 'spawn');
+
+ const { minRooms, extraRooms, mapRadius } = this.rules.map;
+ const frontier: Array<[number, number]> = [[0, 0]];
+ let count = 1;
+ const target = minRooms + rng.int(0, extraRooms);
+
+ const dirs: Array<[Dir, number, number]> = [
+ ['up', 0, -1],
+ ['down', 0, 1],
+ ['left', -1, 0],
+ ['right', 1, 0],
+ ];
+
+ while (frontier.length > 0 && count < target) {
+ const idx = rng.int(0, frontier.length - 1);
+ const [c, r] = frontier[idx];
+ rng.shuffle(dirs);
+ let added = false;
+
+ for (const [dir, dc, dr] of dirs) {
+ if (count >= target) break;
+ const nc = c + dc;
+ const nr = r + dr;
+
+ if (Math.abs(nc) > mapRadius || Math.abs(nr) > mapRadius) continue;
+ if (this.has(nc, nr)) continue;
+
+ let type: RoomType = 'normal';
+ if (!this.hasBoss() && (count === target - 1 || (rng.chance(SPAWN.bossChance) && count >= 3))) {
+ type = 'boss';
+ } else if (rng.chance(SPAWN.treasureChance) && count >= 2) {
+ type = 'treasure';
+ }
+
+ this.add(nc, nr, type);
+ // Открываем дверь у текущей комнаты и ВСТРЕЧНУЮ дверь у соседа.
+ this.get(c, r)!.doors[dir] = true;
+ this.get(nc, nr)!.doors[OPP[dir]] = true;
+
+ frontier.push([nc, nr]);
+ count++;
+ added = true;
+ }
+
+ if (!added) frontier.splice(idx, 1);
+ }
+
+ // Страховка: если босс почему-то не появился — назначаем им любую
+ // не-спавновую комнату.
+ if (!this.hasBoss()) {
+ const candidates = [...this.rooms.values()].filter((rm) => rm.type !== 'spawn');
+ if (candidates.length > 0) {
+ candidates[rng.int(0, candidates.length - 1)].type = 'boss';
+ }
+ }
+ }
+}
diff --git a/src/core/world/tiles.ts b/src/core/world/tiles.ts
new file mode 100644
index 0000000..0717049
--- /dev/null
+++ b/src/core/world/tiles.ts
@@ -0,0 +1,27 @@
+import { T_WALL, T_FLOOR, T_DOOR, COLS, ROWS, DOOR } from '../../config';
+import type { Doors } from '../types';
+
+/**
+ * Строит свежую сетку тайлов комнаты: по краям стены, внутри пол.
+ * Если передан doorState — в стенах прорезаются дверные проёмы.
+ */
+export function buildTiles(doorState?: Doors): number[][] {
+ const tiles: number[][] = [];
+ for (let r = 0; r < ROWS; r++) {
+ tiles[r] = [];
+ for (let c = 0; c < COLS; c++) {
+ const isEdge = r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1;
+ tiles[r][c] = isEdge ? T_WALL : T_FLOOR;
+ }
+ }
+ if (doorState) placeDoors(tiles, doorState);
+ return tiles;
+}
+
+/** Помечает дверные тайлы на готовой сетке согласно набору открытых дверей. */
+export function placeDoors(tiles: number[][], doors: Doors): void {
+ if (doors.up) for (const c of DOOR.up.cols) tiles[DOOR.up.row][c] = T_DOOR;
+ if (doors.down) for (const c of DOOR.down.cols) tiles[DOOR.down.row][c] = T_DOOR;
+ if (doors.left) for (const r of DOOR.left.rows) tiles[r][DOOR.left.col] = T_DOOR;
+ if (doors.right) for (const r of DOOR.right.rows) tiles[r][DOOR.right.col] = T_DOOR;
+}
diff --git a/src/doors.ts b/src/doors.ts
deleted file mode 100644
index b5c1506..0000000
--- a/src/doors.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { DOOR, DIR, OPP } from './constants';
-import type { Dir, Doors } from './types';
-
-export { DOOR, DIR, OPP };
-export type { Dir, Doors };
-
-/** Return the direction opposite to the given one */
-export function oppositeDir(d: Dir): Dir {
- return OPP[d] as Dir;
-}
-
-/** Return the [dc, dr] offset for a direction */
-export function dirOffset(d: Dir): [number, number] {
- return DIR[d];
-}
diff --git a/src/engine/GameLoop.ts b/src/engine/GameLoop.ts
new file mode 100644
index 0000000..cae4fec
--- /dev/null
+++ b/src/engine/GameLoop.ts
@@ -0,0 +1,61 @@
+import { FIXED_DT } from '../config';
+import type { Game } from '../core/Game';
+import type { KeyboardController } from '../input/KeyboardController';
+
+/**
+ * Игровой цикл с ФИКСИРОВАННЫМ шагом.
+ *
+ * Почему так: старый код двигал всё прямо в requestAnimationFrame, поэтому
+ * скорость зависела от частоты монитора — на 144 Гц игра летела в 2.4 раза
+ * быстрее. Здесь логика всегда обновляется ровно 60 раз в секунду (накопитель
+ * времени), а рендер рисует с интерполяцией. Игра идёт одинаково везде.
+ */
+export class GameLoop {
+ private accumulator = 0;
+ private last = 0;
+ private rafId = 0;
+ private running = false;
+ private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
+
+ constructor(
+ private readonly game: Game,
+ private readonly controller: KeyboardController,
+ private readonly onRender: (alpha: number) => void,
+ ) {}
+
+ start(): void {
+ if (this.running) return;
+ this.running = true;
+ this.last = performance.now();
+ this.rafId = requestAnimationFrame(this.frame);
+ }
+
+ stop(): void {
+ this.running = false;
+ cancelAnimationFrame(this.rafId);
+ }
+
+ private frame = (now: number): void => {
+ this.rafId = requestAnimationFrame(this.frame);
+
+ let frameTime = (now - this.last) / 1000;
+ this.last = now;
+ if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб)
+
+ // Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
+ const input = this.controller.poll();
+ this.game.consumeActions(input);
+
+ this.accumulator += frameTime;
+ let steps = 0;
+ while (this.accumulator >= FIXED_DT && steps < this.maxSteps) {
+ this.game.step(input);
+ this.accumulator -= FIXED_DT;
+ steps++;
+ }
+ if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся
+
+ const alpha = this.accumulator / FIXED_DT;
+ this.onRender(alpha);
+ };
+}
diff --git a/src/entities/Enemy.ts b/src/entities/Enemy.ts
deleted file mode 100644
index d805a7f..0000000
--- a/src/entities/Enemy.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { rand } from '../math';
-import type { Box } from '../types';
-
-export type EnemyType = 'normal' | 'fast' | 'boss';
-
-/** Stats table indexed by enemy type */
-const STATS: Record = {
- normal: { w: 32, hp: 3, speed: 1.15, damage: 1 },
- fast: { w: 26, hp: 2, speed: 1.9, damage: 1 },
- boss: { w: 46, hp: 10, speed: 0.9, damage: 2 },
-};
-
-export class Enemy {
- x: number;
- y: number;
- type: EnemyType;
- w: number;
- h: number;
- hp: number;
- maxHp: number;
- speed: number;
- damage: number;
- knx = 0;
- kny = 0;
- hitTimer = 0;
- atkTimer = 0;
-
- constructor(x: number, y: number, type: EnemyType) {
- this.x = x;
- this.y = y;
- this.type = type;
- const s = STATS[type];
- this.w = s.w;
- this.h = s.w;
- this.hp = s.hp;
- this.maxHp = s.hp;
- this.speed = s.speed;
- this.damage = s.damage;
- }
-
- get box(): Box {
- return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
- }
-
- get alive(): boolean {
- return this.hp > 0;
- }
-}
-
-/** Pick a random enemy type, weighted */
-export function randomEnemyType(bossRoom: boolean): EnemyType {
- if (bossRoom) return 'boss';
- return Math.random() < 0.3 ? 'fast' : 'normal';
-}
diff --git a/src/entities/Player.ts b/src/entities/Player.ts
deleted file mode 100644
index 540e233..0000000
--- a/src/entities/Player.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { MODE_RANGED, MODE_MELEE } from '../constants';
-import type { CombatMode, Box, Dir } from '../types';
-
-export class Player {
- x = 0;
- y = 0;
- w = 26;
- h = 26;
- speed = 3.2;
- hp = 6;
- maxHp = 6;
- mode: CombatMode = MODE_RANGED;
- facing: Dir = 'up';
- moveDir: Dir = 'up';
- atkCD = 0;
- invTimer = 0;
- transCD = 0;
-
- get box(): Box {
- return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
- }
-}
diff --git a/src/entities/Tear.ts b/src/entities/Tear.ts
deleted file mode 100644
index ace9e45..0000000
--- a/src/entities/Tear.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export class Tear {
- x: number;
- y: number;
- dx: number;
- dy: number;
- r = 5;
- speed = 7;
- damage = 1;
- life = 80;
-
- constructor(x: number, y: number, dx: number, dy: number) {
- this.x = x;
- this.y = y;
- this.dx = dx;
- this.dy = dy;
- }
-
- get alive(): boolean {
- return this.life > 0;
- }
-}
diff --git a/src/game/Game.ts b/src/game/Game.ts
deleted file mode 100644
index 285781c..0000000
--- a/src/game/Game.ts
+++ /dev/null
@@ -1,334 +0,0 @@
-import { CW, CH, OX, OY, TILE, COLS, ROWS, DIR, DOOR } from '../constants';
-import { T_WALL } from '../room/tiles';
-import { MODE_RANGED, MODE_MELEE } from '../constants';
-import type { Dir } from '../types';
-import { KEYS } from '../input';
-import { overlap } from '../math';
-import { RoomMap } from '../room/RoomMap';
-import { Room } from '../room/Room';
-import { Player } from '../entities/Player';
-import { Enemy } from '../entities/Enemy';
-import { Tear } from '../entities/Tear';
-import { MeleeSwing } from '../entities/MeleeSwing';
-import { collidesWall } from './collision';
-import { checkTransition } from './transitions';
-import { drawRoom } from '../render/roomRenderer';
-import { drawEntities } from '../render/entityRenderer';
-import { drawHUD } from '../render/hudRenderer';
-import { drawMinimap } from '../render/minimapRenderer';
-
-export class Game {
- // Canvas
- canvas: HTMLCanvasElement;
- ctx: CanvasRenderingContext2D;
-
- // World
- roomMap = new RoomMap();
- player = new Player();
- cc = 0;
- cr = 0;
- meleeSwing: MeleeSwing | null = null;
-
- // State
- gameOver = false;
- won = false;
-
- constructor(canvas: HTMLCanvasElement) {
- this.canvas = canvas;
- this.ctx = canvas.getContext('2d')!;
- this.enterRoom('up');
- this.loop();
- }
-
- get curRoom(): Room {
- return this.roomMap.get(this.cc, this.cr)!;
- }
-
- toggleMode(): void {
- this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
- }
-
- restart(): void {
- this.gameOver = false;
- this.won = false;
- this.roomMap = new RoomMap();
- this.player = new Player();
- this.cc = 0;
- this.cr = 0;
- this.meleeSwing = null;
- this.enterRoom('up');
- }
-
- /** Place the player inside the current room after entering via a door */
- enterRoom(fromDir: Dir): void {
- const room = this.curRoom;
- room.visited = true;
-
- const d = DOOR[fromDir];
- const [ddc, ddr] = DIR[fromDir];
-
- this.player.x = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
- this.player.y = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
- this.player.facing = fromDir;
- this.player.invTimer = 20;
- this.player.transCD = 15;
-
- this.meleeSwing = null;
- room.buildTiles();
- room.enemies = [];
- room.tears = [];
-
- if (!room.cleared && room.type !== 'spawn') {
- this.spawnEnemies(room, fromDir);
- } else {
- room.cleared = true;
- room.buildTiles();
- }
- }
-
- // -------- SPAWNING --------
-
- private spawnEnemies(room: Room, entryDir: Dir): void {
- const count = room.type === 'boss' ? 1 : room.type === 'treasure' ? 0 : 2 + Math.floor(Math.random() * 3);
-
- for (let i = 0; i < count; i++) {
- let tries = 0;
- let x: number, y: number, ok: boolean;
- const type = room.type === 'boss' ? 'boss' as const
- : Math.random() < 0.3 ? 'fast' as const : 'normal' as const;
-
- do {
- x = OX + 2 * TILE + Math.random() * (COLS - 4) * TILE;
- y = OY + 2 * TILE + Math.random() * (ROWS - 4) * TILE;
- ok = true;
-
- const ed = DOOR[entryDir];
- const dx = OX + ed.cx * TILE + TILE / 2;
- const dy = OY + ed.cy * TILE + TILE / 2;
- if (Math.hypot(x - dx, y - dy) < 180) ok = false;
-
- for (const e of room.enemies) {
- if (Math.hypot(x - e.x, y - e.y) < 60) { ok = false; break; }
- }
- if (Math.hypot(x - this.player.x, y - this.player.y) < 150) ok = false;
-
- tries++;
- } while (!ok && tries < 100);
-
- room.enemies.push(new Enemy(x, y, type));
- }
- }
-
- // -------- GAME LOOP --------
-
- private loop(): void {
- if (!this.gameOver && !this.won) this.tick();
- this.render();
- requestAnimationFrame(() => this.loop());
- }
-
- private tick(): void {
- const room = this.curRoom;
- const p = this.player;
-
- if (p.invTimer > 0) p.invTimer--;
- if (p.atkCD > 0) p.atkCD--;
- if (p.transCD > 0) p.transCD--;
-
- this.processMovement(p);
- this.processAttack(room, p);
- this.updateMelee(room);
- this.updateTears(room);
-
- const aliveCount = this.updateEnemies(room, p);
-
- if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
- room.cleared = true;
- room.buildTiles();
- }
-
- checkTransition(this);
-
- if (!this.gameOver) {
- const bossRoom = [...this.roomMap.rooms.values()].find(r => r.type === 'boss');
- if (bossRoom?.cleared) this.won = true;
- }
- }
-
- private processMovement(p: Player): void {
- let mx = 0, my = 0;
- if (KEYS['w'] || KEYS['W']) my = -1;
- if (KEYS['s'] || KEYS['S']) my = 1;
- if (KEYS['a'] || KEYS['A']) mx = -1;
- if (KEYS['d'] || KEYS['D']) mx = 1;
-
- if (mx !== 0 || my !== 0) {
- const len = Math.hypot(mx, my);
- mx /= len;
- my /= len;
-
- if (my < 0) p.moveDir = 'up';
- else if (my > 0) p.moveDir = 'down';
- if (mx < 0) p.moveDir = 'left';
- else if (mx > 0) p.moveDir = 'right';
-
- const dx = mx * p.speed;
- const dy = my * p.speed;
- p.x += dx;
- if (collidesWall(p.box, this.curRoom, OX, OY)) p.x -= dx;
- p.y += dy;
- if (collidesWall(p.box, this.curRoom, OX, OY)) p.y -= dy;
- }
- }
-
- private processAttack(room: Room, p: Player): void {
- let ax = 0, ay = 0;
- if (KEYS['ArrowUp']) { ax = 0; ay = -1; }
- else if (KEYS['ArrowDown']) { ax = 0; ay = 1; }
- else if (KEYS['ArrowLeft']) { ax = -1; ay = 0; }
- else if (KEYS['ArrowRight']) { ax = 1; ay = 0; }
- else if (KEYS[' '] || KEYS['Space']) {
- [ax, ay] = DIR[p.moveDir];
- }
-
- if ((ax !== 0 || ay !== 0) && p.atkCD <= 0) {
- const len = Math.hypot(ax, ay);
- ax /= len;
- ay /= len;
-
- const dn: Dir = ay < 0 ? 'up' : ay > 0 ? 'down' : ax < 0 ? 'left' : 'right';
- p.facing = dn;
- p.atkCD = p.mode === MODE_RANGED ? 10 : 22;
-
- if (p.mode === MODE_RANGED) {
- room.tears.push(new Tear(p.x, p.y, ax, ay));
- } else {
- this.meleeSwing = new MeleeSwing(p.x, p.y, dn);
- }
- }
- }
-
- private updateMelee(room: Room): void {
- if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null;
- if (!this.meleeSwing) return;
-
- this.meleeSwing.life--;
- for (const e of room.enemies) {
- if (!e.alive || e.hitTimer > 0) continue;
- if (overlap(e.box, this.meleeSwing.box)) {
- e.hp -= this.meleeSwing.damage;
- e.hitTimer = 10;
- const [dx, dy] = DIR[this.meleeSwing.dir];
- e.knx = dx * this.meleeSwing.kb;
- e.kny = dy * this.meleeSwing.kb;
- }
- }
- }
-
- private updateTears(room: Room): void {
- for (const t of room.tears) {
- if (!t.alive) continue;
- t.x += t.dx * t.speed;
- t.y += t.dy * t.speed;
- t.life--;
-
- const col = Math.floor((t.x - OX) / TILE);
- const row = Math.floor((t.y - OY) / TILE);
- if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
- t.life = 0;
- continue;
- }
- if (room.tiles[row][col] === T_WALL) {
- t.life = 0;
- continue;
- }
-
- for (const e of room.enemies) {
- if (!e.alive) continue;
- if (Math.hypot(t.x - e.x, t.y - e.y) < e.w / 2 + t.r) {
- e.hp -= t.damage;
- e.hitTimer = 8;
- t.life = 0;
- break;
- }
- }
- }
- room.tears = room.tears.filter(t => t.alive);
- }
-
- private updateEnemies(room: Room, p: Player): number {
- let aliveCount = 0;
-
- for (const e of room.enemies) {
- if (!e.alive) continue;
- aliveCount++;
-
- if (e.hitTimer > 0) e.hitTimer--;
-
- if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
- e.x += e.knx * 3;
- e.y += e.kny * 3;
- e.knx *= 0.85;
- e.kny *= 0.85;
- continue;
- }
- e.knx = 0;
- e.kny = 0;
-
- const dx = p.x - e.x;
- const dy = p.y - e.y;
- const d = Math.hypot(dx, dy);
- if (d > 0 && d < 500) {
- const s = e.speed;
- const mx = (dx / d) * s;
- const my = (dy / d) * s;
- e.x += mx;
- if (collidesWall(e.box, room, OX, OY)) e.x -= mx;
- e.y += my;
- if (collidesWall(e.box, room, OX, OY)) e.y -= my;
- }
-
- if (e.atkTimer > 0) e.atkTimer--;
- if (Math.hypot(e.x - p.x, e.y - p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
- p.hp -= e.damage;
- p.invTimer = 60;
- e.atkTimer = 30;
- if (p.hp <= 0) {
- this.gameOver = true;
- return aliveCount;
- }
- }
- }
-
- return aliveCount;
- }
-
- // -------- RENDERING --------
-
- private render(): void {
- const ctx = this.ctx;
- ctx.fillStyle = '#0a0a0f';
- ctx.fillRect(0, 0, CW, CH);
-
- drawRoom(ctx, this.curRoom);
- drawEntities(ctx, this.curRoom, this.player, this.meleeSwing);
- drawHUD(ctx, this.player, this.curRoom);
- drawMinimap(ctx, this.roomMap, this.cc, this.cr);
-
- if (this.gameOver) this.drawOverlay('#c33', 'GAME OVER');
- else if (this.won) this.drawOverlay('#3c3', 'VICTORY');
- }
-
- private drawOverlay(color: string, text: string): void {
- const ctx = this.ctx;
- ctx.fillStyle = 'rgba(0,0,0,0.8)';
- ctx.fillRect(0, 0, CW, CH);
- ctx.fillStyle = color;
- ctx.font = 'bold 56px monospace';
- ctx.textAlign = 'center';
- ctx.fillText(text, CW / 2, CH / 2 - 20);
- ctx.fillStyle = '#888';
- ctx.font = '18px monospace';
- ctx.fillText('[R] restart', CW / 2, CH / 2 + 40);
- }
-}
diff --git a/src/game/collision.ts b/src/game/collision.ts
deleted file mode 100644
index 153fdd3..0000000
--- a/src/game/collision.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ROWS, COLS, DOOR } from '../constants';
-import { T_WALL } from '../room/tiles';
-import type { Room } from '../room/Room';
-import type { Box } from '../types';
-
-/** Check if a given tile is blocked for movement */
-export function isBlocked(room: Room, col: number, row: number): boolean {
- // Allow passing through the room boundary at door openings
- if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false;
- if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false;
- if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false;
- if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false;
-
- if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true;
- return room.tiles[row][col] === T_WALL;
-}
-
-/** Test whether an entity's bounding box overlaps any wall tile */
-export function collidesWall(box: Box, room: Room, ox: number, oy: number): boolean {
- const l = Math.floor((box.x - ox) / TILE_SIZE);
- const r = Math.floor((box.x + box.w - ox) / TILE_SIZE);
- const t = Math.floor((box.y - oy) / TILE_SIZE);
- const b = Math.floor((box.y + box.h - oy) / TILE_SIZE);
-
- for (let row = t; row <= b; row++) {
- for (let col = l; col <= r; col++) {
- if (isBlocked(room, col, row)) return true;
- }
- }
- return false;
-}
-
-const TILE_SIZE = 44; // matches TILE in constants — duplicated to avoid circular dep
diff --git a/src/game/transitions.ts b/src/game/transitions.ts
deleted file mode 100644
index 63704e2..0000000
--- a/src/game/transitions.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { OX, OY, TILE, COLS, ROWS, DOOR } from '../constants';
-import type { Dir } from '../types';
-import type { Game } from './Game';
-import { KEYS } from '../input';
-
-/** Entry direction → movement keys that trigger a transition */
-const ENTRY_KEYS: Record = {
- up: ['w', 'W', 'ArrowUp'],
- down: ['s', 'S', 'ArrowDown'],
- left: ['a', 'A', 'ArrowLeft'],
- right: ['d', 'D', 'ArrowRight'],
-};
-
-function keyPressed(dir: Dir): boolean {
- for (const k of ENTRY_KEYS[dir]) {
- if (KEYS[k]) return true;
- }
- return false;
-}
-
-/**
- * Called every frame from Game.tick().
- * If the player stands on a cleared room's door tile and presses
- * the matching movement key, transition into the adjacent room.
- */
-export function checkTransition(game: Game): void {
- if (game.gameOver || game.won) return;
- if (game.player.transCD > 0) return;
-
- const p = game.player;
- const room = game.curRoom;
- if (!room.cleared) return;
-
- const col = Math.floor((p.x - OX) / TILE);
- const row = Math.floor((p.y - OY) / TILE);
-
- // Top door
- if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && keyPressed('up')) {
- if (game.roomMap.has(game.cc, game.cr - 1)) {
- game.cr--;
- game.enterRoom('down');
- return;
- }
- }
-
- // Bottom door
- if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && keyPressed('down')) {
- if (game.roomMap.has(game.cc, game.cr + 1)) {
- game.cr++;
- game.enterRoom('up');
- return;
- }
- }
-
- // Left door
- if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && keyPressed('left')) {
- if (game.roomMap.has(game.cc - 1, game.cr)) {
- game.cc--;
- game.enterRoom('right');
- return;
- }
- }
-
- // Right door
- if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && keyPressed('right')) {
- if (game.roomMap.has(game.cc + 1, game.cr)) {
- game.cc++;
- game.enterRoom('left');
- return;
- }
- }
-}
diff --git a/src/index.html b/src/index.html
deleted file mode 100644
index ddce2b6..0000000
--- a/src/index.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-Dungeon Crawl — Ranged / Melee
-
-
-
-
-
-
-
diff --git a/src/input.ts b/src/input.ts
deleted file mode 100644
index 8df1134..0000000
--- a/src/input.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// Global keyboard state (shared mutable map)
-export const KEYS: Record = {};
-
-export function setupInput(): void {
- window.addEventListener('keydown', (e: KeyboardEvent) => {
- KEYS[e.key] = true;
- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
- e.preventDefault();
- }
- });
- window.addEventListener('keyup', (e: KeyboardEvent) => {
- KEYS[e.key] = false;
- });
- window.addEventListener('blur', () => {
- // Reset all keys on window blur to avoid stuck keys
- for (const k in KEYS) delete KEYS[k];
- });
-}
diff --git a/src/input/InputState.ts b/src/input/InputState.ts
new file mode 100644
index 0000000..1cb5f8c
--- /dev/null
+++ b/src/input/InputState.ts
@@ -0,0 +1,42 @@
+import type { Dir } from '../core/types';
+
+/**
+ * Снимок намерений игрока за один опрос ввода — абстракция над «железом».
+ * Игровая логика (core/Game.ts) читает ТОЛЬКО это, ничего не зная про
+ * клавиатуру. Захочешь геймпад или сенсор — просто сделай ещё один
+ * контроллер, отдающий такой же InputState.
+ *
+ * Поля делятся на два вида:
+ * • удерживаемые (move*, aimDir, attackHeld) — читаются каждый шаг симуляции;
+ * • однократные «edge» (toggleWeapon, restart) — срабатывают один раз на нажатие.
+ */
+export interface InputState {
+ moveX: number; // -1 влево, +1 вправо, 0 нет
+ moveY: number; // -1 вверх, +1 вниз, 0 нет
+ aimDir: Dir | null; // прицеливание стрелками (приоритетнее attackHeld)
+ attackHeld: boolean; // атака «по ходу движения» (пробел)
+ toggleWeapon: boolean; // сменить оружие (однократно)
+ restart: boolean; // рестарт на экране конца игры (однократно)
+}
+
+/** Нейтральный снимок — ничего не нажато. */
+export function emptyInput(): InputState {
+ return {
+ moveX: 0,
+ moveY: 0,
+ aimDir: null,
+ attackHeld: false,
+ toggleWeapon: false,
+ restart: false,
+ };
+}
+
+/** Жмёт ли игрок в сторону dir (движением ИЛИ прицеливанием) — для переходов. */
+export function pressingDir(input: InputState, dir: Dir): boolean {
+ switch (dir) {
+ case 'up': return input.moveY < 0 || input.aimDir === 'up';
+ case 'down': return input.moveY > 0 || input.aimDir === 'down';
+ case 'left': return input.moveX < 0 || input.aimDir === 'left';
+ case 'right': return input.moveX > 0 || input.aimDir === 'right';
+ }
+}
diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts
new file mode 100644
index 0000000..c6d6d86
--- /dev/null
+++ b/src/input/KeyboardController.ts
@@ -0,0 +1,93 @@
+import type { InputState } from './InputState';
+import type { Dir } from '../core/types';
+
+/**
+ * Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
+ * атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
+ *
+ * Контроллер держит набор зажатых клавиш и «защёлкивает» однократные
+ * действия (смена оружия/рестарт). Раз в кадр вызывается poll(), который
+ * собирает InputState и сбрасывает однократные флаги.
+ */
+export class KeyboardController {
+ private held = new Set();
+ private toggleWeaponEdge = false;
+ private restartEdge = false;
+ private attached = false;
+
+ private onKeyDown = (e: KeyboardEvent): void => {
+ const k = e.key;
+ // Однократные действия ловим по факту нажатия (не по удержанию).
+ if (!this.held.has(k)) {
+ if (k === 'Tab' || k === 'q' || k === 'Q') this.toggleWeaponEdge = true;
+ if (k === 'r' || k === 'R') this.restartEdge = true;
+ }
+ this.held.add(k);
+ if (PREVENT.has(k)) e.preventDefault();
+ };
+
+ private onKeyUp = (e: KeyboardEvent): void => {
+ this.held.delete(e.key);
+ };
+
+ private onBlur = (): void => {
+ // Сбрасываем всё, чтобы клавиши не «залипали» при потере фокуса.
+ this.held.clear();
+ };
+
+ /**
+ * Сбросить весь ввод: зажатые клавиши и однократные действия. Звать на
+ * границе забега (старт новой игры) — иначе клавиша, зажатая в прошлом
+ * забеге, или залатченная смена оружия «перетекут» в новый.
+ */
+ reset(): void {
+ this.held.clear();
+ this.toggleWeaponEdge = false;
+ this.restartEdge = false;
+ }
+
+ /** Подписаться на события окна. Вызывается один раз при старте. */
+ attach(target: Window = window): void {
+ if (this.attached) return;
+ target.addEventListener('keydown', this.onKeyDown);
+ target.addEventListener('keyup', this.onKeyUp);
+ target.addEventListener('blur', this.onBlur);
+ this.attached = true;
+ }
+
+ /** Собрать снимок ввода и сбросить однократные флаги. */
+ poll(): InputState {
+ const down = (k: string) => this.held.has(k);
+
+ let moveX = 0;
+ let moveY = 0;
+ if (down('w') || down('W')) moveY -= 1;
+ if (down('s') || down('S')) moveY += 1;
+ if (down('a') || down('A')) moveX -= 1;
+ if (down('d') || down('D')) moveX += 1;
+
+ let aimDir: Dir | null = null;
+ if (down('ArrowUp')) aimDir = 'up';
+ else if (down('ArrowDown')) aimDir = 'down';
+ else if (down('ArrowLeft')) aimDir = 'left';
+ else if (down('ArrowRight')) aimDir = 'right';
+
+ const snapshot: InputState = {
+ moveX,
+ moveY,
+ aimDir,
+ attackHeld: down(' ') || down('Spacebar'),
+ toggleWeapon: this.toggleWeaponEdge,
+ restart: this.restartEdge,
+ };
+
+ this.toggleWeaponEdge = false;
+ this.restartEdge = false;
+ return snapshot;
+ }
+}
+
+/** Клавиши, у которых гасим стандартное поведение браузера (скролл и т.п.). */
+const PREVENT = new Set([
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Spacebar', 'Tab',
+]);
diff --git a/src/main.ts b/src/main.ts
index 0531b23..a0e0876 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,32 +1,71 @@
-import { setupInput, KEYS } from './input';
-import { Game } from './game/Game';
+/**
+ * main.ts — точка входа и «склейка». Поток:
+ * стартовое меню → выбор уровня (правил) → создаём Game с этими правилами →
+ * запускаем цикл. Esc во время игры — назад в меню.
+ *
+ * Это единственное место, где встречаются логика, рендер и ввод — поэтому
+ * именно здесь проще всего подменить рендер/ввод или добавить экраны.
+ */
+import { Game } from './core/Game';
+import { PRESETS, type LevelRules } from './core/rules';
+import { KeyboardController } from './input/KeyboardController';
+import { ThreeRenderer } from './render/ThreeRenderer';
+import { HudOverlay } from './render/HudOverlay';
+import { GameLoop } from './engine/GameLoop';
+import { StartMenu } from './ui/StartMenu';
-// Global key bindings (mode toggle, restart) handled here
-setupInput();
-
-window.addEventListener('keydown', (e: KeyboardEvent) => {
- const game = (window as any).__game as Game | undefined;
- if (!game) return;
-
- // Toggle combat mode
- if ((e.key === 'Tab' || e.key === 'q' || e.key === 'Q') && !game.gameOver && !game.won) {
- e.preventDefault();
- game.toggleMode();
- }
-
- // Restart
- if (e.key === 'r' || e.key === 'R') {
- if (game.gameOver || game.won) game.restart();
- }
-});
-
-// Bootstrap
-window.addEventListener('load', () => {
- const canvas = document.getElementById('game') as HTMLCanvasElement;
- if (!canvas) {
- document.body.innerHTML = 'Error: canvas element not found
';
+function boot(): void {
+ const world = document.getElementById('game') as HTMLCanvasElement | null;
+ const hudCanvas = document.getElementById('hud') as HTMLCanvasElement | null;
+ const menuEl = document.getElementById('menu');
+ if (!world || !hudCanvas || !menuEl) {
+ document.body.innerHTML =
+ 'Ошибка: не найдены #game / #hud / #menu в разметке.
';
return;
}
- const game = new Game(canvas);
- (window as any).__game = game;
-});
+
+ // Рендер и ввод создаём один раз — они переиспользуются между забегами.
+ const controller = new KeyboardController();
+ const world3d = new ThreeRenderer(world);
+ const hud = new HudOverlay(hudCanvas);
+ controller.attach();
+
+ let loop: GameLoop | null = null;
+
+ const startGame = (rules: LevelRules): void => {
+ loop?.stop();
+ controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
+ const game = new Game(rules);
+ loop = new GameLoop(game, controller, (alpha) => {
+ world3d.render(game, alpha);
+ hud.render(game);
+ });
+ menu.hide();
+ loop.start();
+ // Debug-хэндл: в консоли браузера доступен `game`.
+ (window as Window & { game?: Game }).game = game;
+ };
+
+ const toMenu = (): void => {
+ loop?.stop();
+ loop = null;
+ menu.show(); // фон меню перекрывает «замёрзший» последний кадр
+ };
+
+ const menu = new StartMenu(menuEl, PRESETS, startGame);
+ menu.show();
+
+ // Esc во время игры — вернуться к выбору уровня.
+ window.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && loop) {
+ e.preventDefault();
+ toMenu();
+ }
+ });
+}
+
+if (document.readyState === 'loading') {
+ window.addEventListener('DOMContentLoaded', boot);
+} else {
+ boot();
+}
diff --git a/src/math.ts b/src/math.ts
deleted file mode 100644
index 36cd66e..0000000
--- a/src/math.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/** Fisher-Yates shuffle (mutates array in place) */
-export function shuffle(a: T[]): T[] {
- for (let i = a.length - 1; i > 0; i--) {
- const j = (Math.random() * i) | 0;
- [a[i], a[j]] = [a[j], a[i]];
- }
- return a;
-}
-
-/** Random float in [a, b) */
-export function rand(a: number, b: number): number {
- return Math.random() * (b - a) + a;
-}
-
-/** Random integer in [a, b] inclusive */
-export function ri(a: number, b: number): number {
- return Math.floor(rand(a, b + 1));
-}
-
-/** Euclidean distance */
-export function dist(x1: number, y1: number, x2: number, y2: number): number {
- return Math.hypot(x2 - x1, y2 - y1);
-}
-
-/** Axis-aligned bounding box overlap test */
-export function overlap(a: { x: number; y: number; w: number; h: number },
- b: { x: number; y: number; w: number; h: number }): boolean {
- return a.x < b.x + b.w && a.x + a.w > b.x &&
- a.y < b.y + b.h && a.y + a.h > b.y;
-}
diff --git a/src/render/HudOverlay.ts b/src/render/HudOverlay.ts
new file mode 100644
index 0000000..b95ae60
--- /dev/null
+++ b/src/render/HudOverlay.ts
@@ -0,0 +1,133 @@
+import { CW, CH, OY, RH, MODE_RANGED } from '../config';
+import type { Game } from '../core/Game';
+import type { Renderer } from './Renderer';
+
+/**
+ * HUD и миникарта на прозрачном 2D-канвасе ПОВЕРХ WebGL-холста.
+ * Текст и тонкие линии в Canvas2D остаются чёткими и их просто стилизовать —
+ * куда удобнее, чем тянуть шрифты в WebGL. Чисто отрисовка, без логики.
+ */
+export class HudOverlay implements Renderer {
+ private readonly ctx: CanvasRenderingContext2D;
+
+ constructor(canvas: HTMLCanvasElement) {
+ // Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
+ // а рисуем по-прежнему в логических координатах CW×CH.
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ canvas.width = CW * dpr;
+ canvas.height = CH * dpr;
+ this.ctx = canvas.getContext('2d')!;
+ this.ctx.scale(dpr, dpr);
+ }
+
+ render(game: Game): void {
+ const ctx = this.ctx;
+ ctx.clearRect(0, 0, CW, CH);
+
+ this.drawHud(game);
+ this.drawMinimap(game);
+
+ if (game.gameOver) this.drawOverlay('#c33', 'GAME OVER');
+ else if (game.won) this.drawOverlay('#3c3', 'VICTORY');
+ }
+
+ dispose(): void {
+ this.ctx.clearRect(0, 0, CW, CH);
+ }
+
+ private drawHud(game: Game): void {
+ const ctx = this.ctx;
+ const p = game.player;
+ const room = game.curRoom;
+
+ // Полоса здоровья.
+ const bx = 20, by = 20, bw = 140, bh = 14;
+ ctx.fillStyle = '#111'; ctx.fillRect(bx, by, bw, bh);
+ ctx.fillStyle = '#2a0a0a'; ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
+ const hpRatio = Math.max(0, p.hp / p.maxHp);
+ ctx.fillStyle = hpRatio > 0.5 ? '#993333' : hpRatio > 0.25 ? '#994422' : '#663322';
+ ctx.fillRect(bx + 2, by + 2, (bw - 4) * hpRatio, bh - 4);
+ ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(bx, by, bw, bh);
+ ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
+ ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3);
+
+ // Название текущего уровня (правил).
+ ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
+ ctx.fillText(`Уровень: ${game.rules.name}`, bx, by + bh + 14);
+
+ // Индикатор режима боя.
+ const my = CH - 46;
+ const ranged = p.mode === MODE_RANGED;
+ const mText = ranged ? 'RANGED' : 'MELEE';
+ const mCol = ranged ? '#4488cc' : '#cc6644';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
+ ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
+ ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
+ ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
+
+ // Счётчик врагов / подсказка зачистки.
+ ctx.textAlign = 'left';
+ const alive = room.enemies.filter((e) => e.alive).length;
+ if (alive > 0) {
+ ctx.fillStyle = '#aa4444'; ctx.font = '13px monospace';
+ ctx.fillText(`▶ ${alive}`, 20, CH - 18);
+ } else if (!room.cleared && room.type !== 'spawn') {
+ ctx.fillStyle = '#886633'; ctx.font = '13px monospace';
+ ctx.fillText('Зачисти комнату', 20, CH - 18);
+ }
+
+ // Подпись типа комнаты.
+ if (room.visited) {
+ const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС' }[room.type];
+ if (label) {
+ ctx.textAlign = 'right'; ctx.fillStyle = '#555'; ctx.font = '11px monospace';
+ ctx.fillText(label, CW - 20, OY + RH + 30);
+ }
+ }
+ }
+
+ private drawMinimap(game: Game): void {
+ const ctx = this.ctx;
+ const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap;
+
+ ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.fillRect(ox - 8, oy - 8, cs * 7 + 16, cs * 7 + 16);
+ ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(ox - 8, oy - 8, cs * 7 + 16, cs * 7 + 16);
+
+ for (let r = -3; r <= 3; r++) {
+ for (let c = -3; c <= 3; c++) {
+ const room = game.roomMap.get(game.cc + c, game.cr + r);
+ if (!room) continue;
+ const x = ox + (c + 3) * cs, y = oy + (r + 3) * cs;
+
+ let color = '#141414';
+ if (room.visited) {
+ color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', normal: '#555' }[room.type];
+ }
+ ctx.fillStyle = color; ctx.fillRect(x, y, cell, cell);
+
+ if (room.visited) {
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ if (room.doors.up) ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3);
+ if (room.doors.down) ctx.fillRect(x + cs / 2 - 2, y + cell - 1, 4, 3);
+ if (room.doors.left) ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4);
+ if (room.doors.right) ctx.fillRect(x + cell - 1, y + cs / 2 - 2, 3, 4);
+ }
+ if (c === 0 && r === 0) {
+ ctx.strokeStyle = '#ddd'; ctx.lineWidth = 2; ctx.strokeRect(x - 1.5, y - 1.5, cell + 3, cell + 3);
+ }
+ }
+ }
+ }
+
+ private drawOverlay(color: string, text: string): void {
+ const ctx = this.ctx;
+ ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(0, 0, CW, CH);
+ ctx.fillStyle = color; ctx.font = 'bold 56px monospace'; ctx.textAlign = 'center';
+ ctx.fillText(text, CW / 2, CH / 2 - 20);
+ ctx.fillStyle = '#888'; ctx.font = '18px monospace';
+ ctx.fillText('[R] заново', CW / 2, CH / 2 + 40);
+ ctx.fillStyle = '#666'; ctx.font = '14px monospace';
+ ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68);
+ }
+}
diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts
new file mode 100644
index 0000000..eaa42e2
--- /dev/null
+++ b/src/render/Renderer.ts
@@ -0,0 +1,21 @@
+import type { Game } from '../core/Game';
+
+/**
+ * Контракт любого рендера. Игровая логика про него ничего не знает —
+ * рендер только ЧИТАЕТ состояние Game и рисует его.
+ *
+ * Хочешь другой рендер (чистый Canvas2D, пиксель-арт, настоящий 3D) —
+ * реализуй этот интерфейс и подмени в main.ts. Ядро менять не нужно.
+ */
+export interface Renderer {
+ /**
+ * Нарисовать кадр.
+ * @param game текущее состояние игры (только чтение).
+ * @param alpha доля времени до следующего шага [0..1) для интерполяции
+ * позиций между prevX/prevY и x/y (плавность на >60 Гц).
+ */
+ render(game: Game, alpha: number): void;
+
+ /** Освободить ресурсы GPU/DOM. */
+ dispose(): void;
+}
diff --git a/src/render/ThreeRenderer.ts b/src/render/ThreeRenderer.ts
new file mode 100644
index 0000000..2cc5189
--- /dev/null
+++ b/src/render/ThreeRenderer.ts
@@ -0,0 +1,230 @@
+import * as THREE from 'three';
+import {
+ CW, CH, OX, OY, TILE, COLS, ROWS,
+ T_WALL, T_DOOR, MODE_RANGED, PROJECTILE,
+} from '../config';
+import { lerp } from '../core/util';
+import type { Game } from '../core/Game';
+import type { Room } from '../core/world/Room';
+import type { Enemy } from '../core/entities/Enemy';
+import type { Projectile } from '../core/entities/Projectile';
+import type { Renderer } from './Renderer';
+import { DEFAULT_THEME, type Theme } from './theme';
+
+/** Z-слои: больше значение — ближе к камере (рисуется поверх). */
+const Z = { floor: 0, wall: 1, door: 0.5, swing: 4, entity: 5, tear: 6 };
+
+/**
+ * Все материалы — DoubleSide. Наша ортокамера переворачивает ось Y
+ * (top=0 сверху), из-за чего инвертируется порядок вершин и при обычном
+ * отсечении задних граней плоскости становятся невидимыми. DoubleSide
+ * рисует грань с обеих сторон — для плоского 2D это правильный выбор.
+ */
+function flatMat(params: THREE.MeshBasicMaterialParameters = {}): THREE.MeshBasicMaterial {
+ return new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, ...params });
+}
+
+/**
+ * Рендер мира на three.js с ОРТОГРАФИЧЕСКОЙ камерой: 3D-движок, но картинка
+ * плоская 2D-сверху (как у настоящего Isaac). Мировые координаты совпадают
+ * с пиксельными координатами логики (x вправо, y вниз), поэтому вся
+ * математика ядра остаётся валидной без пересчётов.
+ *
+ * Управление ресурсами:
+ * • геометрии-«единицы» (unitPlane/unitCircle) общие и переиспользуются
+ * масштабированием — не плодим геометрии;
+ * • тайлы комнаты пересобираются ТОЛЬКО при смене комнаты;
+ * • меши сущностей создаются/удаляются по мере появления/исчезновения
+ * (mark-and-sweep), их персональные материалы корректно dispose-ятся.
+ */
+export class ThreeRenderer implements Renderer {
+ private readonly renderer: THREE.WebGLRenderer;
+ private readonly scene = new THREE.Scene();
+ private readonly camera: THREE.OrthographicCamera;
+
+ // Общие геометрии-единицы (масштабируем под размер сущности).
+ private readonly unitPlane = new THREE.PlaneGeometry(1, 1);
+ private readonly unitCircle = new THREE.CircleGeometry(0.5, 24);
+
+ // Общие материалы тайлов (без пер-тайлового мигания — можно шарить).
+ private readonly tileMats: Record;
+
+ // Группа статичных тайлов текущей комнаты.
+ private roomGroup = new THREE.Group();
+ private renderedRoom: Room | null = null;
+
+ // Динамические меши с персональными материалами.
+ private readonly playerMesh: THREE.Mesh;
+ private readonly enemyMeshes = new Map();
+ private readonly tearMeshes = new Map();
+ private readonly swingMesh: THREE.Mesh;
+
+ private readonly theme: Theme;
+
+ constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
+ this.theme = theme;
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ this.renderer.setSize(CW, CH, false);
+ this.renderer.setClearColor(this.theme.bg, 1);
+
+ // Ортокамера: world (0,0) — верхний левый угол, (CW,CH) — нижний правый.
+ this.camera = new THREE.OrthographicCamera(0, CW, 0, CH, 0.1, 1000);
+ this.camera.position.z = 100;
+
+ this.tileMats = {
+ floorA: flatMat({ color: this.theme.floorA }),
+ floorB: flatMat({ color: this.theme.floorB }),
+ wall: flatMat({ color: this.theme.wall }),
+ door: flatMat({ color: this.theme.door }),
+ };
+
+ this.scene.add(this.roomGroup);
+
+ this.playerMesh = new THREE.Mesh(this.unitPlane, flatMat({ color: this.theme.playerRanged }));
+ this.playerMesh.position.z = Z.entity;
+ this.scene.add(this.playerMesh);
+
+ this.swingMesh = new THREE.Mesh(
+ this.unitPlane,
+ flatMat({ color: this.theme.swing, transparent: true, opacity: 0.45 }),
+ );
+ this.swingMesh.position.z = Z.swing;
+ this.swingMesh.visible = false;
+ this.scene.add(this.swingMesh);
+ }
+
+ render(game: Game, alpha: number): void {
+ const room = game.curRoom;
+ if (room !== this.renderedRoom) this.buildRoom(room);
+
+ this.syncPlayer(game, alpha);
+ this.syncEnemies(room, alpha);
+ this.syncTears(room, alpha);
+ this.syncSwing(game);
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ // ── Статичная геометрия комнаты ───────────────────────────
+
+ private buildRoom(room: Room): void {
+ this.clearGroup(this.roomGroup);
+ this.renderedRoom = room;
+
+ for (let r = 0; r < ROWS; r++) {
+ for (let c = 0; c < COLS; c++) {
+ const t = room.tiles[r][c];
+ let mat: THREE.MeshBasicMaterial;
+ let z = Z.floor;
+ if (t === T_WALL) { mat = this.tileMats.wall; z = Z.wall; }
+ else if (t === T_DOOR) { mat = this.tileMats.door; z = Z.door; }
+ else { mat = (r + c) % 2 === 0 ? this.tileMats.floorA : this.tileMats.floorB; }
+
+ const mesh = new THREE.Mesh(this.unitPlane, mat);
+ mesh.scale.set(TILE, TILE, 1);
+ mesh.position.set(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2, z);
+ this.roomGroup.add(mesh);
+ }
+ }
+ }
+
+ // ── Динамические сущности ─────────────────────────────────
+
+ private syncPlayer(game: Game, alpha: number): void {
+ const p = game.player;
+ const x = lerp(p.prevX, p.x, alpha);
+ const y = lerp(p.prevY, p.y, alpha);
+ this.playerMesh.position.set(x, y, Z.entity);
+ this.playerMesh.scale.set(p.w, p.h, 1);
+
+ const base = p.mode === MODE_RANGED ? this.theme.playerRanged : this.theme.playerMelee;
+ const flashing = p.invTimer > 0 && p.invTimer % 6 < 3;
+ (this.playerMesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
+ }
+
+ private syncEnemies(room: Room, alpha: number): void {
+ const live = new Set();
+ for (const e of room.enemies) {
+ if (!e.alive) continue;
+ live.add(e);
+
+ let mesh = this.enemyMeshes.get(e);
+ if (!mesh) {
+ const geo = e.type === 'normal' ? this.unitPlane : this.unitCircle;
+ mesh = new THREE.Mesh(geo, flatMat());
+ mesh.position.z = Z.entity;
+ this.scene.add(mesh);
+ this.enemyMeshes.set(e, mesh);
+ }
+
+ const x = lerp(e.prevX, e.x, alpha);
+ const y = lerp(e.prevY, e.y, alpha);
+ mesh.position.set(x, y, Z.entity);
+ mesh.scale.set(e.w, e.h, 1);
+
+ const base = e.type === 'fast' ? this.theme.enemyFast : e.type === 'boss' ? this.theme.enemyBoss : this.theme.enemyNormal;
+ const flashing = e.hitTimer > 0 && e.hitTimer % 4 < 2;
+ (mesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
+ }
+ this.sweep(this.enemyMeshes, live);
+ }
+
+ private syncTears(room: Room, alpha: number): void {
+ const live = new Set();
+ for (const t of room.tears) {
+ if (!t.alive) continue;
+ live.add(t);
+
+ let mesh = this.tearMeshes.get(t);
+ if (!mesh) {
+ mesh = new THREE.Mesh(this.unitCircle, flatMat({ color: this.theme.tear }));
+ mesh.position.z = Z.tear;
+ mesh.scale.set(PROJECTILE.radius * 2, PROJECTILE.radius * 2, 1);
+ this.scene.add(mesh);
+ this.tearMeshes.set(t, mesh);
+ }
+ mesh.position.set(lerp(t.prevX, t.x, alpha), lerp(t.prevY, t.y, alpha), Z.tear);
+ }
+ this.sweep(this.tearMeshes, live);
+ }
+
+ private syncSwing(game: Game): void {
+ const s = game.meleeSwing;
+ if (!s || !s.alive) { this.swingMesh.visible = false; return; }
+ this.swingMesh.visible = true;
+ this.swingMesh.position.set(s.box.x + s.box.w / 2, s.box.y + s.box.h / 2, Z.swing);
+ this.swingMesh.scale.set(s.box.w, s.box.h, 1);
+ (this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.45 * (s.life / 10);
+ }
+
+ // ── Утилиты управления ресурсами ──────────────────────────
+
+ /** Удаляет меши, чьих сущностей больше нет, освобождая их материалы. */
+ private sweep(map: Map, live: Set): void {
+ for (const [key, mesh] of map) {
+ if (live.has(key)) continue;
+ this.scene.remove(mesh);
+ (mesh.material as THREE.Material).dispose();
+ map.delete(key);
+ }
+ }
+
+ private clearGroup(group: THREE.Group): void {
+ // Материалы и геометрия тайлов общие (живут весь срок рендера),
+ // поэтому здесь только убираем меши из сцены — без dispose.
+ group.clear();
+ }
+
+ dispose(): void {
+ this.clearGroup(this.roomGroup);
+ this.sweep(this.enemyMeshes, new Set());
+ this.sweep(this.tearMeshes, new Set());
+ (this.playerMesh.material as THREE.Material).dispose();
+ (this.swingMesh.material as THREE.Material).dispose();
+ this.unitPlane.dispose();
+ this.unitCircle.dispose();
+ for (const m of Object.values(this.tileMats)) m.dispose();
+ this.renderer.dispose();
+ }
+}
diff --git a/src/render/entityRenderer.ts b/src/render/entityRenderer.ts
deleted file mode 100644
index 5803ce0..0000000
--- a/src/render/entityRenderer.ts
+++ /dev/null
@@ -1,270 +0,0 @@
-import { DIR, OX, OY, TILE } from '../constants';
-import type { Room } from '../room/Room';
-import type { Player } from '../entities/Player';
-import type { MeleeSwing } from '../entities/MeleeSwing';
-
-/** Draw enemies, player, tears, and the melee swing arc */
-export function drawEntities(
- ctx: CanvasRenderingContext2D,
- room: Room,
- player: Player,
- meleeSwing: MeleeSwing | null,
-): void {
- // --- ENEMIES ---
- for (const e of room.enemies) {
- if (!e.alive) continue;
- const flash = e.hitTimer > 0 && e.hitTimer % 4 < 2;
-
- ctx.save();
-
- // Shadow
- ctx.fillStyle = 'rgba(0,0,0,0.3)';
- ctx.beginPath();
- ctx.ellipse(e.x + 2, e.y + e.h / 4, e.w / 3, 4, 0, 0, Math.PI * 2);
- ctx.fill();
-
- if (e.type === 'boss') {
- drawBoss(ctx, e, flash);
- } else if (e.type === 'fast') {
- drawFastEnemy(ctx, e, flash);
- } else {
- drawNormalEnemy(ctx, e, flash);
- }
-
- ctx.restore();
- }
-
- // --- PLAYER ---
- drawPlayer(ctx, player);
-
- // --- TEARS ---
- for (const t of room.tears) {
- if (!t.alive) continue;
- ctx.save();
- ctx.fillStyle = '#6699cc';
- ctx.beginPath();
- ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#99bbee';
- ctx.beginPath();
- ctx.arc(t.x - 1.5, t.y - 1.5, t.r - 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
- }
-
- // --- MELEE SWING ---
- if (meleeSwing && meleeSwing.alive) {
- drawMeleeSwing(ctx, meleeSwing);
- }
-}
-
-function drawBoss(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
- ctx.fillStyle = flash ? '#ddd' : '#5a0a0a';
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#4a0808';
- ctx.beginPath();
- ctx.arc(e.x - 3, e.y - 3, e.w / 2 - 4, 0, Math.PI * 2);
- ctx.fill();
-
- ctx.fillStyle = flash ? '#000' : '#ff3333';
- ctx.beginPath();
- ctx.arc(e.x - 8, e.y - 8, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 8, e.y - 8, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#000';
- ctx.beginPath();
- ctx.arc(e.x - 8, e.y - 8, 2.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 8, e.y - 8, 2.5, 0, Math.PI * 2);
- ctx.fill();
-
- ctx.fillStyle = flash ? '#bbb' : '#3a0505';
- ctx.beginPath();
- ctx.moveTo(e.x - 16, e.y - e.w / 2 + 4);
- ctx.lineTo(e.x - 8, e.y - e.w / 2 - 16);
- ctx.lineTo(e.x, e.y - e.w / 2 + 4);
- ctx.fill();
- ctx.beginPath();
- ctx.moveTo(e.x - 4, e.y - e.w / 2 + 4);
- ctx.lineTo(e.x + 4, e.y - e.w / 2 - 16);
- ctx.lineTo(e.x + 12, e.y - e.w / 2 + 4);
- ctx.fill();
-
- if (e.hp < e.maxHp) {
- ctx.fillStyle = '#222';
- ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44, 4);
- ctx.fillStyle = '#c33';
- ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44 * (e.hp / e.maxHp), 4);
- }
-}
-
-function drawFastEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
- ctx.fillStyle = flash ? '#ddd' : '#992222';
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#771111';
- ctx.beginPath();
- ctx.arc(e.x - 1, e.y - 1, e.w / 2 - 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#ff4444';
- ctx.beginPath();
- ctx.arc(e.x - 5, e.y - 4, 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 5, e.y - 4, 3, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#000';
- ctx.beginPath();
- ctx.arc(e.x - 5, e.y - 5, 1.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.beginPath();
- ctx.arc(e.x + 5, e.y - 5, 1.5, 0, Math.PI * 2);
- ctx.fill();
-}
-
-function drawNormalEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
- ctx.fillStyle = flash ? '#ccc' : '#5a4a2e';
- ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
- ctx.fillStyle = '#4a3a1e';
- ctx.fillRect(e.x - e.w / 2 + 3, e.y - e.h / 2 + 3, e.w - 6, e.h - 6);
- ctx.fillStyle = '#332816';
- ctx.fillRect(e.x - e.w / 2 + 6, e.y - e.h / 2 + 6, e.w - 12, e.h - 12);
- ctx.fillStyle = '#ffcc66';
- ctx.fillRect(e.x - 7, e.y - 5, 5, 5);
- ctx.fillRect(e.x + 2, e.y - 5, 5, 5);
- ctx.fillStyle = '#000';
- ctx.fillRect(e.x - 6, e.y - 4, 3, 3);
- ctx.fillRect(e.x + 3, e.y - 4, 3, 3);
-}
-
-function drawPlayer(ctx: CanvasRenderingContext2D, p: Player): void {
- ctx.save();
-
- const flash = p.invTimer > 0 && p.invTimer % 6 < 3;
- const bodyColor = p.mode === 0 ? '#2a6a9a' : '#9a3a2a';
-
- ctx.fillStyle = flash ? '#ddd' : bodyColor;
- ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h);
- ctx.fillStyle = flash ? '#ccc' : 'rgba(0,0,0,0.3)';
- ctx.fillRect(p.x - p.w / 2 + 3, p.y - p.h / 2 + 3, p.w - 6, p.h - 6);
-
- // Weapon
- const [fx, fy] = DIR[p.facing];
- const wx = p.x + fx * (p.w / 2 + 4);
- const wy = p.y + fy * (p.h / 2 + 4);
-
- if (p.mode === 0) {
- drawPistol(ctx, p, wx, wy, fx, fy, flash);
- } else {
- drawKnife(ctx, wx, wy, fx, fy, flash);
- }
-
- // Eyes
- ctx.fillStyle = '#fff';
- const ex = p.x + fx * 5;
- const ey = p.y + fy * 5;
- ctx.fillRect(ex - 5, ey - 4, 4, 5);
- ctx.fillRect(ex + 1, ey - 4, 4, 5);
- ctx.fillStyle = '#111';
- ctx.fillRect(ex - 4 + fx, ey - 3 + fy, 2, 3);
- ctx.fillRect(ex + 2 + fx, ey - 3 + fy, 2, 3);
-
- ctx.restore();
-}
-
-function drawPistol(
- ctx: CanvasRenderingContext2D,
- p: Player,
- wx: number, wy: number,
- fx: number, fy: number,
- flash: boolean,
-): void {
- ctx.strokeStyle = flash ? '#999' : '#555';
- ctx.lineWidth = 3;
- ctx.lineCap = 'round';
-
- // Barrel
- ctx.beginPath();
- ctx.moveTo(wx, wy);
- ctx.lineTo(wx + fx * 14 + fy * 2, wy + fy * 14 + fx * 2);
- ctx.stroke();
-
- // Body
- ctx.fillStyle = flash ? '#aaa' : '#444';
- ctx.save();
- const angle = fy !== 0 ? (Math.PI / 2) * (fy < 0 ? -1 : 1) : fx < 0 ? Math.PI : 0;
- ctx.translate(p.x + fx * 8, p.y + fy * 8);
- ctx.rotate(angle);
- ctx.fillRect(-7, -4, 14, 8);
- ctx.restore();
-
- // Muzzle flash
- if (p.atkCD > 8 && p.mode === 0) {
- ctx.fillStyle = 'rgba(255,200,50,0.6)';
- ctx.beginPath();
- ctx.arc(wx + fx * 16, wy + fy * 16, 6, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = 'rgba(255,255,200,0.4)';
- ctx.beginPath();
- ctx.arc(wx + fx * 18, wy + fy * 18, 8, 0, Math.PI * 2);
- ctx.fill();
- }
-}
-
-function drawKnife(
- ctx: CanvasRenderingContext2D,
- wx: number, wy: number,
- fx: number, fy: number,
- flash: boolean,
-): void {
- ctx.strokeStyle = flash ? '#bbb' : '#ccc';
- ctx.lineWidth = 2;
-
- // Blade triangle
- const kx = wx + fx * 6;
- const ky = wy + fy * 6;
- ctx.beginPath();
- ctx.moveTo(kx, ky);
- ctx.lineTo(kx + fx * 16 - fy * 6, ky + fy * 16 + fx * 6);
- ctx.lineTo(kx + fx * 16 + fy * 6, ky + fy * 16 - fx * 6);
- ctx.closePath();
- ctx.fillStyle = flash ? '#ddd' : '#d4d4d4';
- ctx.fill();
- ctx.stroke();
-
- // Handle
- ctx.fillStyle = flash ? '#a99' : '#5a3a1a';
- ctx.fillRect(kx - fx * 3 - fy * 3, ky - fy * 3 - fx * 3, 8, 8);
-
- // Guard
- ctx.fillStyle = flash ? '#bbb' : '#888';
- ctx.fillRect(kx - fx * 2 - fy * 5, ky - fy * 2 - fx * 5, 5, 12);
-}
-
-function drawMeleeSwing(ctx: CanvasRenderingContext2D, s: MeleeSwing): void {
- const alpha = s.life / 10;
- ctx.save();
- ctx.globalAlpha = alpha * 0.35;
- ctx.fillStyle = '#cc8844';
- ctx.fillRect(s.box.x, s.box.y, s.box.w, s.box.h);
- ctx.globalAlpha = alpha;
- ctx.strokeStyle = '#ddbb88';
- ctx.lineWidth = 2;
- ctx.strokeRect(s.box.x, s.box.y, s.box.w, s.box.h);
- ctx.globalAlpha = alpha * 0.8;
- ctx.strokeStyle = '#ffcc88';
- ctx.lineWidth = 3;
-
- const [dx, dy] = DIR[s.dir];
- ctx.beginPath();
- ctx.moveTo(s.box.x + s.box.w / 2 - dx * 18, s.box.y + s.box.h / 2 - dy * 18);
- ctx.lineTo(s.box.x + s.box.w / 2 + dx * 18, s.box.y + s.box.h / 2 + dy * 18);
- ctx.stroke();
- ctx.restore();
-}
diff --git a/src/render/hudRenderer.ts b/src/render/hudRenderer.ts
deleted file mode 100644
index 0baf3cf..0000000
--- a/src/render/hudRenderer.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { CW, CH, OY, RH } from '../constants';
-import { MODE_RANGED, MODE_MELEE } from '../constants';
-import type { Player } from '../entities/Player';
-import type { Room } from '../room/Room';
-
-/** Draw the HUD: HP bar, mode indicator, enemy count, room label */
-export function drawHUD(ctx: CanvasRenderingContext2D, player: Player, room: Room): void {
- drawHPBar(ctx, player);
- drawModeIndicator(ctx, player);
- drawEnemyCount(ctx, room);
- drawRoomLabel(ctx, room);
-}
-
-function drawHPBar(ctx: CanvasRenderingContext2D, p: Player): void {
- const bx = 20, by = 20, bw = 140, bh = 14;
- ctx.fillStyle = '#111';
- ctx.fillRect(bx, by, bw, bh);
- ctx.fillStyle = '#2a0a0a';
- ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
-
- const ratio = Math.max(0, p.hp / p.maxHp);
- const color = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322';
- ctx.fillStyle = color;
- ctx.fillRect(bx + 2, by + 2, (bw - 4) * ratio, bh - 4);
-
- ctx.strokeStyle = '#333';
- ctx.lineWidth = 1;
- ctx.strokeRect(bx, by, bw, bh);
- ctx.fillStyle = '#bbb';
- ctx.font = '10px monospace';
- ctx.textAlign = 'center';
- ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3);
-}
-
-function drawModeIndicator(ctx: CanvasRenderingContext2D, p: Player): void {
- const my = CH - 46;
- ctx.textAlign = 'center';
-
- const label = p.mode === MODE_RANGED ? 'RANGED' : 'MELEE';
- const color = p.mode === MODE_RANGED ? '#4488cc' : '#cc6644';
-
- ctx.fillStyle = '#0d0d0d';
- ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
- ctx.strokeStyle = color;
- ctx.lineWidth = 2;
- ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
-
- ctx.fillStyle = color;
- ctx.font = 'bold 17px monospace';
- ctx.fillText(`[ ${label} ]`, CW / 2, my + 8);
-
- ctx.fillStyle = '#555';
- ctx.font = '11px monospace';
- ctx.fillText('[Tab/Q] switch', CW / 2, my - 26);
-
- // Small weapon icon
- if (p.mode === MODE_RANGED) {
- ctx.strokeStyle = '#88bbdd';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.moveTo(CW / 2 - 82, my - 4);
- ctx.lineTo(CW / 2 - 72, my - 4);
- ctx.stroke();
- ctx.fillStyle = '#88bbdd';
- ctx.fillRect(CW / 2 - 82, my - 8, 10, 8);
- } else {
- ctx.fillStyle = '#ddbb88';
- ctx.beginPath();
- ctx.moveTo(CW / 2 - 82, my - 10);
- ctx.lineTo(CW / 2 - 74, my - 2);
- ctx.lineTo(CW / 2 - 82, my + 4);
- ctx.fill();
- }
-}
-
-function drawEnemyCount(ctx: CanvasRenderingContext2D, room: Room): void {
- const alive = room.enemies.filter(e => e.alive).length;
- ctx.textAlign = 'left';
- if (alive > 0) {
- ctx.fillStyle = '#aa4444';
- ctx.font = '13px monospace';
- ctx.fillText(`\u25B6 ${alive}`, 20, CH - 18);
- } else if (!room.cleared && room.type !== 'spawn') {
- ctx.fillStyle = '#886633';
- ctx.font = '13px monospace';
- ctx.fillText('Clear the room', 20, CH - 18);
- }
-}
-
-function drawRoomLabel(ctx: CanvasRenderingContext2D, room: Room): void {
- if (!room.visited) return;
- ctx.textAlign = 'right';
- const labels: Record = { spawn: 'START', normal: '', treasure: 'TREASURE', boss: 'BOSS' };
- const label = labels[room.type];
- if (label) {
- ctx.fillStyle = '#555';
- ctx.font = '11px monospace';
- ctx.fillText(label, CW - 20, OY + RH + 30);
- }
-}
diff --git a/src/render/minimapRenderer.ts b/src/render/minimapRenderer.ts
deleted file mode 100644
index 3f1dba6..0000000
--- a/src/render/minimapRenderer.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { CW } from '../constants';
-import type { RoomMap } from '../room/RoomMap';
-
-const CELL = 14;
-const GAP = 2;
-
-/** Draw the 7×7 minimap in the top-right corner */
-export function drawMinimap(
- ctx: CanvasRenderingContext2D,
- map: RoomMap,
- cc: number,
- cr: number,
-): void {
- const cs = CELL + GAP;
- const mx = CW - 180;
- const my = 12;
-
- ctx.fillStyle = 'rgba(0,0,0,0.75)';
- ctx.fillRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
- ctx.strokeStyle = '#333';
- ctx.lineWidth = 1;
- ctx.strokeRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
-
- for (let r = -3; r <= 3; r++) {
- for (let c = -3; c <= 3; c++) {
- const room = map.get(cc + c, cr + r);
- if (!room) continue;
-
- const x = mx + (c + 3) * cs;
- const y = my + (r + 3) * cs;
-
- let color = '#141414';
- if (room.visited) {
- color = room.type === 'spawn' ? '#2a5a2a'
- : room.type === 'boss' ? '#5a1a1a'
- : room.type === 'treasure' ? '#5a5a1a'
- : '#555';
- }
- ctx.fillStyle = color;
- ctx.fillRect(x, y, CELL, CELL);
-
- if (room.visited) {
- ctx.strokeStyle = 'rgba(255,255,255,0.12)';
- ctx.lineWidth = 1;
- if (room.doors.up) ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3);
- if (room.doors.down) ctx.fillRect(x + cs / 2 - 2, y + CELL - 1, 4, 3);
- if (room.doors.left) ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4);
- if (room.doors.right) ctx.fillRect(x + CELL - 1, y + cs / 2 - 2, 3, 4);
- }
-
- // Highlight current room
- if (c === 0 && r === 0) {
- ctx.strokeStyle = '#ddd';
- ctx.lineWidth = 2;
- ctx.strokeRect(x - 1.5, y - 1.5, CELL + 3, CELL + 3);
- }
- }
- }
-}
diff --git a/src/render/roomRenderer.ts b/src/render/roomRenderer.ts
deleted file mode 100644
index 73aa613..0000000
--- a/src/render/roomRenderer.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { OX, OY, TILE, COLS, ROWS, RW, RH } from '../constants';
-import { T_WALL, T_DOOR } from '../room/tiles';
-import type { Room } from '../room/Room';
-
-/** Draw the tile grid and wall overlays for a room */
-export function drawRoom(ctx: CanvasRenderingContext2D, room: Room): void {
- for (let r = 0; r < ROWS; r++) {
- for (let c = 0; c < COLS; c++) {
- const x = OX + c * TILE;
- const y = OY + r * TILE;
- const t = room.tiles[r][c];
-
- if (t === T_WALL) {
- ctx.fillStyle = '#1a1a24';
- ctx.fillRect(x, y, TILE, TILE);
- ctx.fillStyle = '#242436';
- ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4);
- ctx.fillStyle = '#1e1e2c';
- ctx.fillRect(x + 4, y + 4, TILE - 8, TILE - 8);
-
- ctx.strokeStyle = '#161620';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x, y + TILE / 2);
- ctx.lineTo(x + TILE, y + TILE / 2);
- ctx.stroke();
- ctx.beginPath();
- ctx.moveTo(x + TILE / 2, y);
- ctx.lineTo(x + TILE / 2, y + TILE / 2);
- ctx.stroke();
- } else if (t === T_DOOR) {
- ctx.fillStyle = '#0d0d14';
- ctx.fillRect(x, y, TILE, TILE);
- ctx.fillStyle = '#2a1e0e';
- ctx.fillRect(x + 6, y + 6, TILE - 12, TILE - 12);
- ctx.fillStyle = '#3a2e14';
- ctx.fillRect(x + 10, y + 10, TILE - 20, TILE - 20);
- } else {
- const dark = (r + c) % 2 === 0;
- ctx.fillStyle = dark ? '#2e2e24' : '#353528';
- ctx.fillRect(x, y, TILE, TILE);
- }
- }
- }
-
- // Border stroke
- ctx.strokeStyle = 'rgba(0,0,0,0.3)';
- ctx.lineWidth = 2;
- ctx.strokeRect(OX, OY, RW, RH);
-}
diff --git a/src/render/theme.ts b/src/render/theme.ts
new file mode 100644
index 0000000..ac89c1c
--- /dev/null
+++ b/src/render/theme.ts
@@ -0,0 +1,48 @@
+/**
+ * theme.ts — ВНЕШНИЙ ВИД мира (задел под кастомные ассеты).
+ *
+ * Сейчас «ассеты» — это просто цвета примитивов (квадраты/круги). Но рендер
+ * берёт их отсюда, а не из хардкода, поэтому вид легко подменить, не трогая
+ * логику: можно завести несколько тем или, в перспективе, расширить Theme
+ * полями со спрайтами/текстурами (см. комментарий ниже) и научить
+ * ThreeRenderer вешать их на материалы.
+ */
+export interface Theme {
+ /** Цвета (0xRRGGBB) элементов мира. */
+ bg: number;
+ floorA: number;
+ floorB: number;
+ wall: number;
+ door: number;
+ playerRanged: number;
+ playerMelee: number;
+ enemyNormal: number;
+ enemyFast: number;
+ enemyBoss: number;
+ tear: number;
+ swing: number;
+ flash: number; // цвет «вспышки» при попадании/неуязвимости
+
+ // ── Задел на будущее (пока не используется) ───────────────
+ // Чтобы перейти со сплошных цветов на картинки, добавь сюда, например:
+ // textures?: { floor?: string; wall?: string; player?: string; ... }
+ // (URL/путь к изображению), загрузи их через THREE.TextureLoader в
+ // ThreeRenderer и положи в material.map вместо/вместе с color.
+}
+
+/** Тема по умолчанию — текущая «тёмное подземелье». */
+export const DEFAULT_THEME: Theme = {
+ bg: 0x0a0a0f,
+ floorA: 0x2e2e24,
+ floorB: 0x353528,
+ wall: 0x242436,
+ door: 0x3a2e14,
+ playerRanged: 0x2a6a9a,
+ playerMelee: 0x9a3a2a,
+ enemyNormal: 0x5a4a2e,
+ enemyFast: 0x992222,
+ enemyBoss: 0x5a0a0a,
+ tear: 0x6699cc,
+ swing: 0xcc8844,
+ flash: 0xdddddd,
+};
diff --git a/src/room/Room.ts b/src/room/Room.ts
deleted file mode 100644
index e03b2ce..0000000
--- a/src/room/Room.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { RoomType, Doors } from '../types';
-import { buildTiles } from './tiles';
-import { Enemy } from '../entities/Enemy';
-import { Tear } from '../entities/Tear';
-
-export class Room {
- c: number;
- r: number;
- type: RoomType;
- doors: Doors = { up: false, down: false, left: false, right: false };
- visited = false;
- cleared = false;
- enemies: Enemy[] = [];
- tears: Tear[] = [];
- tiles: number[][];
-
- constructor(c: number, r: number, type: RoomType) {
- this.c = c;
- this.r = r;
- this.type = type;
- this.tiles = buildTiles();
- }
-
- /** Rebuild base tiles and optionally place doors if cleared */
- buildTiles(): void {
- this.tiles = buildTiles(this.cleared || this.type === 'spawn' ? this.doors : undefined);
- }
-}
diff --git a/src/room/RoomMap.ts b/src/room/RoomMap.ts
deleted file mode 100644
index 393e6b3..0000000
--- a/src/room/RoomMap.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { MAP_RADIUS, MIN_ROOMS, EXTRA_ROOMS } from '../constants';
-import { Room } from './Room';
-import type { RoomType } from '../types';
-import { shuffle, ri } from '../math';
-import { OPP } from '../doors';
-
-export class RoomMap {
- rooms: Map = new Map();
-
- key(c: number, r: number): string {
- return c + ',' + r;
- }
-
- get(c: number, r: number): Room | undefined {
- return this.rooms.get(this.key(c, r));
- }
-
- has(c: number, r: number): boolean {
- return this.rooms.has(this.key(c, r));
- }
-
- private add(c: number, r: number, type: RoomType): Room {
- const room = new Room(c, r, type);
- this.rooms.set(this.key(c, r), room);
- return room;
- }
-
- hasBoss(): boolean {
- for (const room of this.rooms.values()) {
- if (room.type === 'boss') return true;
- }
- return false;
- }
-
- /** Generate a connected 7×7 grid of rooms using a random walk */
- generate(): void {
- this.add(0, 0, 'spawn');
- const frontier: [number, number][] = [[0, 0]];
- let count = 1;
- const target = MIN_ROOMS + ri(0, EXTRA_ROOMS);
- const dirs: [string, number, number][] = [
- ['up', 0, -1],
- ['down', 0, 1],
- ['left', -1, 0],
- ['right', 1, 0],
- ];
-
- while (frontier.length > 0 && count < target) {
- const idx = ri(0, frontier.length - 1);
- const [cr, cc] = frontier[idx];
- shuffle(dirs);
- let added = false;
-
- for (const [_d, dc, dr] of dirs) {
- if (count >= target) break;
- const nc = cr + dc;
- const nr = cc + dr;
-
- if (Math.abs(nc) > MAP_RADIUS || Math.abs(nr) > MAP_RADIUS) continue;
- if (this.has(nc, nr)) continue;
-
- let type: RoomType = 'normal';
- if (!this.hasBoss() && (count === target - 1 || (Math.random() < 0.2 && count >= 3))) {
- type = 'boss';
- } else if (Math.random() < 0.12 && count >= 2) {
- type = 'treasure';
- }
-
- this.add(nc, nr, type);
- const dir = _d as keyof typeof OPP;
- this.get(cr, cc)!.doors[dir] = true;
- this.get(nc, nr)!.doors[OPP[dir] as keyof typeof OPP] = true;
- frontier.push([nc, nr]);
- count++;
- added = true;
- }
-
- if (!added) frontier.splice(idx, 1);
- }
-
- // Ensure at least one boss room exists
- if (!this.hasBoss()) {
- const normals = [...this.rooms.values()].filter(r => r.type === 'normal');
- if (normals.length > 0) {
- normals[ri(0, normals.length - 1)].type = 'boss';
- }
- }
- }
-}
diff --git a/src/room/tiles.ts b/src/room/tiles.ts
deleted file mode 100644
index 30dd378..0000000
--- a/src/room/tiles.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { T_WALL, T_FLOOR, T_DOOR, COLS, ROWS, DOOR } from '../constants';
-import type { Doors } from '../types';
-
-export { T_WALL, T_FLOOR, T_DOOR };
-
-/** Build a fresh tile grid (all edge tiles = wall, interior = floor) */
-export function buildTiles(doorState?: Doors): number[][] {
- const tiles: number[][] = [];
- for (let r = 0; r < ROWS; r++) {
- tiles[r] = [];
- for (let c = 0; c < COLS; c++) {
- tiles[r][c] = (r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1) ? T_WALL : T_FLOOR;
- }
- }
- if (doorState) placeDoors(tiles, doorState);
- return tiles;
-}
-
-/** Mark door tiles on an existing tile grid */
-export function placeDoors(tiles: number[][], doors: Doors): void {
- if (doors.up) for (const c of DOOR.up.cols) tiles[DOOR.up.row][c] = T_DOOR;
- if (doors.down) for (const c of DOOR.down.cols) tiles[DOOR.down.row][c] = T_DOOR;
- if (doors.left) for (const r of DOOR.left.rows) tiles[r][DOOR.left.col] = T_DOOR;
- if (doors.right) for (const r of DOOR.right.rows) tiles[r][DOOR.right.col] = T_DOOR;
-}
diff --git a/src/ui/StartMenu.ts b/src/ui/StartMenu.ts
new file mode 100644
index 0000000..fe6db78
--- /dev/null
+++ b/src/ui/StartMenu.ts
@@ -0,0 +1,45 @@
+import type { LevelRules } from '../core/rules';
+
+/**
+ * Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
+ * по клику зовёт onStart с выбранными правилами. DOM-меню выбрано осознанно:
+ * его проще стилизовать и расширять (новые поля, превью), чем рисовать UI в WebGL.
+ *
+ * Чтобы добавить пункт меню — добавь пресет в core/rules.ts (PRESETS): он
+ * появится здесь автоматически.
+ */
+export class StartMenu {
+ private readonly root: HTMLElement;
+
+ constructor(root: HTMLElement, presets: LevelRules[], onStart: (rules: LevelRules) => void) {
+ this.root = root;
+ const list = root.querySelector('#menu-presets');
+ if (!list) throw new Error('StartMenu: не найден #menu-presets внутри #menu');
+
+ for (const rules of presets) {
+ const btn = document.createElement('button');
+ btn.className = 'menu-preset';
+ btn.type = 'button';
+
+ const name = document.createElement('span');
+ name.className = 'menu-preset-name';
+ name.textContent = rules.name;
+
+ const desc = document.createElement('span');
+ desc.className = 'menu-preset-desc';
+ desc.textContent = rules.description;
+
+ btn.append(name, desc);
+ btn.addEventListener('click', () => onStart(rules));
+ list.appendChild(btn);
+ }
+ }
+
+ show(): void {
+ this.root.style.display = 'flex';
+ }
+
+ hide(): void {
+ this.root.style.display = 'none';
+ }
+}
diff --git a/tests/collision.test.ts b/tests/collision.test.ts
new file mode 100644
index 0000000..774030f
--- /dev/null
+++ b/tests/collision.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'bun:test';
+import { isBlocked, collidesWall } from '../src/core/systems/collision';
+import { Room } from '../src/core/world/Room';
+import { OX, OY, TILE, COLS, ROWS } from '../src/config';
+
+function cleanRoom(): Room {
+ const room = new Room(0, 0, 'normal');
+ room.doors = { up: true, down: false, left: false, right: false };
+ room.cleared = true;
+ room.rebuildTiles();
+ return room;
+}
+
+describe('collision', () => {
+ it('стены по краям блокируют движение', () => {
+ const room = cleanRoom();
+ expect(isBlocked(room, 0, 0)).toBe(true);
+ expect(isBlocked(room, COLS - 1, ROWS - 1)).toBe(true);
+ });
+
+ it('внутренний пол не блокирует', () => {
+ const room = cleanRoom();
+ expect(isBlocked(room, 5, 5)).toBe(false);
+ });
+
+ it('за пределами комнаты — блок, но дверной проём открыт', () => {
+ const room = cleanRoom();
+ // Над комнатой (row < 0) обычно стена...
+ expect(isBlocked(room, 0, -1)).toBe(true);
+ // ...но в колонках двери up проём открыт.
+ expect(isBlocked(room, 7, -1)).toBe(false);
+ });
+
+ it('collidesWall ловит хитбокс на стене и пропускает на полу', () => {
+ const room = cleanRoom();
+ const insideFloor = { x: OX + 5 * TILE, y: OY + 5 * TILE, w: 26, h: 26 };
+ expect(collidesWall(insideFloor, room)).toBe(false);
+
+ const onWall = { x: OX - 5, y: OY - 5, w: 26, h: 26 };
+ expect(collidesWall(onWall, room)).toBe(true);
+ });
+});
diff --git a/tests/game.test.ts b/tests/game.test.ts
new file mode 100644
index 0000000..a70dbcf
--- /dev/null
+++ b/tests/game.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect } from 'bun:test';
+import { Game } from '../src/core/Game';
+import { Rng } from '../src/core/rng';
+import { Enemy } from '../src/core/entities/Enemy';
+import { collidesWall } from '../src/core/systems/collision';
+import { emptyInput, type InputState } from '../src/input/InputState';
+import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
+import { MODE_RANGED, MODE_MELEE, OX, OY, TILE, COLS, RW } from '../src/config';
+
+function input(patch: Partial = {}): InputState {
+ return { ...emptyInput(), ...patch };
+}
+
+describe('Game', () => {
+ it('РЕГРЕССИЯ: конструируется без ошибок и игрок появляется не в стене', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(1));
+ expect(game.curRoom).toBeDefined();
+ expect(game.curRoom.type).toBe('spawn');
+ expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
+ });
+
+ it('600 пустых шагов не падают, игрок не застревает в стене, HP цело на спавне', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(2));
+ const hp0 = game.player.hp;
+ for (let i = 0; i < 600; i++) game.step(input());
+ expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
+ expect(game.player.hp).toBe(hp0); // на спавне врагов нет
+ expect(game.gameOver).toBe(false);
+ });
+
+ it('смена оружия по toggleWeapon', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(3));
+ expect(game.player.mode).toBe(MODE_RANGED);
+ game.consumeActions(input({ toggleWeapon: true }));
+ expect(game.player.mode).toBe(MODE_MELEE);
+ game.consumeActions(input({ toggleWeapon: true }));
+ expect(game.player.mode).toBe(MODE_RANGED);
+ });
+
+ it('стрельба создаёт снаряд, который потом исчезает', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(4));
+ game.step(input({ aimDir: 'right' }));
+ expect(game.curRoom.tears.length).toBe(1);
+ // Снаряд летит вправо и со временем гаснет (стена/время жизни).
+ for (let i = 0; i < 200; i++) game.step(input());
+ expect(game.curRoom.tears.length).toBe(0);
+ });
+
+ it('РЕГРЕССИЯ: комната без врагов (сокровищница) зачищается при входе — иначе двери не открыть', () => {
+ // Ищем seed, где в карте есть сокровищница.
+ let tested = false;
+ for (let seed = 1; seed <= 200 && !tested; seed++) {
+ const game = new Game(DEFAULT_RULES, new Rng(seed));
+ for (const room of game.roomMap.rooms.values()) {
+ if (room.type !== 'treasure') continue;
+ game.cc = room.c;
+ game.cr = room.r;
+ game.enterRoom('up');
+ expect(room.enemies.length).toBe(0);
+ expect(room.cleared).toBe(true); // иначе игрок застрянет без дверей
+ tested = true;
+ break;
+ }
+ }
+ expect(tested).toBe(true); // среди 200 seed сокровищница точно нашлась
+ });
+
+ it('РЕГРЕССИЯ: кнокбэк не выбрасывает врага сквозь стену (нет софт-лока)', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(7));
+ // Переносим игру в любую боевую (normal) комнату.
+ let placed = false;
+ for (const room of game.roomMap.rooms.values()) {
+ if (room.type !== 'normal') continue;
+ game.cc = room.c;
+ game.cr = room.r;
+ game.enterRoom('up');
+ placed = true;
+ break;
+ }
+ expect(placed).toBe(true);
+
+ const room = game.curRoom;
+ // Один контролируемый враг у правой стены, кнокбэк направлен В стену.
+ const e = new Enemy(OX + (COLS - 2) * TILE + TILE / 2, OY + 5 * TILE + TILE / 2, 'normal');
+ e.knx = 50; // заведомо больше толщины стены (1 тайл)
+ e.kny = 0;
+ room.enemies = [e];
+
+ for (let i = 0; i < 40; i++) game.step(emptyInput());
+
+ expect(collidesWall(e.box, room)).toBe(false); // остался внутри и достижим
+ expect(e.x).toBeLessThan(OX + RW); // не вылетел за правую стену
+ });
+
+ it('правила уровня влияют на забег: HP игрока и размер карты', () => {
+ const hardcore = PRESETS.find((p) => p.id === 'hardcore')!;
+ const big = PRESETS.find((p) => p.id === 'big')!;
+
+ const gHard = new Game(hardcore, new Rng(11));
+ expect(gHard.player.maxHp).toBe(hardcore.player.maxHp); // меньше базовых 6
+
+ // «Большой данжен» в среднем даёт больше комнат, чем «Стандарт».
+ const avg = (rules: typeof big) => {
+ let sum = 0;
+ for (let s = 1; s <= 20; s++) sum += new Game(rules, new Rng(s)).roomMap.rooms.size;
+ return sum / 20;
+ };
+ expect(avg(big)).toBeGreaterThan(avg(DEFAULT_RULES));
+ });
+
+ it('фикс-сид даёт одинаковую карту каждый раз — в т.ч. после reset()', () => {
+ const daily = PRESETS.find((p) => p.id === 'daily')!;
+ const a = new Game(daily);
+ const keys = [...a.roomMap.rooms.keys()].sort();
+
+ // другой экземпляр — та же карта
+ const b = new Game(daily);
+ expect([...b.roomMap.rooms.keys()].sort()).toEqual(keys);
+
+ // РЕГРЕССИЯ: после рестарта по [R] фикс-сид воспроизводит тот же данжен
+ a.gameOver = true;
+ a.reset();
+ expect([...a.roomMap.rooms.keys()].sort()).toEqual(keys);
+ });
+
+ it('reset() возвращает чистое состояние', () => {
+ const game = new Game(DEFAULT_RULES, new Rng(5));
+ game.player.hp = 1;
+ game.gameOver = true;
+ game.reset();
+ expect(game.gameOver).toBe(false);
+ expect(game.player.hp).toBe(game.player.maxHp);
+ expect(game.cc).toBe(0);
+ expect(game.cr).toBe(0);
+ });
+});
diff --git a/tests/rng.test.ts b/tests/rng.test.ts
new file mode 100644
index 0000000..5b5e308
--- /dev/null
+++ b/tests/rng.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'bun:test';
+import { Rng } from '../src/core/rng';
+
+describe('Rng', () => {
+ it('детерминирован: одинаковый seed → одинаковая последовательность', () => {
+ const a = new Rng(12345);
+ const b = new Rng(12345);
+ const seqA = Array.from({ length: 10 }, () => a.next());
+ const seqB = Array.from({ length: 10 }, () => b.next());
+ expect(seqA).toEqual(seqB);
+ });
+
+ it('разные seed дают разные последовательности', () => {
+ const a = new Rng(1);
+ const b = new Rng(2);
+ expect(a.next()).not.toBe(b.next());
+ });
+
+ it('next() всегда в [0, 1)', () => {
+ const r = new Rng(7);
+ for (let i = 0; i < 1000; i++) {
+ const v = r.next();
+ expect(v).toBeGreaterThanOrEqual(0);
+ expect(v).toBeLessThan(1);
+ }
+ });
+
+ it('int(a, b) держится в границах включительно', () => {
+ const r = new Rng(99);
+ for (let i = 0; i < 1000; i++) {
+ const v = r.int(3, 7);
+ expect(v).toBeGreaterThanOrEqual(3);
+ expect(v).toBeLessThanOrEqual(7);
+ expect(Number.isInteger(v)).toBe(true);
+ }
+ });
+
+ it('shuffle сохраняет все элементы', () => {
+ const r = new Rng(42);
+ const arr = [1, 2, 3, 4, 5];
+ const shuffled = r.shuffle([...arr]);
+ expect([...shuffled].sort()).toEqual(arr);
+ });
+});
diff --git a/tests/roomMap.test.ts b/tests/roomMap.test.ts
new file mode 100644
index 0000000..df3e644
--- /dev/null
+++ b/tests/roomMap.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'bun:test';
+import { RoomMap } from '../src/core/world/RoomMap';
+import { Rng } from '../src/core/rng';
+import { OPP } from '../src/config';
+import type { Dir } from '../src/core/types';
+
+const NEIGHBOR: Record = {
+ up: [0, -1],
+ down: [0, 1],
+ left: [-1, 0],
+ right: [1, 0],
+};
+
+describe('RoomMap', () => {
+ // Перебираем много seed: генерация случайная, баги могут прятаться в редких раскладах.
+ const maps = Array.from({ length: 50 }, (_, i) => new RoomMap(new Rng(i + 1)));
+
+ it('РЕГРЕССИЯ: карта не пустая (генерация реально вызвана)', () => {
+ // Именно тут раньше всё падало: конструктор не звал generate() и карта была пустой.
+ for (const m of maps) {
+ expect(m.rooms.size).toBeGreaterThan(1);
+ }
+ });
+
+ it('всегда есть спавн в (0,0)', () => {
+ for (const m of maps) {
+ expect(m.get(0, 0)?.type).toBe('spawn');
+ }
+ });
+
+ it('всегда есть ровно одна (минимум одна) комната-босс', () => {
+ for (const m of maps) {
+ const bosses = [...m.rooms.values()].filter((r) => r.type === 'boss');
+ expect(bosses.length).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it('двери симметричны: дверь A→B всегда имеет встречную B→A', () => {
+ for (const m of maps) {
+ for (const room of m.rooms.values()) {
+ for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
+ if (!room.doors[dir]) continue;
+ const [dc, dr] = NEIGHBOR[dir];
+ const neighbor = m.get(room.c + dc, room.r + dr);
+ expect(neighbor).toBeDefined();
+ expect(neighbor!.doors[OPP[dir]]).toBe(true);
+ }
+ }
+ }
+ });
+
+ it('карта связна: все комнаты достижимы из спавна по дверям', () => {
+ for (const m of maps) {
+ const start = m.get(0, 0)!;
+ const seen = new Set([`${start.c},${start.r}`]);
+ const queue = [start];
+ while (queue.length) {
+ const room = queue.shift()!;
+ for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
+ if (!room.doors[dir]) continue;
+ const [dc, dr] = NEIGHBOR[dir];
+ const n = m.get(room.c + dc, room.r + dr);
+ if (n && !seen.has(`${n.c},${n.r}`)) {
+ seen.add(`${n.c},${n.r}`);
+ queue.push(n);
+ }
+ }
+ }
+ expect(seen.size).toBe(m.rooms.size);
+ }
+ });
+});
diff --git a/tests/spawner.test.ts b/tests/spawner.test.ts
new file mode 100644
index 0000000..5b306f5
--- /dev/null
+++ b/tests/spawner.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'bun:test';
+import { spawnEnemies } from '../src/core/systems/spawner';
+import { Room } from '../src/core/world/Room';
+import { Rng } from '../src/core/rng';
+import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
+import { dist } from '../src/core/util';
+
+describe('spawner', () => {
+ it('в обычной комнате врагов в ожидаемом диапазоне', () => {
+ const room = new Room(1, 0, 'normal');
+ for (let s = 0; s < 30; s++) {
+ const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(s + 1));
+ expect(enemies.length).toBeGreaterThanOrEqual(SPAWN.normalMin);
+ expect(enemies.length).toBeLessThanOrEqual(SPAWN.normalMin + SPAWN.normalExtra);
+ }
+ });
+
+ it('в комнате-босс ровно один враг типа boss', () => {
+ const room = new Room(1, 0, 'boss');
+ const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
+ expect(enemies.length).toBe(1);
+ expect(enemies[0].type).toBe('boss');
+ });
+
+ it('в сокровищнице врагов нет', () => {
+ const room = new Room(1, 0, 'treasure');
+ const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
+ expect(enemies.length).toBe(0);
+ });
+
+ it('враги не спавнятся вплотную к двери входа', () => {
+ const room = new Room(1, 0, 'normal');
+ const door = DOOR.left;
+ const doorX = OX + door.cx * TILE + TILE / 2;
+ const doorY = OY + door.cy * TILE + TILE / 2;
+ for (let s = 0; s < 20; s++) {
+ const enemies = spawnEnemies(room, 'left', OX + 7 * TILE, OY + 5 * TILE, new Rng(s + 100));
+ for (const e of enemies) {
+ expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
+ }
+ }
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index bcfac72..f424a51 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,15 +3,15 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
- "noUnusedLocals": false,
+ "noUnusedLocals": true,
"noUnusedParameters": false,
+ "noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
- "outDir": "dist",
- "rootDir": "src",
- "declaration": true,
- "sourceMap": true
+ "noEmit": true,
+ "forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}