fertig-classic-games/tools/wolfensteinMap.js

74 lines
2.9 KiB
JavaScript

// ASCII-map parser for Wolfenstein levels — the tools/bloxorzMap.js role.
// Produces the exact level JSON schema the editor loads/saves/exports and
// the runtime loader consumes, so a level authored here and one exported
// from the in-browser editor are indistinguishable to WolfensteinLogic.
//
// Legend:
// # wall, type 1 (stone) 2 3 4 wall, type 2/3/4 (palette variety)
// . floor D door (closed by default)
// S player start (facing east / 0°) X level exit
// E guard enemy spawn (facing west)
// A ammo pickup H health pickup
//
// Every row must be the same length; the outer ring must be entirely wall/door
// (auditLevel below hard-rejects an open boundary or an unreachable exit —
// the same BFS-solvability gate tools/genBloxorz.js uses).
import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js';
const WALL_CHARS = { '#': 1, 2: 2, 3: 3, 4: 4 };
export function parseMap(id, name, art, opts = {}) {
const lines = art.split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l.length > 0);
const height = lines.length;
const width = lines[0]?.length ?? 0;
for (const l of lines) {
if (l.length !== width) throw new Error(`level ${id}: inconsistent row width (expected ${width}, got ${l.length}: "${l}")`);
}
const walls = [];
const doors = [];
const enemies = [];
const items = [];
let playerStart = null;
let exit = null;
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const ch = lines[y][x];
if (WALL_CHARS[ch]) { row.push(WALL_CHARS[ch]); continue; }
row.push(0);
if (ch === 'D') doors.push({ x, y, orientation: 'vertical' });
else if (ch === 'S') playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
else if (ch === 'X') exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
else if (ch === 'E') enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
else if (ch === 'A') items.push({ type: 'ammo', x: x + 0.5, y: y + 0.5 });
else if (ch === 'H') items.push({ type: 'health', x: x + 0.5, y: y + 0.5 });
// '.' and anything else falls through as plain floor.
}
walls.push(row);
}
if (!playerStart) throw new Error(`level ${id}: missing S (player start)`);
if (!exit) throw new Error(`level ${id}: missing X (exit)`);
return {
version: 1, id, name,
campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0,
width, height, cellSize: 64,
walls, playerStart, doors, enemies, items, exit,
briefing: opts.briefing ?? [],
};
}
/** Hard-rejects an invalid or unsolvable level — the build-time gate. */
export function auditLevel(level) {
const model = buildLevelModel(level);
const result = validateLevel(model);
if (!result.valid) {
throw new Error(`level ${level.id} failed validation:\n ${result.issues.join('\n ')}`);
}
return result;
}