wolfenstein: pan/zoom editor with minimap, sliding recessed doors, enemy patrol

Editor (WolfensteinEditor.js):
- Decouple the board from grid size: fixed 900px viewport with independent
  pan (right-drag / WASD / arrows) and zoom (wheel / +/- / F to fit)
  instead of squeezing the whole grid in; add a whole-level map overview
  minimap with click-to-jump.
- Grids up to 1000 cells/side (was 48); painting past the edge auto-grows
  the grid (preserving content, re-sealing borders) instead of being refused.
- Rebuild the toolbar as a categorized DOM radio + dropdown panel (Wall /
  Door / Pickup / Enemy / Patrol / Zone / Erase) instead of a flat list.
- Add a Patrol tool: select a guard, then click tiles to add/remove
  waypoints; routes are drawn (dim for all, highlighted for the selection).
- Throttle the reachability validate during paint-drag; flush it before
  Test Play / Export; queue game assets in preload() like ZumaEditor.

Doors (WolfensteinLogic.js, WolfensteinRaycaster.js, WolfensteinView.js):
- Player now opens doors by pressing Space (HUD "[SPACE] Open" prompt when
  one is in range); enemies still shove them open. Doors animate open/closed
  via a slide value (400ms) instead of popping, and stay solid for
  gameplay until fully open.
- Render doors as recessed mid-cell geometry (real depth, producing the "H"
  doorway shape) via a door-aware render-only raycast — collision/LOS are
  unchanged; the door visibly slides sideways into a dark socket as it opens.

Enemy AI (WolfensteinLogic.js):
- Idle enemies with an authored patrol route ping-pong along
  [home, ...nodes] until they spot the player; validate patrol nodes.

Rendering (WolfensteinView.js, assets, data):
- Add textured wall rendering: per-column 1px source-texel sampling from
  the new walls.png spritesheet, darkened with a 'multiply' grey to match
  the existing side/fog shading, with per-wall-type flat-color fallback.
- Wire in assets/images/wolfenstein/walls.png and point the art manifest at it.

Misc:
- level-e1m1.json: reworked to a taller (20-row) map exercising the new
  wall/door types; playerRadius 0.25 -> 0.35.
- sprites.md: document exact wall-sheet size, the sliding/recessed door
  behavior, guard sheet usage, and standalone image sizes/proportions.
This commit is contained in:
Brian Fertig 2026-08-21 17:34:07 -06:00
parent 103bc235a7
commit a336376a93
11 changed files with 1041 additions and 162 deletions

View File

@ -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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

View File

@ -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": [

View File

@ -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,

View File

@ -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
? `<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() {
@ -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);
}

View File

@ -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) {

View File

@ -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) };

View File

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

View File

@ -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})`;
}

View File

@ -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