// Wolfenstein level editor. Secret entrance: index.html?wolfenstein-editor=1 // (see PreloadScene). Mirrors the Super Kart / Goo Tower editor workflow: // load (existing level from assets/gamedata/wolfenstein/ or any local .json) // → edit → Test Play → ⬇ Export Level, then hand-drop the file into // assets/gamedata/wolfenstein/. Validation reuses buildLevelModel/validateLevel // from WolfensteinLogic.js verbatim — the same functions the runtime loader // and tools/genWolfenstein.js use — so the editor can never disagree with the // game about what a legal level is. // // Grids up to MAX_GRID cells per side are supported (levels stay a plain // dense JSON `walls` array — no chunking/streaming, so there's a hard bound, // not literal infinity; MAX_GRID was picked so the reachability BFS validateLevel // runs stays under ~250ms worst case). The BOARD is a fixed-size viewport // (BOARD_SIZE px) onto that grid — not "whole grid squeezed into the box" // like it used to be — so editing pans/zooms (right-drag / WASD-arrows / wheel // / +-) instead of shrinking cells to fit. The MAP OVERVIEW minimap gives a // whole-level view for navigation and click-to-jump. import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { buildLevelModel, validateLevel } from './WolfensteinLogic.js'; import { queueGameAssets } from '../../services/assetLoader.js'; const BOARD_X = 70; const BOARD_Y = 70; const BOARD_SIZE = 900; const GAMEDATA = 'assets/gamedata/wolfenstein'; const FONT = '"Julius Sans One"'; // Screen pixels per cell. MIN_ZOOM caps how far "Fit View" zooms out on a // huge grid — below that, cells get too small to click reliably, so past // that point the main board shows a scrollable window instead of everything // at once (use the minimap to navigate). MAX_ZOOM is for fine detail work. const MIN_ZOOM = 4; const MAX_ZOOM = 160; const MAX_GRID = 1000; const MINIMAP_SIZE = 300; const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 }; const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a }; const MINIMAP_WALL_RGB = Object.fromEntries( Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]), ); // One radio row per category; a category with `options` gets a dropdown // next to its radio (even a category with only one option today, per // Door/Enemy — more sub-types land there later), a category with `options: // null` (just Erase) gets its label alone. The tool id actually used by // applyTool()/WALL_TOOLS is either the selected option's id, or — for a // dropdown-less category — the category's own id. const CATEGORIES = [ { id: 'wall', label: 'Wall', options: [ { id: 'wall1', label: 'Stone' }, { id: 'wall2', label: 'Wood' }, { id: 'wall3', label: 'Blue' }, { id: 'wall4', label: 'Green' }, ] }, { id: 'door', label: 'Door', options: [ { id: 'door', label: 'Normal' }, ] }, { id: 'pickup', label: 'Pickup', options: [ { id: 'health', label: 'Health' }, { id: 'ammo', label: 'Ammo' }, ] }, { id: 'enemy', label: 'Enemy', options: [ { id: 'enemy', label: 'Guard' }, ] }, // Not a placement tool — a mode. Click an enemy to select it (see // applyPatrolTool), then click floor tiles to append/remove waypoints on // its route. No sub-types, so no dropdown, same as Erase. { id: 'patrol', label: 'Patrol', options: null }, { id: 'zone', label: 'Zone', options: [ { id: 'start', label: 'Player Start' }, { id: 'exit', label: 'Exit' }, ] }, { id: 'erase', label: 'Erase', options: null }, ]; export default class WolfensteinEditor extends Phaser.Scene { constructor() { super('WolfensteinEditor'); } init(data) { this.resume = !!data?.resume; } preload() { // The editor boots straight out of PreloadScene, so the lazy game data // (wolfenstein-rules/campaigns JSON, sprite art — see // data/assetManifest.js) that GameRoomScene would normally fetch on // first entry isn't loaded yet. Test Play starts WolfensteinGame // directly, bypassing that fetch too, so queue it here instead — same // fix as ZumaEditor.js's preload(). queueGameAssets(this, 'wolfenstein'); } create() { this.level = this.resume ? (this.registry.get('wolfenstein-editor-state') ?? this.defaultLevel()) : this.defaultLevel(); this.tool = 'wall1'; this.undoStack = []; this.result = { valid: false, issues: [], reachable: false }; this.panState = null; this._validateTimer = null; this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018); this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000) .setStrokeStyle(2, COLORS.accent); this.g = this.add.graphics().setDepth(5); // The board is now a fixed-size window onto a possibly much larger grid // (panned/zoomed independently) — clip strictly to the board rect so a // fractional pan offset never bleeds a partial cell past its border. const maskShape = this.make.graphics({ x: 0, y: 0, add: false }); maskShape.fillRect(BOARD_X, BOARD_Y, BOARD_SIZE, BOARD_SIZE); this.g.setMask(maskShape.createGeometryMask()); this.manifestLevels = []; fetch('data/wolfenstein-campaigns.json').then((r) => r.json()) .then((d) => { this.manifestLevels = (d.campaigns ?? []).flatMap((c) => c.missions.map((m) => ({ file: m.levelFile, label: `${c.id}/${m.id}` }))); this.refreshLoadOptions(); }).catch(() => {}); this.buildToolbar(); this.buildLoadPanel(); this.buildMinimap(); this.bindPointer(); this.bindKeys(); this.fitToGrid(); this.rebuildModel(); } defaultLevel() { const W = 12, H = 10; const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => ( x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0 ))); return { version: 1, id: 'level-new', name: 'New Level', campaignId: null, missionIndex: 0, width: W, height: H, cellSize: 64, walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, doors: [], enemies: [], items: [], exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 }, briefing: [], }; } // ── Right-panel layout shared by the load panel and the minimap ──────────── get rpX() { return BOARD_X + BOARD_SIZE + 90 + 260 + 40; } get rpW() { return 420; } // ── Coordinate transforms ──────────────────────────────────────────────── // (viewX, viewY) is the world cell shown at the board's top-left corner; // `zoom` is screen pixels per cell. Both are independent of grid size. toBoard(cx, cy) { const k = this.zoom; return [BOARD_X + (cx - this.viewX) * k, BOARD_Y + (cy - this.viewY) * k]; } toCell(px, py) { const k = this.zoom; return [Math.floor(this.viewX + (px - BOARD_X) / k), Math.floor(this.viewY + (py - BOARD_Y) / k)]; } onBoard(p) { return p.x >= BOARD_X && p.x <= BOARD_X + BOARD_SIZE && p.y >= BOARD_Y && p.y <= BOARD_Y + BOARD_SIZE; } /** [x0, y0, x1, y1) cell range currently on screen, clamped to the grid. */ visibleCellRange() { const x0 = Math.max(0, Math.floor(this.viewX)); const y0 = Math.max(0, Math.floor(this.viewY)); const x1 = Math.min(this.level.width, Math.ceil(this.viewX + BOARD_SIZE / this.zoom) + 1); const y1 = Math.min(this.level.height, Math.ceil(this.viewY + BOARD_SIZE / this.zoom) + 1); return [x0, y0, x1, y1]; } /** Reset pan/zoom to show the whole grid (or as much as MIN_ZOOM allows). */ fitToGrid() { this.zoom = Phaser.Math.Clamp(Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height), MIN_ZOOM, MAX_ZOOM); this.viewX = 0; this.viewY = 0; this.draw(); } setView(vx, vy) { const visW = BOARD_SIZE / this.zoom, visH = BOARD_SIZE / this.zoom; this.viewX = Phaser.Math.Clamp(vx, -visW * 0.5, Math.max(-visW * 0.5, this.level.width - visW * 0.5)); this.viewY = Phaser.Math.Clamp(vy, -visH * 0.5, Math.max(-visH * 0.5, this.level.height - visH * 0.5)); this.draw(); } zoomAt(anchorX, anchorY, factor) { const worldX = this.viewX + (anchorX - BOARD_X) / this.zoom; const worldY = this.viewY + (anchorY - BOARD_Y) / this.zoom; this.zoom = Phaser.Math.Clamp(this.zoom * factor, MIN_ZOOM, MAX_ZOOM); this.setView(worldX - (anchorX - BOARD_X) / this.zoom, worldY - (anchorY - BOARD_Y) / this.zoom); } // ── Toolbar ─────────────────────────────────────────────────────────────── buildToolbar() { const tx = BOARD_X + BOARD_SIZE + 90; const bw = 260; this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5); const panelBottom = this.buildToolPanel(tx, bw); let y = panelBottom + 30; this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.level.name}`, () => this.renameLevel(), { width: bw, height: 48, fontSize: 16, variant: 'ghost' }); y += 58; new Button(this, tx + bw / 2, y, 'Resize Grid', () => this.resizeLevel(), { width: bw, height: 48, fontSize: 18 }); y += 58; new Button(this, tx + bw / 2, y, 'Fit View', () => this.fitToGrid(), { width: bw, height: 48, fontSize: 18 }); y += 58; new Button(this, tx + bw / 2, y, 'New Blank Level', () => this.newLevel(), { width: bw, height: 48, fontSize: 18 }); y += 66; this.issueText = this.add.text(tx, y, '', { fontFamily: FONT, fontSize: '16px', color: COLORS.dangerHex, wordWrap: { width: bw + 40 }, }); y += 100; new Button(this, tx + bw / 2, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 20 }); y += 66; new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 }); } /** Which category/option a tool id belongs to — drives both the initial radio-checked/option-selected HTML and (indirectly) which id gets applied when a row's dropdown or dropdown-less radio fires. */ findCategoryFor(toolId) { for (const cat of CATEGORIES) { if (!cat.options) { if (cat.id === toolId) return [cat, null]; continue; } if (cat.options.some((o) => o.id === toolId)) return [cat, toolId]; } return [CATEGORIES[0], CATEGORIES[0].options[0].id]; } /** * One radio button per category (mutually exclusive via a shared `name`, * so this is a native HTML radio group — not hand-rolled), a dropdown next * to any category with `options` for its sub-type. Built as a DOM element * (like the Load Level panel below it) rather than Phaser widgets — native * radios/selects are simpler and more robust than reimplementing them in * Graphics. Returns the panel's bottom y so buildToolbar() can lay out the * rest of the column beneath it without hardcoding its (CSS-driven) height. */ buildToolPanel(tx, bw) { const rowH = 34, rowGap = 8, pad = 12, border = 2; const top = 70; const panelH = border * 2 + pad * 2 + CATEGORIES.length * rowH + (CATEGORIES.length - 1) * rowGap; const [activeCat, activeOpt] = this.findCategoryFor(this.tool); const inputCss = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:4px; padding:3px 4px; font-size:13px;'; const rows = CATEGORIES.map((cat, i) => { const checked = cat.id === activeCat.id ? 'checked' : ''; const select = cat.options ? `` : ''; const marginBottom = i === CATEGORIES.length - 1 ? 0 : rowGap; return `
${select}
`; }).join(''); const el = document.createElement('div'); el.style.cssText = `width:${bw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex}; font-size:15px;`; el.innerHTML = `
${rows}
`; this.toolPanelDom = this.add.dom(tx + bw / 2, top + panelH / 2, el); el.querySelectorAll('input[name="wf-tool"]').forEach((radio) => { radio.addEventListener('change', () => { const cat = CATEGORIES.find((c) => c.id === radio.value); const select = el.querySelector(`select[data-cat="${radio.value}"]`); this.tool = select ? select.value : cat.id; this.draw(); // patrol-route overlay only shows while the Patrol tool is active }); }); el.querySelectorAll('select[data-cat]').forEach((select) => { select.addEventListener('change', () => { el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true; this.tool = select.value; this.draw(); }); }); return top + panelH; } renameLevel() { const next = window.prompt('Level name:', this.level.name); if (next) { this.pushUndo(); this.level.name = next; this.metaName.setLabel(`Name: ${next}`); this.rebuildModel(); } } resizeLevel() { const wStr = window.prompt(`Grid width (cells, up to ${MAX_GRID}):`, String(this.level.width)); if (!wStr) return; const hStr = window.prompt(`Grid height (cells, up to ${MAX_GRID}):`, String(this.level.height)); if (!hStr) return; const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, MAX_GRID); const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID); this.pushUndo(); const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => { const border = x === 0 || y === 0 || x === w - 1 || y === h - 1; const old = this.level.walls[y]?.[x]; return border ? 1 : (old ?? 0); })); this.level = { ...this.level, width: w, height: h, walls, doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1), enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1), items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1), playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null, exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null, }; this.fitToGrid(); this.rebuildModel(); } newLevel() { this.pushUndo(); this.level = this.defaultLevel(); this.selectedEnemy = null; this.metaName.setLabel(`Name: ${this.level.name}`); this.fitToGrid(); this.rebuildModel(); } // ── Load panel (DOM) ──────────────────────────────────────────────────── buildLoadPanel() { const rx = this.rpX; const rw = this.rpW; const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;'; const el = document.createElement('div'); el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`; el.innerHTML = `
LOAD LEVEL
`; this.loadDom = this.add.dom(rx + rw / 2, 200, el).setDepth(20); const q = (id) => el.querySelector(id); this.loadSelect = q('#wf-load'); this.loadWarn = q('#wf-load-warn'); this.loadSelect.addEventListener('change', (ev) => { const file = ev.target.value; if (file) this.loadFromManifest(file); ev.target.value = ''; }); q('#wf-fileinput').addEventListener('change', (ev) => { const f = ev.target.files?.[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { this.loadLevelData(JSON.parse(reader.result), f.name); } catch (_) { this.flashLoadWarn(`Could not parse ${f.name}`); } }; reader.readAsText(f); ev.target.value = ''; }); } refreshLoadOptions() { if (!this.loadSelect) return; const opts = [``, ...this.manifestLevels.map((m) => ``)].join(''); this.loadSelect.innerHTML = opts; } flashLoadWarn(msg) { if (!this.loadWarn) return; this.loadWarn.textContent = msg; this.time.delayedCall(4000, () => { if (this.loadWarn) this.loadWarn.textContent = ''; }); } async loadFromManifest(file) { try { const res = await fetch(`${GAMEDATA}/${file}`); if (!res.ok) throw new Error('not found'); this.loadLevelData(await res.json(), file); } catch (_) { this.flashLoadWarn(`Could not load ${file}`); } } loadLevelData(data, fileName) { if (!data || !Array.isArray(data.walls) || !data.width || !data.height) { this.flashLoadWarn(`${fileName}: not a valid level file`); return; } this.pushUndo(); const clone = JSON.parse(JSON.stringify(data)); this.level = { ...clone, doors: clone.doors ?? [], enemies: clone.enemies ?? [], items: clone.items ?? [], playerStart: clone.playerStart ?? null, exit: clone.exit ?? null, }; this.selectedEnemy = null; this.metaName?.setLabel(`Name: ${this.level.name}`); this.fitToGrid(); this.rebuildModel(); this.flashLoadWarn(`Loaded ${fileName}`); } // ── Map overview (minimap) ─────────────────────────────────────────────── buildMinimap() { const x = this.rpX; const y = 330; const size = MINIMAP_SIZE; this.minimapX = x; this.minimapY = y; this.minimapSize = size; this.add.text(x + size / 2, y - 18, 'MAP OVERVIEW', { fontFamily: FONT, fontSize: '16px', color: COLORS.goldHex }).setOrigin(0.5); this.add.rectangle(x + size / 2, y + size / 2, size + 8, size + 8, 0x000000).setStrokeStyle(2, COLORS.accent); const key = 'wf-editor-minimap'; if (this.textures.exists(key)) this.textures.remove(key); this.minimapTexture = this.textures.createCanvas(key, size, size); this.minimapCtx = this.minimapTexture.context; this.minimapImage = this.add.image(x, y, key).setOrigin(0, 0) .setInteractive(new Phaser.Geom.Rectangle(0, 0, size, size), Phaser.Geom.Rectangle.Contains); this.minimapBox = this.add.graphics(); this.minimapImage.on('pointerdown', (p) => this.jumpToMinimap(p)); this.minimapImage.on('pointermove', (p) => { if (p.isDown) this.jumpToMinimap(p); }); this.add.text(x, y + size + 16, 'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n' + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop', { fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 }); } jumpToMinimap(pointer) { const lx = pointer.x - this.minimapX, ly = pointer.y - this.minimapY; if (lx < 0 || ly < 0 || lx > this.minimapSize || ly > this.minimapSize) return; const cx = (lx / this.minimapSize) * this.level.width; const cy = (ly / this.minimapSize) * this.level.height; this.setView(cx - (BOARD_SIZE / this.zoom) / 2, cy - (BOARD_SIZE / this.zoom) / 2); } /** Downsampled whole-grid overview — cost is bounded by minimap resolution, not grid size. Called whenever level data settles. */ drawMinimapContent() { if (!this.minimapCtx) return; const size = this.minimapSize; const lvl = this.level; const ctx = this.minimapCtx; const img = ctx.createImageData(size, size); const sx = lvl.width / size, sy = lvl.height / size; for (let py = 0; py < size; py++) { const cy = Math.min(lvl.height - 1, Math.floor(py * sy)); const row = lvl.walls[cy]; for (let px = 0; px < size; px++) { const cx = Math.min(lvl.width - 1, Math.floor(px * sx)); const wt = row[cx]; const [r, gr, b] = wt > 0 ? (MINIMAP_WALL_RGB[wt] ?? [136, 136, 136]) : [30, 30, 38]; const idx = (py * size + px) * 4; img.data[idx] = r; img.data[idx + 1] = gr; img.data[idx + 2] = b; img.data[idx + 3] = 255; } } ctx.putImageData(img, 0, 0); this.minimapTexture.refresh(); } /** Cheap viewport-rectangle overlay — redrawn on every pan/zoom, independent of the (throttled) content redraw. */ drawMinimapBox() { if (!this.minimapBox) return; const g = this.minimapBox; g.clear(); const size = this.minimapSize; const sx = size / this.level.width, sy = size / this.level.height; const bx = this.minimapX + Phaser.Math.Clamp(this.viewX, 0, this.level.width) * sx; const by = this.minimapY + Phaser.Math.Clamp(this.viewY, 0, this.level.height) * sy; const bw = Math.min(size, (BOARD_SIZE / this.zoom) * sx); const bh = Math.min(size, (BOARD_SIZE / this.zoom) * sy); g.lineStyle(2, 0xffffff, 0.9); g.strokeRect(bx, by, bw, bh); } // ── Input ───────────────────────────────────────────────────────────────── bindPointer() { this.input.mouse?.disableContextMenu(); this.input.on('pointerdown', (p) => { if (p.rightButtonDown()) { this.panState = { x: p.x, y: p.y, viewX: this.viewX, viewY: this.viewY }; return; } this.handleClick(p, true); }); this.input.on('pointermove', (p) => { if (this.panState) { const dx = (p.x - this.panState.x) / this.zoom, dy = (p.y - this.panState.y) / this.zoom; this.setView(this.panState.viewX - dx, this.panState.viewY - dy); return; } if (p.isDown) this.handleClick(p, false); }); this.input.on('pointerup', () => { if (this.panState) { this.panState = null; return; } this.flushValidate(); }); this.input.on('wheel', (p, _over, _dx, dy) => { if (!this.onBoard(p)) return; this.zoomAt(p.x, p.y, dy > 0 ? 0.85 : 1 / 0.85); }); } handleClick(p, isDown) { if (!this.onBoard(p)) return; let [cx, cy] = this.toCell(p.x, p.y); const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']); if (!isDown && !paintTools.has(this.tool)) return; const outOfRange = cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height; if (outOfRange && this.tool === 'erase') return; // nothing to erase in the void // A single click is cheap to fully validate even on a huge grid; a // drag-continuation shares the undo entry pushed at the stroke's start. if (isDown) this.pushUndo(); if (outOfRange) { if (!this.growToInclude(cx, cy)) return; // refused: would exceed MAX_GRID [cx, cy] = this.toCell(p.x, p.y); // growth shifted viewX/viewY; recompute for the same screen point } this.applyTool(cx, cy); if (isDown) { this.rebuildModel(); } else { // Mid-drag paint stroke: keep painting responsive (draw is now // viewport-clipped, so it stays cheap regardless of grid size) and let // the reachability check catch up after the stroke settles instead of // running on every pointermove. this.draw(); this.scheduleValidate(); } } /** * Expand the grid in whatever direction(s) are needed so cell (cx, cy) * becomes valid, preserving all existing content (walls/doors/entities * shift, never lose data) and re-sealing the new outer edge with border * walls — the same closed-boundary invariant resizeLevel() maintains. * Growing left/up shifts viewX/viewY by the same padding so the on-screen * view doesn't jump. Returns false (no-op) if the grid is already in range * or growth would exceed MAX_GRID. */ growToInclude(cx, cy) { const lvl = this.level; const padLeft = Math.max(0, -cx); const padTop = Math.max(0, -cy); const padRight = Math.max(0, cx - (lvl.width - 1)); const padBottom = Math.max(0, cy - (lvl.height - 1)); if (!padLeft && !padTop && !padRight && !padBottom) return true; const newW = lvl.width + padLeft + padRight; const newH = lvl.height + padTop + padBottom; if (newW > MAX_GRID || newH > MAX_GRID) return false; const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => { const oy = y - padTop, ox = x - padLeft; if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox]; return 0; // freshly grown territory starts as open floor })); for (let x = 0; x < newW; x++) { walls[0][x] = 1; walls[newH - 1][x] = 1; } for (let y = 0; y < newH; y++) { walls[y][0] = 1; walls[y][newW - 1] = 1; } const shift = (e) => ({ ...e, x: e.x + padLeft, y: e.y + padTop }); this.level = { ...lvl, width: newW, height: newH, walls, doors: lvl.doors.map(shift), enemies: lvl.enemies.map(shift), items: lvl.items.map(shift), playerStart: lvl.playerStart ? shift(lvl.playerStart) : null, exit: lvl.exit ? shift(lvl.exit) : null, }; this.viewX += padLeft; this.viewY += padTop; return true; } applyTool(x, y) { const lvl = this.level; if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; } if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; } if (this.tool === 'patrol') { this.applyPatrolTool(x, y); return; } lvl.walls[y][x] = 0; // every remaining tool places on floor if (this.tool === 'door') { const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' }); } else if (this.tool === 'start') { lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 }; } else if (this.tool === 'exit') { lvl.exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 }; } else if (this.tool === 'enemy') { const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); if (i >= 0) { if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null; lvl.enemies.splice(i, 1); } else { lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] }); } } else if (this.tool === 'ammo' || this.tool === 'health') { const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y); if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 }); } } /** * Patrol mode: click a guard to make it the active one for editing (see * `this.selectedEnemy`, an object reference — indices don't survive other * tools splicing the enemies array); once one is active, click any other * cell to append a waypoint at its center, or click an existing waypoint * to remove it. Never touches the walls grid (unlike every other * non-erase tool) — this is metadata editing, not placement. */ applyPatrolTool(x, y) { const lvl = this.level; const clickedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); if (clickedEnemy) { this.selectedEnemy = clickedEnemy; return; } if (!this.selectedEnemy || !lvl.enemies.includes(this.selectedEnemy)) { this.selectedEnemy = null; return; } const enemy = this.selectedEnemy; enemy.patrol = enemy.patrol ?? []; const i = enemy.patrol.findIndex((n) => Math.floor(n.x) === x && Math.floor(n.y) === y); if (i >= 0) enemy.patrol.splice(i, 1); else enemy.patrol.push({ x: x + 0.5, y: y + 0.5 }); } clearEntitiesAt(x, y) { const lvl = this.level; const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null; lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y)); lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y)); lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y)); if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null; if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null; } bindKeys() { this.input.keyboard.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); }); this.input.keyboard.on('keydown-F', () => this.fitToGrid()); const boardCenter = () => [BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2]; this.input.keyboard.on('keydown-PLUS', () => this.zoomAt(...boardCenter(), 1.25)); this.input.keyboard.on('keydown-NUMPAD_ADD', () => this.zoomAt(...boardCenter(), 1.25)); this.input.keyboard.on('keydown-MINUS', () => this.zoomAt(...boardCenter(), 0.8)); this.input.keyboard.on('keydown-NUMPAD_SUBTRACT', () => this.zoomAt(...boardCenter(), 0.8)); this.cursors = this.input.keyboard.createCursorKeys(); this.wasd = this.input.keyboard.addKeys('W,A,S,D'); } update(_time, delta) { if (!this.cursors || this.panState) return; const speed = (500 / this.zoom) * (delta / 1000); // ~constant on-screen pan speed at any zoom let dx = 0, dy = 0; if (this.cursors.left.isDown || this.wasd.A.isDown) dx -= speed; if (this.cursors.right.isDown || this.wasd.D.isDown) dx += speed; if (this.cursors.up.isDown || this.wasd.W.isDown) dy -= speed; if (this.cursors.down.isDown || this.wasd.S.isDown) dy += speed; if (dx || dy) this.setView(this.viewX + dx, this.viewY + dy); } // ── Undo / model ────────────────────────────────────────────────────────── pushUndo() { this.undoStack.push(JSON.stringify(this.level)); if (this.undoStack.length > 60) this.undoStack.shift(); } undo() { const snap = this.undoStack.pop(); if (!snap) return; this.level = JSON.parse(snap); this.selectedEnemy = null; // level was wholesale-replaced; old reference can't match anything in it this.setView(this.viewX, this.viewY); // re-clamp in case the undone edit resized the grid this.rebuildModel(); } rebuildModel() { this.runValidate(); } scheduleValidate() { if (this._validateTimer) this._validateTimer.remove(false); this._validateTimer = this.time.delayedCall(150, () => this.runValidate()); } /** Cancels any pending debounce and validates immediately — call before anything that reads `this.result` (Test Play, Export). */ flushValidate() { this.runValidate(); } runValidate() { if (this._validateTimer) { this._validateTimer.remove(false); this._validateTimer = null; } try { const model = buildLevelModel(this.level); this.result = validateLevel(model); } catch (err) { this.result = { valid: false, issues: [String(err.message ?? err)], reachable: false }; } this.issueText?.setText(this.result.issues.length ? `⚠ ${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK'); this.draw(); this.drawMinimapContent(); } // ── Drawing ─────────────────────────────────────────────────────────────── draw() { const g = this.g; g.clear(); const k = this.zoom; const lvl = this.level; const [x0, y0, x1, y1] = this.visibleCellRange(); for (let y = y0; y < y1; y++) { const row = lvl.walls[y]; for (let x = x0; x < x1; x++) { const wt = row[x]; const [bx, by] = this.toBoard(x, y); g.fillStyle(wt > 0 ? (WALL_COLORS[wt] ?? 0x888888) : 0x2a2a32, 1); g.fillRect(bx, by, k - 1, k - 1); } } g.lineStyle(1, 0x000000, 0.3); for (let x = x0; x <= x1; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + BOARD_SIZE); } for (let y = y0; y <= y1; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + BOARD_SIZE, by); } g.fillStyle(0xb08040, 1); for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); } if (lvl.playerStart) { const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); } if (lvl.exit) { const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y); g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35); } g.fillStyle(0xd83030, 1); for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); } g.fillStyle(0xd4a017, 1); for (const it of lvl.items) { const [bx, by] = this.toBoard(it.x, it.y); if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3); else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); } } if (this.tool === 'patrol') this.drawPatrolRoutes(); this.drawMinimapBox(); } /** * Every enemy with a route gets a dim line; the selected one (see * applyPatrolTool) gets a bright highlighted line plus a ring around the * enemy itself, so it's obvious which guard you're currently editing. * Home (the enemy's own spawn point) is always node 0 of the walked path, * even though it isn't stored in `patrol` — matches stepPatrol(). * * There's no separate "loop" toggle: stepPatrol() closes the route into a * one-way loop automatically once 2+ waypoints are authored (a 0- or * 1-waypoint route ping-pongs, which looks identical to a loop for that * few points anyway), so the overlay draws the same way — an extra * closing segment from the last waypoint back to home whenever * `e.patrol.length >= 2`, so what you see here always matches how the * guard will actually walk it. */ drawPatrolRoutes() { const g = this.g; const lvl = this.level; const k = this.zoom; for (const e of lvl.enemies) { if (!e.patrol || !e.patrol.length) continue; const selected = e === this.selectedEnemy; const looped = e.patrol.length >= 2; const path = [{ x: e.x, y: e.y }, ...e.patrol]; g.lineStyle(selected ? 3 : 2, selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.55); for (let i = 0; i < path.length - 1; i++) { const [ax, ay] = this.toBoard(path[i].x, path[i].y); const [bx, by] = this.toBoard(path[i + 1].x, path[i + 1].y); g.lineBetween(ax, ay, bx, by); } if (looped) { const [ax, ay] = this.toBoard(path[path.length - 1].x, path[path.length - 1].y); const [bx, by] = this.toBoard(path[0].x, path[0].y); g.lineBetween(ax, ay, bx, by); } g.fillStyle(selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.7); for (const node of e.patrol) { const [nx, ny] = this.toBoard(node.x, node.y); g.fillCircle(nx, ny, k * 0.16); } } if (this.selectedEnemy && lvl.enemies.includes(this.selectedEnemy)) { const [sx, sy] = this.toBoard(this.selectedEnemy.x, this.selectedEnemy.y); g.lineStyle(3, 0xffe066, 1); g.strokeCircle(sx, sy, k * 0.45); } } // ── Test play / export ─────────────────────────────────────────────────── testPlay() { this.flushValidate(); if (this.result.issues.length) return; this.registry.set('wolfenstein-editor-state', JSON.parse(JSON.stringify(this.level))); this.scene.start('WolfensteinGame', { game: { slug: 'wolfenstein', name: 'Wolfenstein 3D' }, testLevel: JSON.parse(JSON.stringify(this.level)), returnToEditor: true, }); } download(name, obj) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' })); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); } exportLevel() { this.flushValidate(); if (this.result.issues.length) return; this.download(`level-${this.level.id}.json`, this.level); } }