fertig-classic-games/tools/wolfensteinMap.js

92 lines
4.0 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).
//
// Patrol routes can't be expressed as single ASCII characters (a route is a
// variable-length list of waypoints, not a single cell), so they're passed
// separately via `opts.patrols` instead: `{ 0: [[x,y], [x,y]], ... }`, keyed
// by each guard's 0-based index in reading order (top-to-bottom,
// left-to-right scan of the 'E' characters — the same order `enemies` gets
// built in below). Coordinates are cell coords, auto-offset to the cell
// center exactly like every other symbol here. Matches WolfensteinLogic's
// `patrol` field (an idle guard ping-pongs home -> patrol[0] -> ... -> home,
// see stepPatrol) and the editor's Patrol tool — a level authored here and
// one with a patrol route drawn in the editor are indistinguishable to
// WolfensteinLogic either way.
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)`);
for (const [idx, nodes] of Object.entries(opts.patrols ?? {})) {
const enemy = enemies[Number(idx)];
if (!enemy) throw new Error(`level ${id}: patrols[${idx}] has no matching E (only ${enemies.length} enemies)`);
enemy.patrol = nodes.map(([nx, ny]) => ({ x: nx + 0.5, y: ny + 0.5 }));
}
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;
}