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
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'bun:test';
import { isBlocked, collidesWall } from '../src/core/systems/collision';
import { Room } from '../src/core/world/Room';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function cleanRoom(): Room {
const room = new Room(0, 0, 'normal');
room.doors = { up: true, down: false, left: false, right: false };
room.cleared = true;
room.rebuildTiles();
return room;
}
describe('collision', () => {
it('стены по краям блокируют движение', () => {
const room = cleanRoom();
expect(isBlocked(room, 0, 0)).toBe(true);
expect(isBlocked(room, COLS - 1, ROWS - 1)).toBe(true);
});
it('внутренний пол не блокирует', () => {
const room = cleanRoom();
expect(isBlocked(room, 5, 5)).toBe(false);
});
it('за пределами комнаты — блок, но дверной проём открыт', () => {
const room = cleanRoom();
// Над комнатой (row < 0) обычно стена...
expect(isBlocked(room, 0, -1)).toBe(true);
// ...но в колонках двери up проём открыт.
expect(isBlocked(room, 7, -1)).toBe(false);
});
it('collidesWall ловит хитбокс на стене и пропускает на полу', () => {
const room = cleanRoom();
const insideFloor = { x: OX + 5 * TILE, y: OY + 5 * TILE, w: 26, h: 26 };
expect(collidesWall(insideFloor, room)).toBe(false);
const onWall = { x: OX - 5, y: OY - 5, w: 26, h: 26 };
expect(collidesWall(onWall, room)).toBe(true);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { collidesWall } from '../src/core/systems/collision';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
import { MODE_RANGED, MODE_MELEE, OX, OY, TILE, COLS, RW } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
describe('Game', () => {
it('РЕГРЕССИЯ: конструируется без ошибок и игрок появляется не в стене', () => {
const game = new Game(DEFAULT_RULES, new Rng(1));
expect(game.curRoom).toBeDefined();
expect(game.curRoom.type).toBe('spawn');
expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
});
it('600 пустых шагов не падают, игрок не застревает в стене, HP цело на спавне', () => {
const game = new Game(DEFAULT_RULES, new Rng(2));
const hp0 = game.player.hp;
for (let i = 0; i < 600; i++) game.step(input());
expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
expect(game.player.hp).toBe(hp0); // на спавне врагов нет
expect(game.gameOver).toBe(false);
});
it('смена оружия по toggleWeapon', () => {
const game = new Game(DEFAULT_RULES, new Rng(3));
expect(game.player.mode).toBe(MODE_RANGED);
game.consumeActions(input({ toggleWeapon: true }));
expect(game.player.mode).toBe(MODE_MELEE);
game.consumeActions(input({ toggleWeapon: true }));
expect(game.player.mode).toBe(MODE_RANGED);
});
it('стрельба создаёт снаряд, который потом исчезает', () => {
const game = new Game(DEFAULT_RULES, new Rng(4));
game.step(input({ aimDir: 'right' }));
expect(game.curRoom.tears.length).toBe(1);
// Снаряд летит вправо и со временем гаснет (стена/время жизни).
for (let i = 0; i < 200; i++) game.step(input());
expect(game.curRoom.tears.length).toBe(0);
});
it('РЕГРЕССИЯ: комната без врагов (сокровищница) зачищается при входе — иначе двери не открыть', () => {
// Ищем seed, где в карте есть сокровищница.
let tested = false;
for (let seed = 1; seed <= 200 && !tested; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'treasure') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
expect(room.enemies.length).toBe(0);
expect(room.cleared).toBe(true); // иначе игрок застрянет без дверей
tested = true;
break;
}
}
expect(tested).toBe(true); // среди 200 seed сокровищница точно нашлась
});
it('РЕГРЕССИЯ: кнокбэк не выбрасывает врага сквозь стену (нет софт-лока)', () => {
const game = new Game(DEFAULT_RULES, new Rng(7));
// Переносим игру в любую боевую (normal) комнату.
let placed = false;
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
placed = true;
break;
}
expect(placed).toBe(true);
const room = game.curRoom;
// Один контролируемый враг у правой стены, кнокбэк направлен В стену.
const e = new Enemy(OX + (COLS - 2) * TILE + TILE / 2, OY + 5 * TILE + TILE / 2, 'normal');
e.knx = 50; // заведомо больше толщины стены (1 тайл)
e.kny = 0;
room.enemies = [e];
for (let i = 0; i < 40; i++) game.step(emptyInput());
expect(collidesWall(e.box, room)).toBe(false); // остался внутри и достижим
expect(e.x).toBeLessThan(OX + RW); // не вылетел за правую стену
});
it('правила уровня влияют на забег: HP игрока и размер карты', () => {
const hardcore = PRESETS.find((p) => p.id === 'hardcore')!;
const big = PRESETS.find((p) => p.id === 'big')!;
const gHard = new Game(hardcore, new Rng(11));
expect(gHard.player.maxHp).toBe(hardcore.player.maxHp); // меньше базовых 6
// «Большой данжен» в среднем даёт больше комнат, чем «Стандарт».
const avg = (rules: typeof big) => {
let sum = 0;
for (let s = 1; s <= 20; s++) sum += new Game(rules, new Rng(s)).roomMap.rooms.size;
return sum / 20;
};
expect(avg(big)).toBeGreaterThan(avg(DEFAULT_RULES));
});
it('фикс-сид даёт одинаковую карту каждый раз — в т.ч. после reset()', () => {
const daily = PRESETS.find((p) => p.id === 'daily')!;
const a = new Game(daily);
const keys = [...a.roomMap.rooms.keys()].sort();
// другой экземпляр — та же карта
const b = new Game(daily);
expect([...b.roomMap.rooms.keys()].sort()).toEqual(keys);
// РЕГРЕССИЯ: после рестарта по [R] фикс-сид воспроизводит тот же данжен
a.gameOver = true;
a.reset();
expect([...a.roomMap.rooms.keys()].sort()).toEqual(keys);
});
it('reset() возвращает чистое состояние', () => {
const game = new Game(DEFAULT_RULES, new Rng(5));
game.player.hp = 1;
game.gameOver = true;
game.reset();
expect(game.gameOver).toBe(false);
expect(game.player.hp).toBe(game.player.maxHp);
expect(game.cc).toBe(0);
expect(game.cr).toBe(0);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'bun:test';
import { Rng } from '../src/core/rng';
describe('Rng', () => {
it('детерминирован: одинаковый seed → одинаковая последовательность', () => {
const a = new Rng(12345);
const b = new Rng(12345);
const seqA = Array.from({ length: 10 }, () => a.next());
const seqB = Array.from({ length: 10 }, () => b.next());
expect(seqA).toEqual(seqB);
});
it('разные seed дают разные последовательности', () => {
const a = new Rng(1);
const b = new Rng(2);
expect(a.next()).not.toBe(b.next());
});
it('next() всегда в [0, 1)', () => {
const r = new Rng(7);
for (let i = 0; i < 1000; i++) {
const v = r.next();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('int(a, b) держится в границах включительно', () => {
const r = new Rng(99);
for (let i = 0; i < 1000; i++) {
const v = r.int(3, 7);
expect(v).toBeGreaterThanOrEqual(3);
expect(v).toBeLessThanOrEqual(7);
expect(Number.isInteger(v)).toBe(true);
}
});
it('shuffle сохраняет все элементы', () => {
const r = new Rng(42);
const arr = [1, 2, 3, 4, 5];
const shuffled = r.shuffle([...arr]);
expect([...shuffled].sort()).toEqual(arr);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'bun:test';
import { RoomMap } from '../src/core/world/RoomMap';
import { Rng } from '../src/core/rng';
import { OPP } from '../src/config';
import type { Dir } from '../src/core/types';
const NEIGHBOR: Record<Dir, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
describe('RoomMap', () => {
// Перебираем много seed: генерация случайная, баги могут прятаться в редких раскладах.
const maps = Array.from({ length: 50 }, (_, i) => new RoomMap(new Rng(i + 1)));
it('РЕГРЕССИЯ: карта не пустая (генерация реально вызвана)', () => {
// Именно тут раньше всё падало: конструктор не звал generate() и карта была пустой.
for (const m of maps) {
expect(m.rooms.size).toBeGreaterThan(1);
}
});
it('всегда есть спавн в (0,0)', () => {
for (const m of maps) {
expect(m.get(0, 0)?.type).toBe('spawn');
}
});
it('всегда есть ровно одна (минимум одна) комната-босс', () => {
for (const m of maps) {
const bosses = [...m.rooms.values()].filter((r) => r.type === 'boss');
expect(bosses.length).toBeGreaterThanOrEqual(1);
}
});
it('двери симметричны: дверь A→B всегда имеет встречную B→A', () => {
for (const m of maps) {
for (const room of m.rooms.values()) {
for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
if (!room.doors[dir]) continue;
const [dc, dr] = NEIGHBOR[dir];
const neighbor = m.get(room.c + dc, room.r + dr);
expect(neighbor).toBeDefined();
expect(neighbor!.doors[OPP[dir]]).toBe(true);
}
}
}
});
it('карта связна: все комнаты достижимы из спавна по дверям', () => {
for (const m of maps) {
const start = m.get(0, 0)!;
const seen = new Set<string>([`${start.c},${start.r}`]);
const queue = [start];
while (queue.length) {
const room = queue.shift()!;
for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
if (!room.doors[dir]) continue;
const [dc, dr] = NEIGHBOR[dir];
const n = m.get(room.c + dc, room.r + dr);
if (n && !seen.has(`${n.c},${n.r}`)) {
seen.add(`${n.c},${n.r}`);
queue.push(n);
}
}
}
expect(seen.size).toBe(m.rooms.size);
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'bun:test';
import { spawnEnemies } from '../src/core/systems/spawner';
import { Room } from '../src/core/world/Room';
import { Rng } from '../src/core/rng';
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
import { dist } from '../src/core/util';
describe('spawner', () => {
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
const room = new Room(1, 0, 'normal');
for (let s = 0; s < 30; s++) {
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(s + 1));
expect(enemies.length).toBeGreaterThanOrEqual(SPAWN.normalMin);
expect(enemies.length).toBeLessThanOrEqual(SPAWN.normalMin + SPAWN.normalExtra);
}
});
it('в комнате-босс ровно один враг типа boss', () => {
const room = new Room(1, 0, 'boss');
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
expect(enemies.length).toBe(1);
expect(enemies[0].type).toBe('boss');
});
it('в сокровищнице врагов нет', () => {
const room = new Room(1, 0, 'treasure');
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
expect(enemies.length).toBe(0);
});
it('враги не спавнятся вплотную к двери входа', () => {
const room = new Room(1, 0, 'normal');
const door = DOOR.left;
const doorX = OX + door.cx * TILE + TILE / 2;
const doorY = OY + door.cy * TILE + TILE / 2;
for (let s = 0; s < 20; s++) {
const enemies = spawnEnemies(room, 'left', OX + 7 * TILE, OY + 5 * TILE, new Rng(s + 100));
for (const e of enemies) {
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
}
}
});
});