diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json
index c67ef62..8876cc8 100644
--- a/assets/gamedata/wolfenstein/level-e1m1.json
+++ b/assets/gamedata/wolfenstein/level-e1m1.json
@@ -1,11 +1,11 @@
{
"version": 1,
- "id": "e1m1",
+ "id": "level-new",
"name": "First Blood",
- "campaignId": "episode1",
+ "campaignId": null,
"missionIndex": 0,
"width": 12,
- "height": 10,
+ "height": 20,
"cellSize": 64,
"walls": [
[
@@ -27,8 +27,8 @@
0,
0,
0,
- 1,
0,
+ 3,
0,
0,
0,
@@ -41,8 +41,8 @@
0,
0,
0,
- 1,
0,
+ 3,
0,
0,
0,
@@ -70,11 +70,25 @@
0,
0,
0,
+ 3,
0,
0,
0,
0,
0,
+ 1
+ ],
+ [
+ 1,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 0,
+ 0,
+ 0,
+ 0,
0,
1
],
@@ -84,25 +98,11 @@
0,
0,
0,
- 1,
- 1,
+ 3,
0,
0,
0,
0,
- 1
- ],
- [
- 1,
- 0,
- 0,
- 0,
- 0,
- 1,
- 1,
- 0,
- 0,
- 0,
0,
1
],
@@ -126,7 +126,7 @@
0,
0,
0,
- 0,
+ 3,
0,
0,
0,
@@ -136,17 +136,157 @@
],
[
1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 3,
1,
- 1,
- 1,
- 1,
- 1,
- 1,
- 1,
+ 0,
1,
1,
1,
1
+ ],
+ [
+ 1,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 2,
+ 0,
+ 2,
+ 2,
+ 2,
+ 2
+ ],
+ [
+ 1,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 2,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 2,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 2,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 2,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ [
+ 1,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 4
+ ],
+ [
+ 1,
+ 2,
+ 2,
+ 2,
+ 2,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4
]
],
"playerStart": {
@@ -156,38 +296,40 @@
},
"doors": [
{
- "x": 4,
+ "x": 5,
"y": 3,
"orientation": "vertical"
+ },
+ {
+ "x": 5,
+ "y": 7,
+ "orientation": "vertical"
+ },
+ {
+ "x": 7,
+ "y": 9,
+ "orientation": "vertical"
+ },
+ {
+ "x": 4,
+ "y": 18,
+ "orientation": "vertical"
}
],
"enemies": [
{
"type": "guard",
- "x": 8.5,
- "y": 1.5,
- "facing": 180
- }
- ],
- "items": [
- {
- "type": "ammo",
- "x": 3.5,
- "y": 7.5
- },
- {
- "type": "health",
- "x": 9.5,
- "y": 7.5
+ "x": 2.5,
+ "y": 18.5,
+ "facing": 180,
+ "patrol": []
}
],
+ "items": [],
"exit": {
"x": 10.5,
- "y": 1.5,
+ "y": 14.5,
"radius": 0.6
},
- "briefing": [
- "Clear the outpost.",
- "Find the exit."
- ]
+ "briefing": []
}
diff --git a/assets/images/wolfenstein/walls.png b/assets/images/wolfenstein/walls.png
new file mode 100644
index 0000000..c49ccb9
Binary files /dev/null and b/assets/images/wolfenstein/walls.png differ
diff --git a/assets/images/wolfenstein/walls.psd b/assets/images/wolfenstein/walls.psd
new file mode 100644
index 0000000..c8ddce0
Binary files /dev/null and b/assets/images/wolfenstein/walls.psd differ
diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json
index c8e2c6e..a429348 100644
--- a/data/wolfenstein-artwork.json
+++ b/data/wolfenstein-artwork.json
@@ -1,7 +1,7 @@
{
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
"sheets": {
- "walls": { "key": "wolfenstein-walls", "path": null, "frameWidth": 64, "frameHeight": 64 },
+ "walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 },
"guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }
},
"artwork": [
diff --git a/data/wolfenstein-rules.json b/data/wolfenstein-rules.json
index 5362d3e..abd72ba 100644
--- a/data/wolfenstein-rules.json
+++ b/data/wolfenstein-rules.json
@@ -3,7 +3,7 @@
"tickHz": 60,
"fovDeg": 66,
"playerSpeed": 3.2,
- "playerRadius": 0.25,
+ "playerRadius": 0.35,
"playerMaxHealth": 100,
"mouseSensitivity": 0.0022,
"turnKeysRadPerSec": 2.6,
diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js
index e3aa01d..c78f840 100644
--- a/src/games/wolfenstein/WolfensteinEditor.js
+++ b/src/games/wolfenstein/WolfensteinEditor.js
@@ -6,11 +6,21 @@
// 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;
@@ -18,21 +28,54 @@ 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]]),
+);
-const TOOLS = [
- ['wall1', 'Wall: Stone'],
- ['wall2', 'Wall: Wood'],
- ['wall3', 'Wall: Blue'],
- ['wall4', 'Wall: Green'],
- ['door', 'Door'],
- ['start', 'Player Start'],
- ['exit', 'Exit'],
- ['enemy', 'Guard'],
- ['ammo', 'Ammo Pickup'],
- ['health', 'Health Pickup'],
- ['erase', 'Erase'],
+// 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 {
@@ -40,16 +83,35 @@ export default class WolfensteinEditor extends Phaser.Scene {
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())
@@ -60,8 +122,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.buildToolbar();
this.buildLoadPanel();
+ this.buildMinimap();
this.bindPointer();
this.bindKeys();
+ this.fitToGrid();
this.rebuildModel();
}
@@ -81,14 +145,48 @@ export default class WolfensteinEditor extends Phaser.Scene {
};
}
- // ── Coordinate transforms ────────────────────────────────────────────────
+ // ── Right-panel layout shared by the load panel and the minimap ────────────
- get cellPx() { return Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height); }
- toBoard(cx, cy) { const k = this.cellPx; return [BOARD_X + cx * k, BOARD_Y + cy * k]; }
- toCell(px, py) { const k = this.cellPx; return [Math.floor((px - BOARD_X) / k), Math.floor((py - BOARD_Y) / k)]; }
- onBoard(p) {
- const w = this.cellPx * this.level.width, h = this.cellPx * this.level.height;
- return p.x >= BOARD_X && p.x <= BOARD_X + w && p.y >= BOARD_Y && p.y <= BOARD_Y + h;
+ 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 ───────────────────────────────────────────────────────────────
@@ -98,17 +196,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
const bw = 260;
this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5);
- this.toolButtons = {};
- TOOLS.forEach(([id, label], i) => {
- const y = 80 + i * 58;
- const btn = new Button(this, tx + bw / 2, y, label, () => this.setTool(id), { width: bw, height: 48, fontSize: 18 });
- this.toolButtons[id] = btn;
- });
- this.setTool(this.tool);
+ const panelBottom = this.buildToolPanel(tx, bw);
- let y = 80 + TOOLS.length * 58 + 30;
+ 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, '', {
@@ -119,9 +212,70 @@ export default class WolfensteinEditor extends Phaser.Scene {
new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 });
}
- setTool(id) {
- this.tool = id;
- for (const [key, btn] of Object.entries(this.toolButtons)) btn.setActive(key === id);
+ /** 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() {
@@ -130,12 +284,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
}
resizeLevel() {
- const wStr = window.prompt('Grid width (cells):', String(this.level.width));
+ 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):', String(this.level.height));
+ 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, 48);
- const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, 48);
+ 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;
@@ -150,16 +304,24 @@ export default class WolfensteinEditor extends Phaser.Scene {
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.metaName.setLabel(`Name: ${this.level.name}`); 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 = BOARD_X + BOARD_SIZE + 90 + 260 + 40;
- const rw = 420;
+ 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};`;
@@ -228,33 +390,194 @@ export default class WolfensteinEditor extends Phaser.Scene {
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',
+ { 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.on('pointerdown', (p) => this.handleClick(p, true));
- this.input.on('pointermove', (p) => { if (p.isDown) this.handleClick(p, false); });
+ 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;
- const [cx, cy] = this.toCell(p.x, p.y);
- if (cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height) 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);
- this.rebuildModel();
+ 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') {
@@ -266,15 +589,42 @@ export default class WolfensteinEditor extends Phaser.Scene {
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) lvl.enemies.splice(i, 1); else lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
+ 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));
@@ -284,6 +634,25 @@ export default class WolfensteinEditor extends Phaser.Scene {
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 ──────────────────────────────────────────────────────────
@@ -296,10 +665,23 @@ export default class WolfensteinEditor extends Phaser.Scene {
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() {
+ 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);
@@ -308,6 +690,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
}
this.issueText?.setText(this.result.issues.length ? `⚠ ${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK');
this.draw();
+ this.drawMinimapContent();
}
// ── Drawing ───────────────────────────────────────────────────────────────
@@ -315,20 +698,22 @@ export default class WolfensteinEditor extends Phaser.Scene {
draw() {
const g = this.g;
g.clear();
- const k = this.cellPx;
+ const k = this.zoom;
const lvl = this.level;
+ const [x0, y0, x1, y1] = this.visibleCellRange();
- for (let y = 0; y < lvl.height; y++) {
- for (let x = 0; x < lvl.width; x++) {
- const wt = lvl.walls[y][x];
+ 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 = 0; x <= lvl.width; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + lvl.height * k); }
- for (let y = 0; y <= lvl.height; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + lvl.width * k, by); }
+ 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); }
@@ -349,11 +734,50 @@ export default class WolfensteinEditor extends Phaser.Scene {
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().
+ */
+ 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 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);
+ }
+ 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', {
@@ -372,6 +796,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
}
exportLevel() {
+ this.flushValidate();
if (this.result.issues.length) return;
this.download(`level-${this.level.id}.json`, this.level);
}
diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js
index 51b8c36..fda32b0 100644
--- a/src/games/wolfenstein/WolfensteinGame.js
+++ b/src/games/wolfenstein/WolfensteinGame.js
@@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = false;
this._lastAutosave = 0;
- this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC');
+ this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC,SPACE');
this._bindPointerLock();
@@ -220,6 +220,7 @@ export default class WolfensteinGame extends Phaser.Scene {
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
+ if (Phaser.Input.Keyboard.JustDown(k.SPACE)) Logic.openNearestDoor(this.state);
}
// ------------------------------------------------------------- loop
@@ -296,12 +297,15 @@ export default class WolfensteinGame extends Phaser.Scene {
objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0);
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
+ // Doors no longer open automatically on approach (see openNearestDoor) —
+ // without this, there's no way to discover that Space is the interact key.
+ objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
return objs;
}
_setHudVisible(visible) {
for (const o of Object.values(this.hud)) o.setVisible(visible);
- if (visible) this.hud.lockHint.setVisible(!this._locked);
+ if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); }
}
_updateHud() {
@@ -311,6 +315,7 @@ export default class WolfensteinGame extends Phaser.Scene {
const w = this.rules.weaponById[p.weapon];
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked);
+ this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
}
_toast(msg) {
diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js
index f9ff292..3870a1d 100644
--- a/src/games/wolfenstein/WolfensteinLogic.js
+++ b/src/games/wolfenstein/WolfensteinLogic.js
@@ -24,13 +24,26 @@ export const DOOR_WALL_TYPE = 9;
export function createState(level, rules) {
const walls = level.walls.map((row) => row.slice());
const doors = (level.doors ?? []).map((d, i) => ({
- id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', open: false, timer: 0,
+ id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical',
+ // slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
+ // DOOR_SLIDE_MS. target is where it's animating toward; the cell only
+ // stops blocking movement/sight once slide reaches 1 exactly — no
+ // squeezing through a half-open door — so the visual recede (see
+ // WolfensteinView._drawDoorColumn) never has to jump partway through.
+ slide: 0, target: 0, timer: 0,
}));
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
const enemies = (level.enemies ?? []).map((e, i) => ({
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false,
+ // Patrol route, editor-authored: an idle (never-yet-alerted) enemy walks
+ // home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever,
+ // until it spots the player (see stepPatrol). homeX/Y is the spawn point
+ // itself, since e.x/y move once patrolling starts.
+ homeX: e.x, homeY: e.y,
+ patrol: (e.patrol ?? []).map((n) => ({ x: n.x, y: n.y })),
+ patrolIndex: 0, patrolDir: 1,
}));
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
@@ -165,29 +178,66 @@ function stepPlayer(state, rules) {
}
// ---------------------------------------------------------------------------
-// Doors — open on proximity, auto-close once nobody is near
+// Doors — player must press Space near one to open it (see openNearestDoor,
+// called from WolfensteinGame on a Space keydown); enemies still shove doors
+// open on approach, since they have no equivalent of pressing a key. Either
+// way, once cracked open at all, nobody standing near it lets it finish
+// closing. A door that reaches full auto-close idle time with the area clear
+// starts sliding shut again on its own.
// ---------------------------------------------------------------------------
-function isNear(state, x, y, radius) {
- const p = state.player;
- if (!p.dead && Math.hypot(p.x - x, p.y - y) < radius) return true;
- for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - x, e.y - y) < radius) return true;
+const DOOR_SLIDE_MS = 400;
+const DOOR_RADIUS = 0.9;
+
+function enemyNearDoor(state, cx, cy) {
+ for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - cx, e.y - cy) < DOOR_RADIUS) return true;
return false;
}
+function anyoneNearDoor(state, cx, cy) {
+ const p = state.player;
+ if (!p.dead && Math.hypot(p.x - cx, p.y - cy) < DOOR_RADIUS) return true;
+ return enemyNearDoor(state, cx, cy);
+}
+
+function findOpenableDoor(state) {
+ const p = state.player;
+ if (p.dead) return null;
+ let best = null, bestDist = Infinity;
+ for (const d of state.doors) {
+ if (d.target === 1) continue; // already open or opening
+ const dist = Math.hypot(p.x - (d.x + 0.5), p.y - (d.y + 0.5));
+ if (dist <= DOOR_RADIUS + 0.2 && dist < bestDist) { best = d; bestDist = dist; }
+ }
+ return best;
+}
+
+/** Player interact (Space): open the nearest closed/closing door in range, if any. */
+export function openNearestDoor(state) {
+ const door = findOpenableDoor(state);
+ if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); }
+}
+
+/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */
+export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); }
function stepDoors(state, rules) {
for (const d of state.doors) {
- const near = isNear(state, d.x + 0.5, d.y + 0.5, 0.9);
- if (near) {
- if (!d.open) { d.open = true; state.events.push({ t: 'doorOpen', x: d.x, y: d.y }); }
- d.timer = rules.constants.doorAutoCloseMs;
- state.map.walls[d.y][d.x] = 0;
- } else if (d.open) {
- d.timer -= rules.stepMs;
- if (d.timer <= 0) {
- d.open = false;
- state.map.walls[d.y][d.x] = DOOR_WALL_TYPE;
- state.events.push({ t: 'doorClose', x: d.x, y: d.y });
+ const cx = d.x + 0.5, cy = d.y + 0.5;
+ if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them
+ if (d.target === 0 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it
+
+ const wasOpen = d.slide >= 1;
+ if (d.target === 1 && d.slide < 1) d.slide = Math.min(1, d.slide + rules.stepMs / DOOR_SLIDE_MS);
+ else if (d.target === 0 && d.slide > 0) d.slide = Math.max(0, d.slide - rules.stepMs / DOOR_SLIDE_MS);
+ const isOpen = d.slide >= 1;
+ if (isOpen !== wasOpen) state.map.walls[d.y][d.x] = isOpen ? 0 : DOOR_WALL_TYPE;
+
+ if (d.target === 1 && d.slide >= 1) {
+ if (!wasOpen || anyoneNearDoor(state, cx, cy)) {
+ d.timer = rules.constants.doorAutoCloseMs;
+ } else {
+ d.timer -= rules.stepMs;
+ if (d.timer <= 0) { d.target = 0; state.events.push({ t: 'doorClose', x: d.x, y: d.y }); }
}
}
}
@@ -208,7 +258,8 @@ function stepEnemyAI(state, rules) {
const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
if (e.state === 'idle') {
- if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); }
+ if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); continue; }
+ stepPatrol(state, rules, e, def);
continue;
}
if (canSee) e.state = 'chase';
@@ -240,6 +291,28 @@ function stepEnemyAI(state, rules) {
}
}
+const PATROL_ARRIVE_DIST = 0.12;
+
+/** Ping-pong an idle enemy along [home, ...patrol]; a no-op if it has no route. */
+function stepPatrol(state, rules, e, def) {
+ if (!e.patrol || !e.patrol.length) return;
+ const path = [{ x: e.homeX, y: e.homeY }, ...e.patrol];
+ if (e.patrolIndex >= path.length) e.patrolIndex = path.length - 1;
+ const target = path[e.patrolIndex];
+ const dx = target.x - e.x, dy = target.y - e.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist <= PATROL_ARRIVE_DIST) {
+ let next = e.patrolIndex + e.patrolDir;
+ if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; }
+ e.patrolIndex = next;
+ return;
+ }
+ const mvx = (dx / dist) * def.speed * rules.dt;
+ const mvy = (dy / dist) * def.speed * rules.dt;
+ moveWithCollision(state.map, e, mvx, mvy, def.radius);
+ e.angle = Math.atan2(dy, dx);
+}
+
// ---------------------------------------------------------------------------
// Weapons
// ---------------------------------------------------------------------------
@@ -497,6 +570,10 @@ export function validateLevel(level) {
for (const e of level.enemies ?? []) {
const c = { x: Math.floor(e.x), y: Math.floor(e.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`);
+ for (const n of e.patrol ?? []) {
+ const nc = { x: Math.floor(n.x), y: Math.floor(n.y) };
+ if (level.walls[nc.y]?.[nc.x] > 0) issues.push(`enemy patrol node (${n.x},${n.y}) sits inside a wall cell`);
+ }
}
for (const it of level.items ?? []) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
diff --git a/src/games/wolfenstein/WolfensteinRaycaster.js b/src/games/wolfenstein/WolfensteinRaycaster.js
index be820ba..c2a3941 100644
--- a/src/games/wolfenstein/WolfensteinRaycaster.js
+++ b/src/games/wolfenstein/WolfensteinRaycaster.js
@@ -11,23 +11,73 @@
// also exactly the parametric "t" of the ray equation P = origin + dir*t,
// which is what the swept-bullet and line-of-sight callers rely on (t=1 means
// "exactly at the point dir was aimed at").
+//
+// Door cells get real recessed-mid-plane geometry (see intersectDoorMidplane)
+// when a caller opts in via `doorAware` — WolfensteinView's render raycast
+// does; collision/line-of-sight/bullet callers in WolfensteinLogic don't, so
+// a door stays a simple full-cell solid for gameplay purposes exactly as
+// before (see stepDoors' note on why passability only flips at slide===1).
+// This is a rendering-only enhancement, not a change to what's walkable.
const DEFAULT_MAX_STEPS = 256;
+// Duplicated from WolfensteinLogic.DOOR_WALL_TYPE rather than imported —
+// WolfensteinLogic imports FROM this module, so importing back would be
+// circular. Keep these in sync if either changes.
+const DOOR_WALL_TYPE = 9;
+
/** True outside the grid too, so a ray/AABB check never has to special-case the border. */
export function isWallCell(map, cellX, cellY) {
if (cellX < 0 || cellY < 0 || cellX >= map.width || cellY >= map.height) return true;
return map.walls[cellY][cellX] > 0;
}
+/**
+ * Sub-cell intersection test for a door's mid-cell plane — a door sits at
+ * half the cell's depth along its blocking axis, not flush with either
+ * face, so walls either side of it read as thick blocks with the (thinner)
+ * door recessed between them: the "H" shape, door as the crossbar. The
+ * blocking axis is inferred from geometry (which pair of neighbors is
+ * walled off), not the doors[] `orientation` field, which nothing actually
+ * sets to anything but 'vertical'.
+ *
+ * Returns null when the ray's own trajectory would clear the cell sideways
+ * (cross into a neighboring cell) before ever reaching that mid-plane — the
+ * caller should then keep stepping the DDA as if this cell were transparent
+ * rather than treat it as a miss; stepping on lets the DDA reach the actual
+ * flanking wall cell and hit it at its own true (nearer, unrecessed)
+ * distance, which is what makes that wall's inward face visible at grazing
+ * angles — the two verticals of the "H," rendered as ordinary wall hits,
+ * not anything faked here.
+ */
+function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY) {
+ const blocksEW = isWallCell(map, cellX, cellY - 1) && isWallCell(map, cellX, cellY + 1);
+ if (blocksEW) {
+ if (Math.abs(dirX) < 1e-9) return null;
+ const t = (cellX + 0.5 - x) / dirX;
+ if (t <= 0) return null;
+ const along = y + t * dirY - cellY;
+ if (along < 0 || along > 1) return null;
+ return { perpDist: t, side: 0, textureX: along };
+ }
+ if (Math.abs(dirY) < 1e-9) return null;
+ const t = (cellY + 0.5 - y) / dirY;
+ if (t <= 0) return null;
+ const along = x + t * dirX - cellX;
+ if (along < 0 || along > 1) return null;
+ return { perpDist: t, side: 1, textureX: along };
+}
+
/**
* March a ray from (x,y) along direction (dirX,dirY) — need not be unit
* length; distances/`perpDist` come out scaled to that vector's own length —
* until it exits the first solid cell. Returns null if it runs off the map
* edge or exceeds maxSteps without a hit (should never happen inside a
- * validated closed level).
+ * validated closed level). `doorAware` opts into recessed door-plane
+ * geometry (see intersectDoorMidplane) instead of treating a door cell as an
+ * ordinary flush solid.
*/
-export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
+export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doorAware = false) {
let mapX = Math.floor(x);
let mapY = Math.floor(y);
@@ -48,12 +98,18 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
const wallType = map.walls[mapY][mapX];
- if (wallType > 0) {
- const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
- let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
- wallX -= Math.floor(wallX);
- return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
+ if (wallType === 0) continue;
+
+ if (doorAware && wallType === DOOR_WALL_TYPE) {
+ const doorHit = intersectDoorMidplane(map, x, y, dirX, dirY, mapX, mapY);
+ if (doorHit) return { ...doorHit, mapX, mapY, wallType };
+ continue; // ray grazes past this door cell without reaching its mid-plane
}
+
+ const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
+ let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
+ wallX -= Math.floor(wallX);
+ return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
}
return null;
}
@@ -69,8 +125,8 @@ export function makeCamera(x, y, angle, fov) {
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
}
-/** One castRay per screen column, camera-space (fisheye-free). */
-export function castColumns(map, camera, numColumns) {
+/** One castRay per screen column, camera-space (fisheye-free). `doorAware` — see castRay — is what WolfensteinView passes to get recessed door geometry. */
+export function castColumns(map, camera, numColumns, doorAware = false) {
const { x, y, dirX, dirY, planeX, planeY } = camera;
const maxSteps = fullMapSteps(map);
const out = new Array(numColumns);
@@ -78,7 +134,7 @@ export function castColumns(map, camera, numColumns) {
const cameraX = (2 * col) / numColumns - 1;
const rdx = dirX + planeX * cameraX;
const rdy = dirY + planeY * cameraX;
- out[col] = castRay(map, x, y, rdx, rdy, maxSteps);
+ out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doorAware);
}
return out;
}
diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js
index 8d8890b..9f3b398 100644
--- a/src/games/wolfenstein/WolfensteinView.js
+++ b/src/games/wolfenstein/WolfensteinView.js
@@ -5,10 +5,13 @@
// .refresh()) rather than a WebGL shader — renderer-agnostic (works under
// both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and
// conceptually the same canvas-manipulation approach Excitebike and Super
-// Kart's track pre-rasterizer already use elsewhere in this repo. Flat-shaded
-// per-column rects for now; when painted wall textures land, the same
-// per-column loop swaps fillRect for a drawImage source-column stretch — the
-// architecture doesn't change.
+// Kart's track pre-rasterizer already use elsewhere in this repo. Per column,
+// a single source-texel-wide slice of the `wolfenstein-walls` sheet (picked
+// via the raycaster's `textureX`) is drawImage-stretched to the column's
+// screen width, then darkened with a 'multiply' composite rect to reproduce
+// the same side/fog shading flat colors used. Falls back to a flat-shaded
+// fillRect (the original placeholder look) per wall type whose frame isn't
+// loaded — lets art land wall-type-by-wall-type instead of all at once.
//
// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser
// Images, positioned via the same camera-space projection math as the walls,
@@ -17,11 +20,24 @@
import { castColumns } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
+import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
export const VIEW_W = 1920;
export const VIEW_H = 940;
export const NUM_COLUMNS = 480;
+// wallType -> frame index in the `wolfenstein-walls` sheet, matching
+// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors are
+// never texture-sampled, they get their own recessed-slide treatment in
+// _drawDoorColumn regardless of whether painted wall art exists.
+const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
+
+// A dark, unshaded fill for the portion of a door that's already slid past —
+// meant to read as a shadowed pocket the panel has receded into, not more
+// wall. Kept flat (no side/fog shading) so it reads as a distinct material,
+// not just a darker version of the door itself.
+const DOOR_SOCKET_COLOR = '#141018';
+
export default class WolfensteinView {
constructor(scene, rules) {
this.scene = scene;
@@ -32,42 +48,100 @@ export default class WolfensteinView {
if (scene.textures.exists(key)) scene.textures.remove(key);
this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H);
this.ctx = this.canvasTexture.context;
+ this.ctx.imageSmoothingEnabled = false;
this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10);
this.colWidth = VIEW_W / NUM_COLUMNS;
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
this.spritePool = new Map();
+
+ this.wallTexture = scene.textures.exists('wolfenstein-walls')
+ ? scene.textures.get('wolfenstein-walls') : null;
}
render(state, camera) {
- this._drawWalls(state.map, camera);
+ this._drawWalls(state.map, state.doors, camera);
this._drawSprites(state, camera);
}
- _drawWalls(map, camera) {
+ _drawWalls(map, doors, camera) {
const ctx = this.ctx;
ctx.fillStyle = '#2b2b2b';
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2);
- const cols = castColumns(map, camera, NUM_COLUMNS);
+ const cols = castColumns(map, camera, NUM_COLUMNS, true);
for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i];
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
if (!hit) continue;
const lineHeight = VIEW_H / hit.perpDist;
- let drawStart = -lineHeight / 2 + VIEW_H / 2; if (drawStart < 0) drawStart = 0;
- let drawEnd = lineHeight / 2 + VIEW_H / 2; if (drawEnd > VIEW_H) drawEnd = VIEW_H;
- const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
- ctx.fillStyle = shadeColor(base, hit);
+ const rawStart = -lineHeight / 2 + VIEW_H / 2;
+ const rawEnd = lineHeight / 2 + VIEW_H / 2;
+ const drawStart = Math.max(0, rawStart);
+ const drawEnd = Math.min(VIEW_H, rawEnd);
+ if (drawEnd <= drawStart) continue;
const x = Math.floor(i * this.colWidth);
const w = Math.ceil(this.colWidth) + 1;
- ctx.fillRect(x, Math.floor(drawStart), w, Math.ceil(drawEnd - drawStart));
+ const y = Math.floor(drawStart);
+ const h = Math.ceil(drawEnd - drawStart);
+
+ if (hit.wallType === DOOR_WALL_TYPE) {
+ this._drawDoorColumn(ctx, x, y, w, h, hit, doors);
+ continue;
+ }
+
+ const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]];
+ if (frame) {
+ const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width));
+ // rawStart/rawEnd (not drawStart/drawEnd) are the true, unclipped
+ // extent of this column — up close that's far taller than the
+ // screen. Crop the source by the same fraction that got clipped off
+ // the destination so the vertical zoom keeps pace with the
+ // horizontal one instead of freezing once the wall overflows VIEW_H.
+ const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height;
+ const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height;
+ ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h);
+ ctx.globalCompositeOperation = 'multiply';
+ ctx.fillStyle = shadeGrey(hit);
+ ctx.fillRect(x, y, w, h);
+ ctx.globalCompositeOperation = 'source-over';
+ } else {
+ const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
+ ctx.fillStyle = shadeColor(base, hit);
+ ctx.fillRect(x, y, w, h);
+ }
}
this.canvasTexture.refresh();
}
+ /**
+ * hit.perpDist already IS the recessed mid-plane distance here — the
+ * render raycast opts into door-aware geometry (see castColumns(...,
+ * true) above and WolfensteinRaycaster's intersectDoorMidplane), so this
+ * column's (x,y,w,h), computed by the caller exactly like any other wall,
+ * is already correctly smaller/farther than a flush wall would be. A
+ * grazing column whose ray clears the door cell sideways instead hits the
+ * real flanking wall cell at ITS true distance via the normal wall path
+ * above — that's what makes the wall's inward face visible next to the
+ * door, the two verticals of the "H." Nothing extra to fake here.
+ *
+ * `textureX` is where this column's ray crosses the door's face (0..1
+ * along it, computed at the mid-plane); a door slides such that the low-
+ * textureX edge stays put and the far edge recedes first, so as `slide`
+ * climbs, columns flip from "still there" to "already receded" in
+ * textureX order — the door visibly sliding sideways into its socket.
+ */
+ _drawDoorColumn(ctx, x, y, w, h, hit, doors) {
+ const door = doors.find((d) => d.x === hit.mapX && d.y === hit.mapY);
+ const visibleFrac = 1 - (door?.slide ?? 0);
+ ctx.fillStyle = hit.textureX < visibleFrac
+ ? shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit)
+ : DOOR_SOCKET_COLOR;
+ ctx.fillRect(x, y, w, h);
+ }
+
_drawSprites(state, camera) {
const live = new Set();
const sprites = [];
@@ -132,11 +206,23 @@ export default class WolfensteinView {
}
}
-function shadeColor(hex, hit) {
- let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
+function shadeMul(hit) {
const sideMul = hit.side === 1 ? 0.72 : 1;
const fog = Math.max(0.28, 1 - hit.perpDist / 14);
- const m = sideMul * fog;
+ return sideMul * fog;
+}
+
+function shadeColor(hex, hit) {
+ let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
+ const m = shadeMul(hit);
r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m);
return `rgb(${r},${g},${b})`;
}
+
+// A solid grey drawn with 'multiply' compositing scales every channel of
+// whatever's underneath by the same factor shadeColor() applies to a flat
+// fill — the same side/fog darkening, applied to a drawn texture instead.
+function shadeGrey(hit) {
+ const v = Math.round(255 * shadeMul(hit));
+ return `rgb(${v},${v},${v})`;
+}
diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md
index 6186ca1..4c1102e 100644
--- a/src/games/wolfenstein/sprites.md
+++ b/src/games/wolfenstein/sprites.md
@@ -7,30 +7,118 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
## Wall textures — `sheets.walls`
-A horizontal spritesheet, one 64×64 frame per wall type, in the order used by
-`WolfensteinArt.WALL_COLORS`: frame 0 = type 1 (stone), frame 1 = type 2
-(wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). A door
-(closed) reuses frame index matching `DOOR_WALL_TYPE`'s placeholder color
-(tan) — add a 5th frame if a distinct door texture is wanted.
+Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
+loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom
+— a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames,
+one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion
+order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
+(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
+are **not** part of this sheet at all — `WolfensteinView._drawDoorColumn`
+(as of 2026-08-21) handles type 9 as a special case before the texture path
+even runs, rendering the flat placeholder tan color (`WALL_COLORS[9]`) for
+whatever fraction of the door hasn't yet slid open, and a flat dark "socket"
+fill for the rest (see the sliding-door doc below) — so a 5th sheet frame
+wouldn't currently be read even if added. Painting a real door texture means
+teaching `_drawDoorColumn` to sample a frame the same way the wall path
+does, not just adding a 320×64 sheet.
+
+### Sliding, recessed doors
+
+Doors animate open/closed (`WolfensteinLogic.js`'s `stepDoors`, `slide` field
+0→1 over `DOOR_SLIDE_MS`) rather than popping instantly, and the cell stays
+solid for *gameplay* (collision/LOS/bullets) through the entire animation —
+it only becomes passable once `slide` reaches exactly 1 (see stepDoors'
+comment for why). That's unrelated to the visual, though: doors also render
+recessed to the middle of the wall's depth — real geometry, not a flat-color
+trick — via `WolfensteinRaycaster.js`'s `intersectDoorMidplane`, which
+`WolfensteinView`'s render-only raycast opts into with `castColumns(...,
+true)` (the `doorAware` flag; every gameplay raycast in `WolfensteinLogic.js`
+omits it and still sees a door as an ordinary flush full-cell solid, so this
+never touches what's actually walkable/shootable).
+
+A door's blocking axis is inferred from geometry (which pair of neighbor
+cells is walled off) rather than the `doors[]` `orientation` field, which
+nothing in the codebase actually sets to anything but `'vertical'`. Given
+that axis, the door plane sits at the cell's exact midpoint (e.g. `x =
+doorCellX + 0.5` for a door blocking east-west travel) instead of at the
+near face like a normal wall — a straight-on ray is `perpDist`-recessed by
+0.5 relative to where a flush wall would sit. Critically, a ray whose
+trajectory would clear the door cell sideways (cross into a neighboring
+cell) *before* reaching that mid-plane isn't given a fake hit at all —
+`castRay` just keeps stepping the DDA, so the ray goes on to hit the actual
+flanking wall cell at *its own* true, unrecessed distance. That's what
+produces the "H" shape: the door is the crossbar, visibly set back between
+two verticals that are ordinary wall hits, not anything faked in
+`_drawDoorColumn` — confirmed with a sweep of rays across a doorway (see
+git history around 2026-08-21 for the script) showing a symmetric run of
+door hits at the recessed distance, flanked on both sides by wall hits at
+their own, nearer distances, with no discontinuity at the transition.
+
+`_drawDoorColumn` itself is now simple: `hit.perpDist`/`(x,y,w,h)` already
+reflect the recessed mid-plane (computed by the caller exactly like any
+other wall), so it only has to pick door-texture-tan vs. dark socket color
+per column, using `hit.textureX` (now measured at the mid-plane, 0..1 along
+the door's face) against `1 - slide` — the same left-to-right slide-open
+wipe as before, just riding on correct geometry instead of a projection
+trick.
+
+**Now wired up** (as of 2026-08-21): `WolfensteinView._drawWalls` reads
+`hit.textureX` (the raycaster's fractional wall-face position) and
+`drawImage`s a 1px-wide source-texel column, stretched to the destination
+column's screen width, then darkens it with a `'multiply'`-composited grey
+rect using the same side/fog factor the flat-color fallback uses — matches
+the shading, just applied to a texture instead of a solid fill. Falls back
+to the old flat-shaded `fillRect` per wall type whose frame isn't loaded
+(`this.wallTexture` missing, or no `WALL_FRAME` entry for that type — true
+for type 9/doors today), so art can land wall-type-by-wall-type. The texture
+is grabbed once in the constructor (`scene.textures.get('wolfenstein-walls')`),
+which the manifest loader has already populated by the time a game scene is
+entered — no per-frame lookup cost.
## Guard enemy — `sheets.guard`
-128×128 frames. Minimum for parity with the current placeholder: one idle
-pose. For proper directional billboarding later: 8 facing angles × {idle,
-walk (2-4 frames), shoot, die} — not required for the MVP renderer, which
-only ever shows a single billboard sprite per enemy.
+Per the artwork JSON: `frameWidth: 128, frameHeight: 128`. **Minimum useful
+size: a single 128×128 PNG (1 frame)** — that's all the current renderer can
+show. `WolfensteinView._drawSprites` calls `img.setTexture('wolf-guard')`
+with no frame index, so Phaser always displays frame 0 (top-left) of
+whatever sheet is loaded under that key; there is no facing/animation frame
+selection logic anywhere in the code yet. Painting extra frames into the
+sheet is harmless (they'll just sit unused) but won't animate or turn to
+face the player until that selection logic is written.
+
+If/when directional billboarding is worth building, the natural layout to
+target is a grid, frame index = `row * cols + col` in Phaser's row-major
+order: **8 rows (one per 45° facing angle, starting from "facing camera" and
+going clockwise) × N columns** for whatever animation set is wanted per
+angle, e.g. `{idle, walk1, walk2, shoot, die1, die2}` (6 cols → a 768×1024
+sheet). That's a proposal, not a spec anything reads — pick a smaller set
+(e.g. idle + walk + shoot only, 3 cols) if painting 48 frames is too much
+up front, since nothing currently depends on the exact column count.
## Standalone images — `artwork[]`
+These are loaded as plain images (no frame slicing), so any resolution
+works, but matching the placeholder's proportions keeps in-world scale
+consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
+is applied on top, so a differently-sized source image just gets stretched —
+same aspect ratio is what matters most):
+
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
- — pickup icons, square, transparent background.
-- `wolfenstein-bullet` — small bright sprite for a pistol round in flight.
-- `wolfenstein-muzzle` — muzzle-flash flash sprite (currently unused by the
- scene beyond the painted placeholder texture; wire up on `weaponFired`
- events once sfx/vfx pass lands).
-- `wolfenstein-title` — optional main-menu backdrop art (the menu currently
- renders a flat panel with title text, matching `TAScreens.js`'s style
- before `ta-background` was painted).
+ — pickup icons. Placeholder is 64×64; square, transparent background.
+ Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`).
+- `wolfenstein-bullet` — pistol round in flight. Placeholder is 16×16, a
+ small bright circle; rendered at scale 0.18, so keep it simple/legible at
+ tiny sizes.
+- `wolfenstein-muzzle` — muzzle-flash sprite. Placeholder is 48×48. Not
+ currently spawned by the scene at all (no code creates a `wolf-muzzle`
+ image instance yet, painted or not) — wire it up on `weaponFired` events
+ once a sfx/vfx pass lands.
+- `wolfenstein-title` — optional main-menu backdrop art. Not read anywhere
+ in `WolfensteinScreens.js` today; the menu renders a flat panel + title
+ text only (`backdrop()`'s `GAME_WIDTH`×`GAME_HEIGHT` rectangle, matching
+ `TAScreens.js`'s style before `ta-background` was painted). If added,
+ size it to the shared canvas, **1920×1080**, and code would need to draw
+ it behind that panel.
## Sound effects