Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0684368ac7 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/*.map
|
dist/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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, пока этого не попросили (рендер для этого
|
||||||
|
готов — он изолирован, — но это отдельная большая задача).
|
||||||
@@ -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*.
|
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
|
||||||
Built with TypeScript and Canvas 2D, bundled with **Bun**.
|
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** (ортографическая
|
||||||
|
камера, картинка плоская 2D-сверху). Сборка — **Bun**.
|
||||||
|
|
||||||
## Quick Start
|
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
||||||
|
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
||||||
|
Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры).
|
||||||
|
|
||||||
|
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
|
||||||
|
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
|
||||||
|
> Перед доработкой прочитай [`CLAUDE.md`](./CLAUDE.md) и [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install # install deps (none currently required)
|
bun install # поставить зависимости (three, typescript)
|
||||||
bun start # production build → dist/
|
bun run dev # дев-сервер с авто-пересборкой → http://localhost:3000
|
||||||
open dist/index.html
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or for development with live reload:
|
Продакшн-сборка:
|
||||||
|
|
||||||
```bash
|
```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 |
|
| WASD | движение |
|
||||||
| Arrow keys | Attack in direction |
|
| Стрелки | прицельная стрельба/удар в направлении |
|
||||||
| Space | Attack in facing direction |
|
| Пробел | атака по ходу движения |
|
||||||
| Tab / Q | Switch weapon (Ranged ↔ Melee) |
|
| Tab / Q | сменить оружие (Дальний ↔ Ближний) |
|
||||||
| R | Restart (on Game Over / Victory) |
|
| R | заново (на экране Game Over / Victory) |
|
||||||
|
| Esc | вернуться в меню выбора уровня |
|
||||||
|
|
||||||
## Weapons
|
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
|
||||||
|
дверь, нажимая в её сторону.
|
||||||
|
|
||||||
| Mode | Weapon | Speed | Damage | Effect |
|
## Режимы боя
|
||||||
|
|
||||||
|
| Режим | Оружие | Скорость | Урон | Особенность |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Ranged | Pistol | Fast (10cd) | 1 per shot | Ranged projectiles |
|
| Дальний | пистолет | быстро (cd 10) | 1 за выстрел | снаряды летят по прямой |
|
||||||
| Melee | Knife | Slow (22cd) | 2 + knockback | Wide swing arc |
|
| Ближний | нож | медленно (cd 22) | 2 + отбрасывание | широкий взмах |
|
||||||
|
|
||||||
## Room Types
|
## Типы комнат
|
||||||
|
|
||||||
| Type | Description |
|
| Тип | Описание |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Spawn | Starting room, no enemies |
|
| spawn | старт, врагов нет |
|
||||||
| Normal | 2–4 enemies |
|
| normal | 2–4 врага |
|
||||||
| Treasure | No enemies, loot room |
|
| treasure | без врагов (комната-награда), сразу открыта |
|
||||||
| Boss | 1 boss enemy, clearing wins the game |
|
| boss | 1 босс; его зачистка = победа |
|
||||||
|
|
||||||
## Project Structure
|
## Стек и устройство
|
||||||
|
|
||||||
|
- **Bun** — рантайм, бандлер и тест-раннер.
|
||||||
|
- **three.js** — WebGL-рендер мира через ортокамеру.
|
||||||
|
- **TypeScript (strict)** — весь код.
|
||||||
|
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
|
||||||
|
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
|
||||||
|
желании можно подменить рендер, не трогая игру.
|
||||||
|
|
||||||
|
Подробности — в [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.ts Entry point
|
├── config.ts ⭐ КОНСТАНТЫ движка: размеры, геометрия дверей, базовый баланс
|
||||||
├── constants.ts Grid, canvas, tile/door constants
|
├── main.ts точка входа: меню → игра, «склейка» логики/рендера/ввода
|
||||||
├── types.ts Shared TypeScript types
|
├── core/ ── ИГРОВАЯ ЛОГИКА (без DOM и three.js) ──
|
||||||
├── math.ts Utility: shuffle, rand, dist, overlap
|
│ ├── Game.ts «мозг»: состояние + один шаг симуляции step()
|
||||||
├── input.ts Keyboard state (global KEYS map)
|
│ ├── rules.ts ⭐ ПРАВИЛА УРОВНЯ (пресеты для меню: размер, враги, HP, seed)
|
||||||
├── doors.ts Door geometry helpers
|
│ ├── types.ts общие типы (Dir, RoomType, Box, …)
|
||||||
├── room/
|
│ ├── rng.ts ГПСЧ с seed (детерминизм для тестов/отладки)
|
||||||
│ ├── Room.ts Room class (tiles, enemies, tears)
|
│ ├── util.ts мат-утилиты (dist, overlap, clamp, lerp)
|
||||||
│ ├── RoomMap.ts Map generation (connected 7×7 grid)
|
│ ├── entities/ Player, Enemy, Projectile, MeleeSwing
|
||||||
│ └── tiles.ts Tile builders (wall/floor/door placement)
|
│ ├── world/ Room, RoomMap (генерация), tiles
|
||||||
├── entities/
|
│ └── systems/ collision, spawner (чистые функции)
|
||||||
│ ├── Player.ts Player stat block
|
├── input/ ── ВВОД ──
|
||||||
│ ├── Enemy.ts Enemy stat block + enemy type logic
|
│ ├── InputState.ts абстрактные «намерения» (не сырые клавиши)
|
||||||
│ ├── Tear.ts Ranged projectile
|
│ └── KeyboardController.ts клавиатура → InputState
|
||||||
│ └── MeleeSwing.ts Melee hitbox
|
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
|
||||||
├── game/
|
│ ├── Renderer.ts интерфейс рендера
|
||||||
│ ├── Game.ts Main orchestrator (loop, tick, render)
|
│ ├── ThreeRenderer.ts мир на three.js (ортокамера)
|
||||||
│ ├── collision.ts Wall collision (isBlocked, collidesWall)
|
│ ├── theme.ts ⭐ ВНЕШНИЙ ВИД (цвета; задел под спрайты/текстуры)
|
||||||
│ ├── transitions.ts Room transition detection
|
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
|
||||||
│ └── spawner.ts Enemy placement logic
|
├── ui/
|
||||||
└── render/
|
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
|
||||||
├── roomRenderer.ts Tile rendering
|
└── engine/
|
||||||
├── entityRenderer.ts Player, enemy, tear, melee drawing
|
└── GameLoop.ts игровой цикл с фиксированным шагом (60 Гц)
|
||||||
├── hudRenderer.ts HP bar, mode indicator, HUD
|
|
||||||
└── minimapRenderer.ts Minimap drawing
|
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.
|
|
||||||
|
|||||||
@@ -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({
|
const result = await build({
|
||||||
entrypoints: ["./src/main.ts"],
|
entrypoints: ['./src/main.ts'],
|
||||||
outdir: "./dist",
|
outdir: './dist',
|
||||||
target: "browser",
|
target: 'browser',
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: "external",
|
sourcemap: 'external',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("Build failed", result.logs);
|
console.error('Сборка упала:');
|
||||||
|
for (const log of result.logs) console.error(log);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy HTML template with correct script reference
|
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
||||||
const html = `<!DOCTYPE html>
|
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
|
||||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="game" width="880" height="660"></canvas>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
Bun.write("./dist/index.html", html);
|
|
||||||
console.log("Build complete → dist/");
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
const builder = spawn(
|
||||||
await Bun.write(
|
// --sourcemap=linked: в DevTools брейкпоинты/стек указывают на твой src/*.ts,
|
||||||
"./dist/index.html",
|
// а не на собранный main.js. .map отдаётся из dist тем же сервером.
|
||||||
`<!DOCTYPE html>
|
['bun', 'build', 'src/main.ts', '--outdir', 'dist', '--target', 'browser', '--sourcemap=linked', '--watch'],
|
||||||
<html lang="ru">
|
{ stdout: 'inherit', stderr: 'inherit' },
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
|
||||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="game" width="880" height="660"></canvas>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 port = 3000;
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port,
|
port,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
let path = url.pathname === "/" ? "/index.html" : url.pathname;
|
// index.html отдаём прямо из корня — правки CSS/разметки сразу живые;
|
||||||
const file = Bun.file("./dist" + path);
|
// бандл main.js и карты — из dist (их пишет watch-сборщик).
|
||||||
return new Response(file);
|
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(`\n Dev server: http://localhost:${server.port}`);
|
||||||
console.log(` Watching: src/\n`);
|
console.log(' Watching: src/\n');
|
||||||
|
|
||||||
// Graceful shutdown
|
process.on('SIGINT', () => {
|
||||||
process.on("SIGINT", () => {
|
|
||||||
builder.kill();
|
builder.kill();
|
||||||
server.stop();
|
server.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
Vendored
-16
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
|
||||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="game" width="880" height="660"></canvas>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Vendored
-1091
File diff suppressed because it is too large
Load Diff
+147
-102
@@ -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)
|
│ GameLoop (engine/) │
|
||||||
└── Game.ts
|
│ фиксированный шаг 60 Гц + интерполяция │
|
||||||
├── constants.ts (all shared numeric constants)
|
└───────┬───────────────┬──────────────┬─────┘
|
||||||
├── types.ts (Dir, RoomType, CombatMode, Box, Doors)
|
│ poll() │ step(input) │ render(game, alpha)
|
||||||
├── math.ts (shuffle, rand, ri, dist, overlap)
|
▼ ▼ ▼
|
||||||
├── RoomMap.ts → Room.ts, tiles.ts, doors.ts
|
KeyboardController Game (core/) Renderer (render/)
|
||||||
├── Player.ts
|
клавиши → InputState состояние + ЧИТАЕТ состояние,
|
||||||
├── Enemy.ts
|
логика игры рисует three.js + HUD
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Game Loop
|
Три части не знают лишнего друг о друге:
|
||||||
|
|
||||||
Every frame (`requestAnimationFrame`):
|
- **`core/`** — игровая логика. Не импортирует ни `three`, ни DOM (`window`,
|
||||||
|
`document`, `canvas`). Получает на вход абстрактный `InputState`, меняет своё
|
||||||
|
состояние. Поэтому ядро **тестируется без браузера** (`bun test`) и не зависит
|
||||||
|
от того, чем мы рисуем.
|
||||||
|
- **`render/`** — рендер. Только **читает** `Game` и рисует. Менять состояние
|
||||||
|
игры рендеру запрещено.
|
||||||
|
- **`input/`** — превращает устройство ввода (клавиатуру) в абстрактные
|
||||||
|
«намерения» (`InputState`). Захочешь геймпад — добавишь ещё один контроллер.
|
||||||
|
|
||||||
1. **Timers** — decrement `invTimer`, `atkCD`, `transCD`
|
`main.ts` — единственное место, где всё это соединяется.
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
||||||
each cardinal edge (top: cols 6-8, row 0; bottom: cols 6-8, row 10;
|
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Единица времени везде — **шаг** (= 1/60 c). Скорости заданы «пикселей за шаг»,
|
||||||
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.
|
|
||||||
|
|
||||||
## Room Generation
|
## Шаг симуляции (core/Game.ts → step)
|
||||||
|
|
||||||
`RoomMap.generate()` uses a random walk:
|
Каждый шаг по порядку:
|
||||||
|
|
||||||
1. Start at (0,0) with a spawn room.
|
1. **Снимок prev-позиций** — `prevX/prevY` всех подвижных сущностей (для интерполяции).
|
||||||
2. Maintain a frontier list of rooms that have room to expand.
|
2. **Таймеры** — `invTimer`, `atkCD`, `transCD`.
|
||||||
3. Each step, pick a random frontier room, shuffle directions, and
|
3. **Движение** игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
|
||||||
attempt to add a new room in an unoccupied adjacent cell within
|
4. **Атака** — стрелки/пробел: выстрел (`Projectile`) или взмах (`MeleeSwing`).
|
||||||
the 7×7 grid.
|
5. **Ближний бой** — урон+отбрасывание по врагам в хитбоксе взмаха.
|
||||||
4. Room types are assigned probabilistically (boss at ~20%, treasure
|
6. **Снаряды** — полёт, попадание в стену/врага.
|
||||||
at ~12%, rest normal).
|
7. **ИИ врагов** — преследование игрока, контактный урон с перезарядкой.
|
||||||
5. If no boss room was generated, one normal room is promoted.
|
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
|
## Ввод (input/)
|
||||||
`CanvasRenderingContext2D` as the first argument:
|
|
||||||
|
|
||||||
- **roomRenderer** — tile loop with wall/door/floor styles
|
`InputState` — снимок намерений: оси движения, направление прицела, флаги
|
||||||
- **entityRenderer** — enemies (3 types), player (body + weapon),
|
удержания и однократные «edge»-действия. Делится на:
|
||||||
tears, melee swing arc
|
|
||||||
- **hudRenderer** — HP bar, mode indicator, enemy count, room label
|
|
||||||
- **minimapRenderer** — 7×7 grid with visited/current room highlights
|
|
||||||
|
|
||||||
## Weapon Visuals
|
- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
|
||||||
|
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
|
||||||
|
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
|
||||||
|
|
||||||
- **Pistol (ranged)**: Barrel line + body rect, rotated toward facing
|
## Мир: комнаты и генерация (core/world/)
|
||||||
direction. Muzzle flash circle at `atkCD > 8`.
|
|
||||||
- **Knife (melee)**: Triangular blade + handle + guard rects, offset
|
|
||||||
in facing direction.
|
|
||||||
|
|
||||||
## Enemy Types
|
- **Комната** — сетка `COLS×ROWS` тайлов (стены по краю, пол внутри). Двери
|
||||||
|
прорезаются в тайлах только когда комната `cleared` (или это `spawn`).
|
||||||
|
- **RoomMap** — связный набор комнат на сетке `(2·MAP_RADIUS+1)²`, генерируется
|
||||||
|
**случайным блужданием прямо в конструкторе**. Двери ставятся парами: у текущей
|
||||||
|
комнаты в сторону соседа и встречная у соседа (через `OPP`).
|
||||||
|
|
||||||
| Type | Size | HP | Speed | Damage | Visual |
|
> **Исторический баг №1.** При разнесении на модули у `RoomMap` потеряли вызов
|
||||||
|--------|------|----|-------|--------|--------|
|
> генерации → карта была пустой → игра падала на старте. Теперь генерация в
|
||||||
| Normal | 32px | 3 | 1.15 | 1 | Brown block, yellow eyes |
|
> конструкторе, а тест `tests/roomMap.test.ts` это стережёт.
|
||||||
| Fast | 26px | 2 | 1.9 | 1 | Red circle, small eyes |
|
>
|
||||||
| Boss | 46px | 10 | 0.9 | 2 | Large red circle, horns, HP bar |
|
> **Исторический баг №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.
|
- `isBlocked(room, col, row)` — тайл-стена или выход за границы блокируют, **кроме**
|
||||||
- `KEYS` — global mutable map, reset on window blur.
|
дверных проёмов (там граница «прозрачна», чтобы встать на дверь и перейти).
|
||||||
- Room state (`cleared`, `visited`, `enemies[]`, `tears[]`) — per-room.
|
- `collidesWall(box, room)` — перебирает тайлы под хитбоксом.
|
||||||
- Player state (`hp`, `mode`, `facing`, `transCD`, etc.) — single
|
- Движение разрешается по осям раздельно: применяем X (откат при коллизии),
|
||||||
`Player` instance.
|
затем Y. Это даёт «скольжение» вдоль стен.
|
||||||
- Cooldowns (`invTimer`, `atkCD`, `transCD`) decrement each frame.
|
|
||||||
|
|
||||||
## 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
|
LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер карты
|
||||||
cp src/index.html dist/
|
└──► Player(rules.player) // HP/скорость
|
||||||
|
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Bundler: Bun's native bundler (esbuild under the hood).
|
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
|
||||||
- Target: browser (ES module → IIFE/global wrapper).
|
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
|
||||||
- Result: single `dist/main.js` (~30 KB) + `dist/index.html`.
|
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
|
||||||
- Open `dist/index.html` directly in any modern browser.
|
|
||||||
|
### Тема / ассеты (render/theme.ts)
|
||||||
|
|
||||||
|
Внешний вид мира вынесен в `Theme` (сейчас — только цвета примитивов). `ThreeRenderer`
|
||||||
|
берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику.
|
||||||
|
Это **задел под кастомные ассеты**: чтобы перейти на спрайты/текстуры, расширь `Theme`
|
||||||
|
полями с путями к изображениям, загрузи их `THREE.TextureLoader` и положи в
|
||||||
|
`material.map`. Логика игры при этом не меняется.
|
||||||
|
|||||||
+161
@@ -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<Type>(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`. Логику отлаживай
|
||||||
|
тестом, а не кликами.
|
||||||
+58
-694
@@ -1,702 +1,66 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<style>
|
<title>Dungeon Crawl — three.js</title>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
<style>
|
||||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
body {
|
||||||
</style>
|
background: #0a0a0a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
/* Сцена = два наложенных холста: WebGL-мир снизу, 2D-HUD сверху.
|
||||||
|
width/height: 100% обязательны — иначе canvas (replaced-элемент)
|
||||||
|
показывается в размер своего HiDPI-буфера и вылезает за рамки. */
|
||||||
|
#stage { position: relative; width: 880px; height: 660px; border: 1px solid #222; }
|
||||||
|
#stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
#hud { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
|
||||||
|
#menu {
|
||||||
|
position: absolute; inset: 0; z-index: 10;
|
||||||
|
background: #0a0a0fee;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 14px; padding: 24px;
|
||||||
|
}
|
||||||
|
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; }
|
||||||
|
.menu-sub { color: #888; margin-bottom: 6px; }
|
||||||
|
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
||||||
|
.menu-preset {
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
text-align: left; cursor: pointer;
|
||||||
|
background: #15151f; color: #ddd;
|
||||||
|
border: 1px solid #333; border-radius: 4px;
|
||||||
|
padding: 12px 14px; font-family: monospace; font-size: 14px;
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.menu-preset:hover { background: #1d1d2c; border-color: #5a78c0; }
|
||||||
|
.menu-preset-name { font-weight: bold; color: #cdd6f4; }
|
||||||
|
.menu-preset-desc { font-size: 11px; color: #888; }
|
||||||
|
.menu-hint { color: #555; font-size: 12px; margin-top: 10px; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="game" width="880" height="660"></canvas>
|
<div id="stage">
|
||||||
<script>
|
<canvas id="game" width="880" height="660"></canvas>
|
||||||
// ============================================================
|
<canvas id="hud" width="880" height="660"></canvas>
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================
|
|
||||||
const CW=880,CH=660,TILE=44,COLS=15,ROWS=11;
|
|
||||||
const RW=COLS*TILE,RH=ROWS*TILE;
|
|
||||||
const OX=(CW-RW)/2,OY=80;
|
|
||||||
|
|
||||||
const T_WALL=0,T_FLOOR=1,T_DOOR=2;
|
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
|
||||||
const MODE_RANGED=0,MODE_MELEE=1;
|
<div id="menu">
|
||||||
|
<h1>DUNGEON CRAWL</h1>
|
||||||
const DIR={
|
<p class="menu-sub">Выбери уровень</p>
|
||||||
up:[0,-1], down:[0,1], left:[-1,0], right:[1,0]
|
<div id="menu-presets"></div>
|
||||||
};
|
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
|
||||||
const OPP={up:'bottom',down:'top',left:'right',right:'left'};
|
</div>
|
||||||
|
</div>
|
||||||
const DOOR={
|
<script type="module" src="./main.js"></script>
|
||||||
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 }
|
|
||||||
};
|
|
||||||
|
|
||||||
let KEYS={},GAME_OVER=false,WON=false;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// UTILITY
|
|
||||||
// ============================================================
|
|
||||||
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 dist(x1,y1,x2,y2){return Math.hypot(x2-x1,y2-y1)}
|
|
||||||
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}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ROOM
|
|
||||||
// ============================================================
|
|
||||||
class Room{
|
|
||||||
constructor(c,r,type){
|
|
||||||
this.c=c;this.r=r;this.type=type;
|
|
||||||
this.doors={up:false,down:false,left:false,right:false};
|
|
||||||
this.visited=false;this.cleared=false;
|
|
||||||
this.enemies=[];this.tears=[];
|
|
||||||
this.tiles=[];this.buildTiles();
|
|
||||||
}
|
|
||||||
buildTiles(){
|
|
||||||
for(let r=0;r<ROWS;r++){
|
|
||||||
this.tiles[r]=[];
|
|
||||||
for(let c=0;c<COLS;c++){
|
|
||||||
this.tiles[r][c]=(r===0||r===ROWS-1||c===0||c===COLS-1)?T_WALL:T_FLOOR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(this.cleared)this.placeDoors();
|
|
||||||
}
|
|
||||||
placeDoors(){
|
|
||||||
if(this.doors.up)for(const c of DOOR.up.cols)this.tiles[DOOR.up.row][c]=T_DOOR;
|
|
||||||
if(this.doors.down)for(const c of DOOR.down.cols)this.tiles[DOOR.down.row][c]=T_DOOR;
|
|
||||||
if(this.doors.left)for(const r of DOOR.left.rows)this.tiles[r][DOOR.left.col]=T_DOOR;
|
|
||||||
if(this.doors.right)for(const r of DOOR.right.rows)this.tiles[r][DOOR.right.col]=T_DOOR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ROOM MAP
|
|
||||||
// ============================================================
|
|
||||||
class RoomMap{
|
|
||||||
constructor(){this.rooms={};this.generate()}
|
|
||||||
key(c,r){return c+','+r}
|
|
||||||
get(c,r){return this.rooms[this.key(c,r)]}
|
|
||||||
has(c,r){return !!this.get(c,r)}
|
|
||||||
add(c,r,t){const o=new Room(c,r,t);this.rooms[this.key(c,r)]=o;return o}
|
|
||||||
|
|
||||||
hasBoss(){return Object.values(this.rooms).some(r=>r.type==='boss')}
|
|
||||||
|
|
||||||
generate(){
|
|
||||||
this.add(0,0,'spawn');
|
|
||||||
let frontier=[[0,0]],count=1,target=8+ri(0,4);
|
|
||||||
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,nr=cc+dr;
|
|
||||||
if(Math.abs(nc)>3||Math.abs(nr)>3||this.has(nc,nr))continue;
|
|
||||||
let type='normal';
|
|
||||||
if((count===target-1||(Math.random()<0.2&&count>=3))&&!this.hasBoss())type='boss';
|
|
||||||
else if(Math.random()<0.12&&count>=2)type='treasure';
|
|
||||||
this.add(nc,nr,type);
|
|
||||||
this.get(cr,cc).doors[d]=true;
|
|
||||||
this.get(nc,nr).doors[OPP[d]]=true;
|
|
||||||
frontier.push([nc,nr]);count++;added=true;
|
|
||||||
}
|
|
||||||
if(!added)frontier.splice(idx,1);
|
|
||||||
}
|
|
||||||
if(!this.hasBoss()){
|
|
||||||
const cs=Object.values(this.rooms).filter(r=>r.type==='normal');
|
|
||||||
if(cs.length>0)cs[ri(0,cs.length-1)].type='boss';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ENTITIES
|
|
||||||
// ============================================================
|
|
||||||
class Player{
|
|
||||||
constructor(){
|
|
||||||
this.x=0;this.y=0;this.w=26;this.h=26;
|
|
||||||
this.speed=3.2;this.hp=6;this.maxHp=6;
|
|
||||||
this.mode=MODE_RANGED;this.facing='up';this.moveDir='up';
|
|
||||||
this.atkCD=0;this.invTimer=0;this.transCD=0;
|
|
||||||
}
|
|
||||||
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Enemy{
|
|
||||||
constructor(x,y,type){
|
|
||||||
this.x=x;this.y=y;this.type=type;
|
|
||||||
this.w=type==='boss'?46:type==='fast'?26:32;
|
|
||||||
this.h=this.w;
|
|
||||||
this.hp=type==='boss'?10:type==='fast'?2:3;
|
|
||||||
this.maxHp=this.hp;
|
|
||||||
this.speed=type==='boss'?0.9:type==='fast'?1.9:1.15;
|
|
||||||
this.damage=type==='boss'?2:1;
|
|
||||||
this.knx=0;this.kny=0;this.hitTimer=0;this.atkTimer=0;
|
|
||||||
}
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Tear{
|
|
||||||
constructor(x,y,dx,dy){
|
|
||||||
this.x=x;this.y=y;this.dx=dx;this.dy=dy;
|
|
||||||
this.r=5;this.speed=7;this.damage=1;this.life=80;
|
|
||||||
}
|
|
||||||
get alive(){return this.life>0}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MeleeSwing{
|
|
||||||
constructor(x,y,dir){
|
|
||||||
this.dir=dir;this.life=10;this.damage=2;this.kb=10;
|
|
||||||
const d=22,s=50;
|
|
||||||
switch(dir){
|
|
||||||
case'up': this.box={x:x-s/2,y:y-d-s,w:s,h:s};break;
|
|
||||||
case'down': this.box={x:x-s/2,y:y+d,w:s,h:s};break;
|
|
||||||
case'left': this.box={x:x-d-s,y:y-s/2,w:s,h:s};break;
|
|
||||||
case'right':this.box={x:x+d,y:y-s/2,w:s,h:s};break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get alive(){return this.life>0}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// GAME
|
|
||||||
// ============================================================
|
|
||||||
let game=null;
|
|
||||||
|
|
||||||
class Game{
|
|
||||||
constructor(){
|
|
||||||
this.canvas=document.getElementById('game');
|
|
||||||
this.ctx=this.canvas.getContext('2d');
|
|
||||||
this.map=new RoomMap();
|
|
||||||
this.player=new Player();
|
|
||||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
|
||||||
this.setupInput();
|
|
||||||
this.enterRoom('up');
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
get cur(){return this.map.get(this.cc,this.cr)}
|
|
||||||
|
|
||||||
setupInput(){
|
|
||||||
window.addEventListener('keydown',e=>{
|
|
||||||
if((e.key==='Tab'||e.key==='q'||e.key==='Q')&&!GAME_OVER&&!WON){
|
|
||||||
e.preventDefault();this.player.mode=this.player.mode===MODE_RANGED?MODE_MELEE:MODE_RANGED;
|
|
||||||
}
|
|
||||||
if(e.key==='r'||e.key==='R'){if(GAME_OVER||WON)this.restart()}
|
|
||||||
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',()=>{KEYS={}});
|
|
||||||
}
|
|
||||||
|
|
||||||
restart(){
|
|
||||||
GAME_OVER=false;WON=false;
|
|
||||||
this.map=new RoomMap();this.player=new Player();
|
|
||||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
|
||||||
this.enterRoom('up');
|
|
||||||
}
|
|
||||||
|
|
||||||
enterRoom(fromDir){
|
|
||||||
const room=this.cur;
|
|
||||||
room.visited=true;
|
|
||||||
this.entryDir=fromDir;
|
|
||||||
const d=DOOR[fromDir],[ddc,ddr]=DIR[fromDir];
|
|
||||||
|
|
||||||
// place player 1 tile inside from the door center
|
|
||||||
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; // prevent re-transition for 15 frames
|
|
||||||
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+ri(0,2);
|
|
||||||
for(let i=0;i<count;i++){
|
|
||||||
let tries=0,x,y,ok;
|
|
||||||
const type=room.type==='boss'?'boss':Math.random()<0.3?'fast':'normal';
|
|
||||||
do{
|
|
||||||
x=OX+2*TILE+rand(0,COLS-4)*TILE;
|
|
||||||
y=OY+2*TILE+rand(0,ROWS-4)*TILE;
|
|
||||||
ok=true;
|
|
||||||
const ed=DOOR[entryDir],dx=OX+ed.cx*TILE+TILE/2,dy=OY+ed.cy*TILE+TILE/2;
|
|
||||||
if(dist(x,y,dx,dy)<180)ok=false;
|
|
||||||
for(const e of room.enemies)if(dist(x,y,e.x,e.y)<60)ok=false;
|
|
||||||
if(dist(x,y,this.player.x,this.player.y)<150)ok=false;
|
|
||||||
tries++;
|
|
||||||
}while(!ok&&tries<100);
|
|
||||||
room.enemies.push(new Enemy(x,y,type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isBlocked(room,col,row){
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
collidesWall(ent,room){
|
|
||||||
const l=Math.floor((ent.x-ent.w/2-OX)/TILE);
|
|
||||||
const r=Math.floor((ent.x+ent.w/2-OX)/TILE);
|
|
||||||
const t=Math.floor((ent.y-ent.h/2-OY)/TILE);
|
|
||||||
const b=Math.floor((ent.y+ent.h/2-OY)/TILE);
|
|
||||||
for(let row=t;row<=b;row++)
|
|
||||||
for(let col=l;col<=r;col++)
|
|
||||||
if(this.isBlocked(room,col,row))return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TRANSITION: player stands on a door tile and moves into it ---
|
|
||||||
checkTransition(){
|
|
||||||
if(GAME_OVER||WON)return;
|
|
||||||
if(this.player.transCD>0)return;
|
|
||||||
const p=this.player,room=this.cur;
|
|
||||||
if(!room.cleared)return;
|
|
||||||
const col=Math.floor((p.x-OX)/TILE),row=Math.floor((p.y-OY)/TILE);
|
|
||||||
|
|
||||||
// top door
|
|
||||||
if(row===0&&room.doors.up&&DOOR.up.cols.includes(col)&&(KEYS['w']||KEYS['W']||KEYS['ArrowUp'])){
|
|
||||||
if(this.map.has(this.cc,this.cr-1)){this.cr--;this.enterRoom('down');return}
|
|
||||||
}
|
|
||||||
// bottom door
|
|
||||||
if(row===ROWS-1&&room.doors.down&&DOOR.down.cols.includes(col)&&(KEYS['s']||KEYS['S']||KEYS['ArrowDown'])){
|
|
||||||
if(this.map.has(this.cc,this.cr+1)){this.cr++;this.enterRoom('up');return}
|
|
||||||
}
|
|
||||||
// left door
|
|
||||||
if(col===0&&room.doors.left&&DOOR.left.rows.includes(row)&&(KEYS['a']||KEYS['A']||KEYS['ArrowLeft'])){
|
|
||||||
if(this.map.has(this.cc-1,this.cr)){this.cc--;this.enterRoom('right');return}
|
|
||||||
}
|
|
||||||
// right door
|
|
||||||
if(col===COLS-1&&room.doors.right&&DOOR.right.rows.includes(row)&&(KEYS['d']||KEYS['D']||KEYS['ArrowRight'])){
|
|
||||||
if(this.map.has(this.cc+1,this.cr)){this.cc++;this.enterRoom('left');return}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update(){
|
|
||||||
if(!GAME_OVER&&!WON)this.tick();
|
|
||||||
this.render();
|
|
||||||
requestAnimationFrame(()=>this.update());
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(){
|
|
||||||
const room=this.cur,p=this.player;
|
|
||||||
|
|
||||||
if(p.invTimer>0)p.invTimer--;
|
|
||||||
if(p.atkCD>0)p.atkCD--;
|
|
||||||
if(p.transCD>0)p.transCD--;
|
|
||||||
|
|
||||||
// --- MOVEMENT ---
|
|
||||||
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,dy=my*p.speed;
|
|
||||||
p.x+=dx;if(this.collidesWall(p,room))p.x-=dx;
|
|
||||||
p.y+=dy;if(this.collidesWall(p,room))p.y-=dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ATTACK ---
|
|
||||||
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']){const[fx,fy]=DIR[p.moveDir];ax=fx;ay=fy}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MELEE ---
|
|
||||||
if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;
|
|
||||||
if(this.meleeSwing){
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TEARS ---
|
|
||||||
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),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=8;t.life=0;break}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.tears=room.tears.filter(t=>t.alive);
|
|
||||||
|
|
||||||
// --- ENEMY AI ---
|
|
||||||
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,dy=p.y-e.y,d=Math.hypot(dx,dy);
|
|
||||||
if(d>0&&d<500){
|
|
||||||
const s=e.speed,mx=dx/d*s,my=dy/d*s;
|
|
||||||
e.x+=mx;if(this.collidesWall(e,room))e.x-=mx;
|
|
||||||
e.y+=my;if(this.collidesWall(e,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=60;e.atkTimer=30;
|
|
||||||
if(p.hp<=0){GAME_OVER=true;return}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ROOM CLEARED ---
|
|
||||||
if(room.enemies.length>0&&aliveCount===0&&!room.cleared){
|
|
||||||
room.cleared=true;room.buildTiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TRANSITION ---
|
|
||||||
this.checkTransition();
|
|
||||||
|
|
||||||
// --- WIN ---
|
|
||||||
if(!GAME_OVER){
|
|
||||||
const br=Object.values(this.map.rooms).find(r=>r.type==='boss');
|
|
||||||
if(br&&br.cleared)WON=true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// RENDER
|
|
||||||
// ============================================================
|
|
||||||
render(){
|
|
||||||
const ctx=this.ctx;
|
|
||||||
ctx.fillStyle='#0a0a0f';
|
|
||||||
ctx.fillRect(0,0,CW,CH);
|
|
||||||
this.drawRoom();
|
|
||||||
this.drawEntities();
|
|
||||||
this.drawHUD();
|
|
||||||
this.drawMinimap();
|
|
||||||
if(GAME_OVER)this.drawOverlay('#c33','GAME OVER');
|
|
||||||
else if(WON)this.drawOverlay('#3c3','VICTORY');
|
|
||||||
}
|
|
||||||
|
|
||||||
drawOverlay(c,t){
|
|
||||||
const ctx=this.ctx;
|
|
||||||
ctx.fillStyle='rgba(0,0,0,0.8)';ctx.fillRect(0,0,CW,CH);
|
|
||||||
ctx.fillStyle=c;ctx.font='bold 56px monospace';ctx.textAlign='center';ctx.fillText(t,CW/2,CH/2-20);
|
|
||||||
ctx.fillStyle='#888';ctx.font='18px monospace';ctx.fillText('[R] restart',CW/2,CH/2+40);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawRoom(){
|
|
||||||
const ctx=this.ctx,room=this.cur;
|
|
||||||
// floor
|
|
||||||
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
|
|
||||||
const x=OX+c*TILE,y=OY+r*TILE,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);
|
|
||||||
// brick lines
|
|
||||||
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 d=(r+c)%2===0;
|
|
||||||
ctx.fillStyle=d?'#2e2e24':'#353528';
|
|
||||||
ctx.fillRect(x,y,TILE,TILE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// wall overlay gradient at edges
|
|
||||||
const grad=ctx.createLinearGradient(OX,OY,OX+RW,OY);
|
|
||||||
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=2;
|
|
||||||
ctx.strokeRect(OX,OY,RW,RH);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawEntities(){
|
|
||||||
const ctx=this.ctx,room=this.cur,p=this.player;
|
|
||||||
|
|
||||||
// --- ENEMIES ---
|
|
||||||
for(const e of room.enemies){
|
|
||||||
if(!e.alive)continue;
|
|
||||||
const fl=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'){
|
|
||||||
ctx.fillStyle=fl?'#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();
|
|
||||||
// eyes
|
|
||||||
ctx.fillStyle=fl?'#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();
|
|
||||||
// horns
|
|
||||||
ctx.fillStyle=fl?'#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();
|
|
||||||
// HP
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}else if(e.type==='fast'){
|
|
||||||
ctx.fillStyle=fl?'#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();
|
|
||||||
}else{
|
|
||||||
ctx.fillStyle=fl?'#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);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PLAYER ---
|
|
||||||
ctx.save();
|
|
||||||
const ifl=p.invTimer>0&&p.invTimer%6<3;
|
|
||||||
const pCol=p.mode===MODE_RANGED?'#2a6a9a':'#9a3a2a';
|
|
||||||
ctx.fillStyle=ifl?'#ddd':pCol;
|
|
||||||
// body shape
|
|
||||||
ctx.fillRect(p.x-p.w/2,p.y-p.h/2,p.w,p.h);
|
|
||||||
ctx.fillStyle=ifl?'#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),wy=p.y+fy*(p.h/2+4);
|
|
||||||
|
|
||||||
if(p.mode===MODE_RANGED){
|
|
||||||
// PISTOL
|
|
||||||
ctx.strokeStyle=ifl?'#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=ifl?'#aaa':'#444';
|
|
||||||
const pw=14,ph=8;
|
|
||||||
ctx.save();
|
|
||||||
const rot=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.rect(-pw/2,-ph/2,pw,ph);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
// muzzle flash on attack
|
|
||||||
if(p.atkCD>8&&p.mode===MODE_RANGED){
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
// KNIFE
|
|
||||||
ctx.strokeStyle=ifl?'#bbb':'#ccc';
|
|
||||||
ctx.lineWidth=2;
|
|
||||||
// blade
|
|
||||||
ctx.beginPath();
|
|
||||||
const kx=wx+fx*6,ky=wy+fy*6;
|
|
||||||
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=ifl?'#ddd':'#d4d4d4';
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle='#999';ctx.stroke();
|
|
||||||
// handle
|
|
||||||
ctx.fillStyle=ifl?'#a99':'#5a3a1a';
|
|
||||||
ctx.fillRect(kx-fx*3-fy*3,ky-fy*3-fx*3,8,8);
|
|
||||||
// guard
|
|
||||||
ctx.fillStyle=ifl?'#bbb':'#888';
|
|
||||||
ctx.fillRect(kx-fx*2-fy*5,ky-fy*2-fx*5,5,12);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eyes
|
|
||||||
ctx.fillStyle='#fff';
|
|
||||||
const ex=p.x+fx*5,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();
|
|
||||||
|
|
||||||
// --- 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(this.meleeSwing&&this.meleeSwing.alive){
|
|
||||||
const s=this.meleeSwing,a=s.life/10;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha=a*0.35;
|
|
||||||
ctx.fillStyle='#cc8844';ctx.fillRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
|
||||||
ctx.globalAlpha=a;
|
|
||||||
ctx.strokeStyle='#ddbb88';ctx.lineWidth=2;ctx.strokeRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
|
||||||
ctx.globalAlpha=a*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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawHUD(){
|
|
||||||
const ctx=this.ctx,p=this.player;
|
|
||||||
|
|
||||||
// --- HP BAR ---
|
|
||||||
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);
|
|
||||||
const hpCol=hpRatio>0.5?'#993333':hpRatio>0.25?'#994422':'#663322';
|
|
||||||
ctx.fillStyle=hpCol;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);
|
|
||||||
|
|
||||||
// --- MODE INDICATOR ---
|
|
||||||
const my=CH-46;
|
|
||||||
ctx.textAlign='center';
|
|
||||||
const mText=p.mode===MODE_RANGED?'RANGED':'MELEE';
|
|
||||||
const mCol=p.mode===MODE_RANGED?'#4488cc':'#cc6644';
|
|
||||||
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] switch',CW/2,my-26);
|
|
||||||
|
|
||||||
// weapon icon in mode indicator
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ENEMY COUNT ---
|
|
||||||
ctx.textAlign='left';
|
|
||||||
const room=this.cur;
|
|
||||||
const alive=room.enemies.filter(e=>e.alive).length;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ROOM TYPE ---
|
|
||||||
if(room.visited){
|
|
||||||
ctx.textAlign='right';
|
|
||||||
const tn={spawn:'START',normal:'',treasure:'TREASURE',boss:'BOSS'}[room.type];
|
|
||||||
if(tn){
|
|
||||||
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText(tn,CW-20,OY+RH+30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawMinimap(){
|
|
||||||
const ctx=this.ctx;
|
|
||||||
const mx=CW-180,my=12,cell=14,gap=2,cs=cell+gap;
|
|
||||||
|
|
||||||
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=this.map.get(this.cc+c,this.cr+r);
|
|
||||||
if(!room)continue;
|
|
||||||
const x=mx+(c+3)*cs,y=my+(r+3)*cs;
|
|
||||||
let color='#141414';
|
|
||||||
if(room.visited){
|
|
||||||
const cl={spawn:'#2a5a2a',boss:'#5a1a1a',treasure:'#5a5a1a'}[room.type]||'#555';
|
|
||||||
color=cl;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// START
|
|
||||||
// ============================================================
|
|
||||||
window.addEventListener('load',()=>{game=new Game()});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run dev.ts",
|
"dev": "bun run dev.ts",
|
||||||
"build": "bun run build.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -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<Dir, readonly [number, number]> = {
|
||||||
|
up: [0, -1],
|
||||||
|
down: [0, 1],
|
||||||
|
left: [-1, 0],
|
||||||
|
right: [1, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Противоположное направление. Используется при простановке дверей соседей. */
|
||||||
|
export const OPP: Record<Dir, Dir> = {
|
||||||
|
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, // шанс назначить комнату боссом
|
||||||
|
};
|
||||||
@@ -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<string, [number, number]> = {
|
|
||||||
up: [0, -1],
|
|
||||||
down: [0, 1],
|
|
||||||
left: [-1, 0],
|
|
||||||
right: [1, 0],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Opposite direction lookup
|
|
||||||
export const OPP: Record<string, string> = {
|
|
||||||
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;
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
|
import { MELEE, DIR } from '../../config';
|
||||||
import type { Dir, Box } from '../types';
|
import type { Dir, Box } from '../types';
|
||||||
import { DIR } from '../constants';
|
|
||||||
|
|
||||||
|
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
|
||||||
export class MeleeSwing {
|
export class MeleeSwing {
|
||||||
dir: Dir;
|
readonly dir: Dir;
|
||||||
life = 10;
|
life = MELEE.life;
|
||||||
damage = 2;
|
readonly damage = MELEE.damage;
|
||||||
kb = 10;
|
readonly kb = MELEE.knockback;
|
||||||
box: Box;
|
readonly box: Box;
|
||||||
|
|
||||||
constructor(x: number, y: number, dir: Dir) {
|
constructor(x: number, y: number, dir: Dir) {
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
const d = 22;
|
const { reach: d, size: s } = MELEE;
|
||||||
const s = 50;
|
|
||||||
const [dx, dy] = DIR[dir];
|
const [dx, dy] = DIR[dir];
|
||||||
this.box = {
|
this.box = {
|
||||||
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
|
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(arr: readonly T[]): T {
|
||||||
|
return arr[this.int(0, arr.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перемешивание Фишера–Йейтса на месте. */
|
||||||
|
shuffle<T>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
/** Общие типы данных, на которые опирается вся игра. */
|
||||||
|
|
||||||
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
|
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
|
||||||
export type Dir = 'up' | 'down' | 'left' | 'right';
|
export type Dir = 'up' | 'down' | 'left' | 'right';
|
||||||
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
|
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
|
||||||
|
export type EnemyType = 'normal' | 'fast' | 'boss';
|
||||||
|
|
||||||
|
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
|
||||||
export interface Box {
|
export interface Box {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -9,6 +13,7 @@ export interface Box {
|
|||||||
h: number;
|
h: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Какие из четырёх дверей есть у комнаты. */
|
||||||
export interface Doors {
|
export interface Doors {
|
||||||
up: boolean;
|
up: boolean;
|
||||||
down: boolean;
|
down: boolean;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Room>();
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<EnemyType, { w: number; hp: number; speed: number; damage: number }> = {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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<string, string[]> = {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
|
||||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="game" width="880" height="660"></canvas>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Global keyboard state (shared mutable map)
|
|
||||||
export const KEYS: Record<string, boolean> = {};
|
|
||||||
|
|
||||||
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];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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',
|
||||||
|
]);
|
||||||
+68
-29
@@ -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
|
function boot(): void {
|
||||||
setupInput();
|
const world = document.getElementById('game') as HTMLCanvasElement | null;
|
||||||
|
const hudCanvas = document.getElementById('hud') as HTMLCanvasElement | null;
|
||||||
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
const menuEl = document.getElementById('menu');
|
||||||
const game = (window as any).__game as Game | undefined;
|
if (!world || !hudCanvas || !menuEl) {
|
||||||
if (!game) return;
|
document.body.innerHTML =
|
||||||
|
'<p style="color:#c33;font-family:monospace;padding:2rem">Ошибка: не найдены #game / #hud / #menu в разметке.</p>';
|
||||||
// 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 = '<p style="color:red">Error: canvas element not found</p>';
|
|
||||||
return;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
/** Fisher-Yates shuffle (mutates array in place) */
|
|
||||||
export function shuffle<T>(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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, THREE.MeshBasicMaterial>;
|
||||||
|
|
||||||
|
// Группа статичных тайлов текущей комнаты.
|
||||||
|
private roomGroup = new THREE.Group();
|
||||||
|
private renderedRoom: Room | null = null;
|
||||||
|
|
||||||
|
// Динамические меши с персональными материалами.
|
||||||
|
private readonly playerMesh: THREE.Mesh;
|
||||||
|
private readonly enemyMeshes = new Map<Enemy, THREE.Mesh>();
|
||||||
|
private readonly tearMeshes = new Map<Projectile, THREE.Mesh>();
|
||||||
|
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<Enemy>();
|
||||||
|
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<Projectile>();
|
||||||
|
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<K>(map: Map<K, THREE.Mesh>, live: Set<K>): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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<string, string> = { 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, Room> = 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Dir, [number, number]> = {
|
||||||
|
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<string>([`${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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-5
@@ -3,15 +3,15 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitOverride": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"noEmit": true,
|
||||||
"rootDir": "src",
|
"forceConsistentCasingInFileNames": true
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user