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
+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);
});
});