Files
Binding-Fignyaac/src/core/world/RoomMap.ts
T
mayatnikovandClaude Opus 4.8 0684368ac7 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>
2026-06-18 14:07:06 +03:00

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';
}
}
}
}