refactor: рабочая база на three.js + меню/правила уровней + доки

Полный рефакторинг проекта в стабильную расширяемую базу рогалика.

Почему: после разнесения single-file на модули потерялся вызов генерации
карты (RoomMap не генерировал комнаты) → игра падала на старте; баг скрывался
тем, что Bun-бандлер не проверяет типы.

Архитектура:
- Логика игры (src/core/) полностью отделена от рендера: без three.js и DOM,
  тестируется без браузера.
- Рендер мира на three.js с ортокамерой (2D-вид); HUD/миникарта — 2D-канвас поверх.
- Фиксированный игровой цикл 60 Гц + интерполяция (раньше скорость зависела
  от частоты кадров).
- Seeded-RNG, ввод через абстрактные «намерения» (InputState).

Возможности:
- Стартовое меню с выбором уровня (Esc → меню).
- Конфигуратор уровней: LevelRules + 5 пресетов (размер карты, плотность/сила
  врагов, HP, фиксированный seed).
- Тема внешнего вида (render/theme.ts) — задел под кастомные ассеты.

Качество:
- 27 юнит-тестов ядра (генерация, симметрия дверей, коллизии, спавн, правила).
- Два круга adversarial-ревью; исправлено 6 реальных багов
  (кнокбэк сквозь стены → софт-лок; незакрываемая сокровищница; фикс-сид после
  рестарта; перенос ввода между забегами; нет source maps; неточности в доках).
- Документация: README, docs/ARCHITECTURE.md, CLAUDE.md, docs/HOWTO.md.
- dist/ исключён из гита; bun.lock зафиксирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 14:07:06 +03:00
co-authored by Claude Opus 4.8
parent 46dc9f2fad
commit 0684368ac7
60 changed files with 2759 additions and 3364 deletions
+142
View File
@@ -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, // шанс назначить комнату боссом
};
-48
View File
@@ -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;
+326
View File
@@ -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;
}
}
}
}
+50
View File
@@ -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 { DIR } from '../constants';
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
export class MeleeSwing {
dir: Dir;
life = 10;
damage = 2;
kb = 10;
box: Box;
readonly dir: Dir;
life = MELEE.life;
readonly damage = MELEE.damage;
readonly kb = MELEE.knockback;
readonly box: Box;
constructor(x: number, y: number, dir: Dir) {
this.dir = dir;
const d = 22;
const s = 50;
const { reach: d, size: s } = MELEE;
const [dx, dy] = DIR[dir];
this.box = {
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
+43
View File
@@ -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;
}
}
+26
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
}
+94
View File
@@ -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 },
},
];
+33
View File
@@ -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;
}
+64
View File
@@ -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;
}
+5
View File
@@ -1,7 +1,11 @@
/** Общие типы данных, на которые опирается вся игра. */
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
export type Dir = 'up' | 'down' | 'left' | 'right';
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
export type EnemyType = 'normal' | 'fast' | 'boss';
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
export interface Box {
x: number;
y: number;
@@ -9,6 +13,7 @@ export interface Box {
h: number;
}
/** Какие из четырёх дверей есть у комнаты. */
export interface Doors {
up: boolean;
down: boolean;
+28
View File
@@ -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;
}
+34
View File
@@ -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);
}
}
+103
View File
@@ -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';
}
}
}
}
+27
View File
@@ -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;
}
-15
View File
@@ -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];
}
+61
View File
@@ -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);
};
}
-54
View File
@@ -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';
}
-22
View File
@@ -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 };
}
}
-21
View File
@@ -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;
}
}
-334
View File
@@ -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);
}
}
-33
View File
@@ -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
-72
View File
@@ -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;
}
}
}
-17
View File
@@ -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>
-18
View File
@@ -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];
});
}
+42
View File
@@ -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';
}
}
+93
View File
@@ -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
View File
@@ -1,32 +1,71 @@
import { setupInput, KEYS } from './input';
import { Game } from './game/Game';
/**
* main.ts — точка входа и «склейка». Поток:
* стартовое меню → выбор уровня (правил) → создаём Game с этими правилами →
* запускаем цикл. Esc во время игры — назад в меню.
*
* Это единственное место, где встречаются логика, рендер и ввод — поэтому
* именно здесь проще всего подменить рендер/ввод или добавить экраны.
*/
import { Game } from './core/Game';
import { PRESETS, type LevelRules } from './core/rules';
import { KeyboardController } from './input/KeyboardController';
import { ThreeRenderer } from './render/ThreeRenderer';
import { HudOverlay } from './render/HudOverlay';
import { GameLoop } from './engine/GameLoop';
import { StartMenu } from './ui/StartMenu';
// Global key bindings (mode toggle, restart) handled here
setupInput();
window.addEventListener('keydown', (e: KeyboardEvent) => {
const game = (window as any).__game as Game | undefined;
if (!game) return;
// Toggle combat mode
if ((e.key === 'Tab' || e.key === 'q' || e.key === 'Q') && !game.gameOver && !game.won) {
e.preventDefault();
game.toggleMode();
}
// Restart
if (e.key === 'r' || e.key === 'R') {
if (game.gameOver || game.won) game.restart();
}
});
// Bootstrap
window.addEventListener('load', () => {
const canvas = document.getElementById('game') as HTMLCanvasElement;
if (!canvas) {
document.body.innerHTML = '<p style="color:red">Error: canvas element not found</p>';
function boot(): void {
const world = document.getElementById('game') as HTMLCanvasElement | null;
const hudCanvas = document.getElementById('hud') as HTMLCanvasElement | null;
const menuEl = document.getElementById('menu');
if (!world || !hudCanvas || !menuEl) {
document.body.innerHTML =
'<p style="color:#c33;font-family:monospace;padding:2rem">Ошибка: не найдены #game / #hud / #menu в разметке.</p>';
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
View File
@@ -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;
}
+133
View File
@@ -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);
}
}
+21
View File
@@ -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;
}
+230
View File
@@ -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();
}
}
-270
View File
@@ -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();
}
-100
View File
@@ -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);
}
}
-59
View File
@@ -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);
}
}
}
}
-50
View File
@@ -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);
}
+48
View File
@@ -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,
};
-28
View File
@@ -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);
}
}
-89
View File
@@ -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';
}
}
}
}
-25
View File
@@ -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;
}
+45
View File
@@ -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';
}
}