diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json index 204b801..c610450 100644 --- a/assets/gamedata/wolfenstein/level-e1m1.json +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -512,5 +512,37 @@ "y": 18.5, "radius": 0.6 }, - "briefing": [] + "briefing": [], + "wallArt": [ + { + "x": 11, + "y": 2, + "frame": 0 + }, + { + "x": 11, + "y": 4, + "frame": 0 + }, + { + "x": 11, + "y": 6, + "frame": 0 + }, + { + "x": 11, + "y": 8, + "frame": 0 + }, + { + "x": 7, + "y": 14, + "frame": 1 + }, + { + "x": 1, + "y": 15, + "frame": 1 + } + ] } diff --git a/assets/images/wolfenstein/wall-art.png b/assets/images/wolfenstein/wall-art.png new file mode 100644 index 0000000..d2e56c7 Binary files /dev/null and b/assets/images/wolfenstein/wall-art.png differ diff --git a/assets/images/wolfenstein/wall-art.psd b/assets/images/wolfenstein/wall-art.psd new file mode 100644 index 0000000..297c7bb Binary files /dev/null and b/assets/images/wolfenstein/wall-art.psd differ diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json index 0734ac9..88a996d 100644 --- a/data/wolfenstein-artwork.json +++ b/data/wolfenstein-artwork.json @@ -3,7 +3,8 @@ "sheets": { "walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, "guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 }, - "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 } + "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 }, + "wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 } }, "artwork": [ { "key": "wolfenstein-item-pistol", "path": null }, diff --git a/data/wolfenstein-wallart.json b/data/wolfenstein-wallart.json new file mode 100644 index 0000000..2fe806a --- /dev/null +++ b/data/wolfenstein-wallart.json @@ -0,0 +1,7 @@ +{ + "_readme": "Frame registry for the wall-art decal sheet (assets/images/wolfenstein/wall-art.png, sheets.wallArt in wolfenstein-artwork.json, 64x64 frames, transparent PNGs painted on top of a wall's own texture). To add a new decal: paint the next 64x64 tile into wall-art.png, then append an entry here — WolfensteinEditor.js's Wall Art dropdown reads this file directly, no code changes needed.", + "frames": [ + { "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" }, + { "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" } + ] +} diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js index a5e1d7c..0cc5fad 100644 --- a/src/games/wolfenstein/WolfensteinEditor.js +++ b/src/games/wolfenstein/WolfensteinEditor.js @@ -62,6 +62,8 @@ const FACING_DIRS = { // 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 WALLART_PREFIX = 'wallart:'; + const CATEGORIES = [ { id: 'wall', label: 'Wall', options: [ { id: 'wall1', label: 'Stone' }, @@ -69,6 +71,13 @@ const CATEGORIES = [ { id: 'wall3', label: 'Blue' }, { id: 'wall4', label: 'Green' }, ] }, + // Options populated from data/wolfenstein-wallart.json once it loads (see + // create()'s fetch + refreshWallArtOptions) — empty here just gives + // buildToolPanel a once data/wolfenstein-wallart.json loads — mirrors refreshLoadOptions above. */ + refreshWallArtOptions() { + const select = this.toolPanelDom?.node?.querySelector('select[data-cat="wallart"]'); + if (!select) return; + select.innerHTML = this.wallArtFrames.map((f) => ``).join(''); + } + flashLoadWarn(msg) { if (!this.loadWarn) return; this.loadWarn.textContent = msg; @@ -404,6 +428,7 @@ export default class WolfensteinEditor extends Phaser.Scene { doors: clone.doors ?? [], enemies: clone.enemies ?? [], items: clone.items ?? [], + wallArt: clone.wallArt ?? [], playerStart: clone.playerStart ?? null, exit: clone.exit ?? null, }; @@ -441,7 +466,8 @@ export default class WolfensteinEditor extends Phaser.Scene { this.add.text(x, y + size + 16, 'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n' - + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop', + + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop\n\n' + + 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).', { fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 }); } @@ -603,6 +629,7 @@ export default class WolfensteinEditor extends Phaser.Scene { doors: lvl.doors.map(shift), enemies: lvl.enemies.map(shift), items: lvl.items.map(shift), + wallArt: (lvl.wallArt ?? []).map(shift), playerStart: lvl.playerStart ? shift(lvl.playerStart) : null, exit: lvl.exit ? shift(lvl.exit) : null, }; @@ -613,11 +640,18 @@ export default class WolfensteinEditor extends Phaser.Scene { applyTool(x, y) { const lvl = this.level; + // Repainting a wall's own type/color keeps any wall art on it — the + // decal isn't tied to a specific wall type, only to the cell being a + // wall at all — so this is the one wall-touching branch that does NOT + // clear wall art (contrast erase and every floor-placing tool below, + // which all turn the cell into non-wall and so must clear it). 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 === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); this.clearWallArtAt(x, y); return; } if (this.tool === 'patrol') { this.applyPatrolTool(x, y); return; } + if (this.tool.startsWith(WALLART_PREFIX)) { this.applyWallArtTool(x, y, Number(this.tool.slice(WALLART_PREFIX.length))); return; } lvl.walls[y][x] = 0; // every remaining tool places on floor + this.clearWallArtAt(x, y); if (this.tool === 'door') { const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' }); @@ -667,6 +701,29 @@ export default class WolfensteinEditor extends Phaser.Scene { if (i >= 0) enemy.patrol.splice(i, 1); else enemy.patrol.push({ x: x + 0.5, y: y + 0.5 }); } + /** + * Wall Art: only an actual wall cell (walls[y][x] > 0 — floor, and door + * cells too since a door leaves walls[y][x] at 0 in editor-authored level + * JSON, both fail this check the same way) is a valid decal surface. + * Clicking one with no art applies the selected frame; clicking one that + * already has art removes it — a straight toggle, regardless of which + * frame is currently selected in the dropdown, so switching an + * already-decorated wall from one decal to another is two clicks + * (remove, then apply) rather than an implicit replace. + */ + applyWallArtTool(x, y, frame) { + const lvl = this.level; + if (!(lvl.walls[y]?.[x] > 0)) return; + lvl.wallArt = lvl.wallArt ?? []; + const i = lvl.wallArt.findIndex((w) => w.x === x && w.y === y); + if (i >= 0) lvl.wallArt.splice(i, 1); else lvl.wallArt.push({ x, y, frame }); + } + + clearWallArtAt(x, y) { + const lvl = this.level; + lvl.wallArt = (lvl.wallArt ?? []).filter((w) => !(w.x === x && w.y === y)); + } + clearEntitiesAt(x, y) { const lvl = this.level; const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); @@ -766,6 +823,16 @@ export default class WolfensteinEditor extends Phaser.Scene { 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); } + // Decorated walls get a yellow outline at all times (not just while the + // Wall Art tool is active) — same "always visible, not tool-scoped" + // treatment as doors above, since it's level content worth seeing no + // matter which tool you're currently using. + g.lineStyle(2, 0xffe066, 1); + for (const w of lvl.wallArt ?? []) { + const [bx, by] = this.toBoard(w.x, w.y); + g.strokeRect(bx + 1, by + 1, k - 3, k - 3); + } + if (lvl.playerStart) { const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index eb7b767..b3c5687 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -57,9 +57,15 @@ export function createState(level, rules) { const cooldowns = {}; for (const w of rules.weapons) cooldowns[w.id] = 0; + // Wall art (decal painted on top of a wall's own texture) is static, + // level-authored decoration with no runtime behavior of its own — it just + // rides along on state.map for WolfensteinView to sample, the same way + // the wall grid itself does. + const wallArt = (level.wallArt ?? []).map((w) => ({ x: w.x, y: w.y, frame: w.frame })); + return { tick: 0, accumulatorMs: 0, alpha: 0, - map: { width: level.width, height: level.height, walls }, + map: { width: level.width, height: level.height, walls, wallArt }, player: { x: level.playerStart.x, y: level.playerStart.y, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, @@ -573,11 +579,21 @@ function stepProjectiles(state, rules) { const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]); if (proj.friendly) { // Ammo hitting an enemy both damages and stuns it (unless the - // hit was lethal — a dead enemy has nothing left to flinch). + // hit was lethal — a dead enemy has nothing left to flinch). Being + // shot is also unconditional proof of the player's presence — it + // alerts the enemy the same way spotting the player or hearing + // gunfire in its room does, regardless of range/LOS/facing-cone + // (a shot from outside its own vision cone, e.g. from behind, + // still counts). Only a genuinely idle enemy transitions here; + // one already alert/chasing/attacking has nothing to escalate. damageEnemy(state, bestTarget, dmg); if (!bestTarget.dead) { bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0; state.events.push({ t: 'enemyStunned', id: bestTarget.id }); + if (bestTarget.state === 'idle') { + bestTarget.state = 'alert'; + state.events.push({ t: 'enemyAlert', id: bestTarget.id }); + } } } else { applyDamageToPlayer(state, dmg); @@ -647,6 +663,7 @@ export function buildLevelModel(levelJson) { doors: (levelJson.doors ?? []).map((d) => ({ ...d })), enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })), items: (levelJson.items ?? []).map((it) => ({ ...it })), + wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })), exit: levelJson.exit ? { ...levelJson.exit } : null, briefing: (levelJson.briefing ?? []).slice(), }; @@ -717,6 +734,9 @@ export function validateLevel(level) { const c = { x: Math.floor(it.x), y: Math.floor(it.y) }; if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`); } + for (const w of level.wallArt ?? []) { + if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`); + } const reachable = bfsReachable(level, startCell, exitCell); if (!reachable) issues.push('exit is not reachable from playerStart'); @@ -755,7 +775,7 @@ export function serialize(state) { return JSON.stringify({ v: SAVE_VERSION, tick: state.tick, accumulatorMs: state.accumulatorMs, - map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) }, + map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls), wallArt: state.map.wallArt }, player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } }, enemies: state.enemies.map((e) => ({ ...e })), projectiles: state.projectiles.map((p) => ({ ...p })), @@ -773,7 +793,14 @@ export function deserialize(rules, raw) { if (!data || data.v !== SAVE_VERSION) return null; return { tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0, - map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) }, + // wallArt defaults to [] for a save taken before this feature existed — + // purely cosmetic, static data, so a missing field degrades to "no + // decals" instead of needing a SAVE_VERSION bump. + map: { + width: data.map.width, height: data.map.height, + walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height), + wallArt: data.map.wallArt ?? [], + }, player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false }, enemies: data.enemies.map((e) => ({ ...e })), projectiles: data.projectiles.map((p) => ({ ...p })), diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 3334b78..5de40c2 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -96,6 +96,8 @@ export default class WolfensteinView { this.wallTexture = scene.textures.exists('wolfenstein-walls') ? scene.textures.get('wolfenstein-walls') : null; + this.wallArtTexture = scene.textures.exists('wolfenstein-wall-art') + ? scene.textures.get('wolfenstein-wall-art') : null; this.doorTexture = scene.textures.exists('wolfenstein-doors') ? scene.textures.get('wolfenstein-doors') : null; this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet') @@ -134,6 +136,11 @@ export default class WolfensteinView { ctx.fillStyle = '#4a4a4a'; ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); + // x,y -> wall-art frame index. map.wallArt is small, level-authored, + // static data — cheap enough to rebuild this lookup every call rather + // than caching it across frames. + const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame])); + const cols = castColumns(map, camera, NUM_COLUMNS, doors); for (let i = 0; i < NUM_COLUMNS; i++) { const hit = cols[i]; @@ -155,26 +162,43 @@ export default class WolfensteinView { continue; } + // 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. Wall art below + // reuses this exact same fraction so a decal lines up pixel-for-pixel + // with the wall face beneath it at any distance. 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.fillStyle = `#${(WALL_COLORS[hit.wallType] ?? 0xaaaaaa).toString(16).padStart(6, '0')}`; ctx.fillRect(x, y, w, h); } + + // Wall art: an optional decal painted on top of the wall's own + // texture (a poster/flag, not a different wall type), sampled with + // the identical per-column source-texel technique above so it lines + // up with the wall face beneath it, drawn BEFORE the shading pass + // below so it darkens with distance/side the same as the wall does — + // otherwise it'd read as a flat unlit sticker glued onto a dim wall. + const artFrameIndex = wallArtByCell.get(`${hit.mapX},${hit.mapY}`); + const artFrame = artFrameIndex != null ? this.wallArtTexture?.frames[artFrameIndex] : null; + if (artFrame) { + const artSrcX = artFrame.cutX + Math.min(artFrame.width - 1, Math.floor(hit.textureX * artFrame.width)); + const artSrcY = artFrame.cutY + ((drawStart - rawStart) / lineHeight) * artFrame.height; + const artSrcH = ((drawEnd - drawStart) / lineHeight) * artFrame.height; + ctx.drawImage(artFrame.source.image, artSrcX, artSrcY, 1, artSrcH, x, y, w, h); + } + + ctx.globalCompositeOperation = 'multiply'; + ctx.fillStyle = shadeGrey(hit); + ctx.fillRect(x, y, w, h); + ctx.globalCompositeOperation = 'source-over'; } this.canvasTexture.refresh(); } diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index 5794377..3c89f28 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -15,6 +15,52 @@ 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 — see `sheets.doors` below. +## Wall art decals — `sheets.wallArt` + +`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls — +but unlike walls/doors this isn't a wall *type*, it's an optional decal +(poster/flag/etc.) painted on top of whichever wall type is already there. +**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png` +(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are +assigned so far). Frame *names* (not just indices) live in a separate JSON +registry, `data/wolfenstein-wallart.json` — `{ frames: [{ frame, id, name }] }` +— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at +startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`). +To add a new decal: paint the next tile into `wall-art.png`, append one +entry to that registry — no code changes needed on either side. Current +frames: `0` = Nazi Flag, `1` = Hitler Pic. + +Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like +`doors`/`items`, not a dense grid) — one entry per decorated wall cell, +folded into `buildLevelModel`/`validateLevel` in `WolfensteinLogic.js` (an +entry must sit on an actual wall cell, `walls[y][x] > 0`) and carried +statically on `state.map.wallArt` (round-trips through save/load; a save +predating this feature just deserializes to `[]`, no `SAVE_VERSION` bump +needed since it's purely cosmetic data with no simulation behavior). + +Rendering (`WolfensteinView._drawWalls`): sampled with the *exact same* +per-column source-texel technique as the wall texture itself (same `srcX` +from `hit.textureX`, same vertical crop math), so a decal lines up +pixel-for-pixel with the wall face beneath it at any distance — it's drawn +directly on top of the base wall draw, before the shared `multiply` shading +pass, so it darkens with distance/side exactly like the wall instead of +reading as a flat unlit sticker. Falls back to showing nothing (just the +plain wall) if the sheet isn't loaded — there's no procedural placeholder +for wall art, unlike walls/doors/guard. + +Editor (`WolfensteinEditor.js`): a "Wall Art" tool category whose dropdown +is the one populated dynamically (from the JSON registry above) rather than +hardcoded in `CATEGORIES` — every other category's options are static. +Clicking an existing wall cell with a frame selected toggles that cell's +entry in `level.wallArt` (apply if absent, remove if present, regardless of +which frame is currently selected — switching an already-decorated wall to +a different decal is two clicks). Repainting a wall's type/color keeps its +wall art (`applyTool`'s `WALL_TOOLS` branch); erasing a wall or placing +anything that turns the cell to floor clears it (`clearWallArtAt`, called +alongside every floor-creating branch). Decorated walls get a persistent +yellow outline on the board regardless of which tool is active, the same +"always visible" treatment doors get. + ## Door textures — `sheets.doors` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.