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"] }