818 lines
37 KiB
JavaScript
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);
|
|
}
|
|
}
|