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