fertig-classic-games/src/games/wolfenstein/WolfensteinEditor.js

818 lines
37 KiB
JavaScript

// 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
? `<select data-cat="${cat.id}" style="${inputCss} margin-left:auto; width:112px;">
${cat.options.map((o) => `<option value="${o.id}" ${cat.id === activeCat.id && o.id === activeOpt ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>`
: '';
const marginBottom = i === CATEGORIES.length - 1 ? 0 : rowGap;
return `
<div style="display:flex; align-items:center; height:${rowH}px; margin-bottom:${marginBottom}px;">
<input type="radio" name="wf-tool" id="wf-tool-${cat.id}" value="${cat.id}" ${checked}
style="accent-color:#c8a84b; width:16px; height:16px; flex:none; cursor:pointer;">
<label for="wf-tool-${cat.id}" style="margin-left:8px; width:62px; flex:none; cursor:pointer;">${cat.label}</label>
${select}
</div>`;
}).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 = `<div style="background:#0a0a12ee; border:${border}px solid #c8a84b; border-radius:12px; padding:${pad}px;">${rows}</div>`;
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 = `
<div style="background:#0a0a12ee; border:2px solid #c8a84b; border-radius:14px; padding:16px 20px;">
<div style="font-size:20px; color:#c8a84b; text-align:center; margin-bottom:10px;">LOAD LEVEL</div>
<select id="wf-load" style="${input} margin-bottom:10px;"><option value="">— load from ${GAMEDATA}/ —</option></select>
<input id="wf-fileinput" type="file" accept=".json" style="width:100%; font-size:14px; color:${COLORS.textHex};">
<div id="wf-load-warn" style="color:${COLORS.dangerHex}; font-size:14px; min-height:20px; margin-top:8px;"></div>
</div>`;
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 = [`<option value="">— load from ${GAMEDATA}/ —</option>`,
...this.manifestLevels.map((m) => `<option value="${m.file}">${m.label}</option>`)].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);
}
}