Полный рефакторинг проекта в стабильную расширяемую базу рогалика. Почему: после разнесения 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>
104 lines
3.2 KiB
TypeScript
104 lines
3.2 KiB
TypeScript
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';
|
|
}
|
|
}
|
|
}
|
|
}
|