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