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