Add wall art decals to Wolfenstein levels

- Add new `wallArt` level data array with per-cell decal frames
- Integrate wall art rendering into WolfensteinView using same texture sampling as walls
- Add Wall Art tool to WolfensteinEditor with dynamic dropdown from JSON registry
- Create wolfenstein-wallart.json frame registry for easy decal management
- Add validation ensuring wall art sits on actual wall cells
- Support wall art in save/load system with backward compatibility
- Alert idle enemies when shot, even outside vision cone
This commit is contained in:
Brian Fertig 2026-08-22 10:32:59 -06:00
parent 7d9729e594
commit c0ab9155a3
9 changed files with 224 additions and 20 deletions

View File

@ -512,5 +512,37 @@
"y": 18.5, "y": 18.5,
"radius": 0.6 "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
}
]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

View File

@ -3,7 +3,8 @@
"sheets": { "sheets": {
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, "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 }, "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": [ "artwork": [
{ "key": "wolfenstein-item-pistol", "path": null }, { "key": "wolfenstein-item-pistol", "path": null },

View File

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

View File

@ -62,6 +62,8 @@ const FACING_DIRS = {
// null` (just Erase) gets its label alone. The tool id actually used by // 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 // applyTool()/WALL_TOOLS is either the selected option's id, or — for a
// dropdown-less category — the category's own id. // dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [ { id: 'wall', label: 'Wall', options: [
{ id: 'wall1', label: 'Stone' }, { id: 'wall1', label: 'Stone' },
@ -69,6 +71,13 @@ const CATEGORIES = [
{ id: 'wall3', label: 'Blue' }, { id: 'wall3', label: 'Blue' },
{ id: 'wall4', label: 'Green' }, { 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 <select> to patch into rather than a hardcoded list,
// since new decal frames are meant to be added to that JSON alone, no
// code changes. Each option's id is `${WALLART_PREFIX}${frame}` so
// applyTool can recover which frame is selected without a second lookup.
{ id: 'wallart', label: 'Wall Art', options: [] },
{ id: 'door', label: 'Door', options: [ { id: 'door', label: 'Door', options: [
{ id: 'door', label: 'Normal' }, { id: 'door', label: 'Normal' },
] }, ] },
@ -134,6 +143,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshLoadOptions(); this.refreshLoadOptions();
}).catch(() => {}); }).catch(() => {});
this.wallArtFrames = [];
fetch('data/wolfenstein-wallart.json').then((r) => r.json())
.then((d) => {
this.wallArtFrames = d.frames ?? [];
this.refreshWallArtOptions();
}).catch(() => {});
this.buildToolbar(); this.buildToolbar();
this.buildLoadPanel(); this.buildLoadPanel();
this.buildMinimap(); this.buildMinimap();
@ -153,7 +169,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
campaignId: null, missionIndex: 0, campaignId: null, missionIndex: 0,
width: W, height: H, cellSize: 64, width: W, height: H, cellSize: 64,
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [], doors: [], enemies: [], items: [], wallArt: [],
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 }, exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
briefing: [], briefing: [],
}; };
@ -317,6 +333,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1), doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1), enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1), items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null, 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, exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null,
}; };
@ -378,6 +395,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.loadSelect.innerHTML = opts; this.loadSelect.innerHTML = opts;
} }
/** Patches the Wall Art category's <select> 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) => `<option value="${WALLART_PREFIX}${f.frame}">${f.name}</option>`).join('');
}
flashLoadWarn(msg) { flashLoadWarn(msg) {
if (!this.loadWarn) return; if (!this.loadWarn) return;
this.loadWarn.textContent = msg; this.loadWarn.textContent = msg;
@ -404,6 +428,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
doors: clone.doors ?? [], doors: clone.doors ?? [],
enemies: clone.enemies ?? [], enemies: clone.enemies ?? [],
items: clone.items ?? [], items: clone.items ?? [],
wallArt: clone.wallArt ?? [],
playerStart: clone.playerStart ?? null, playerStart: clone.playerStart ?? null,
exit: clone.exit ?? null, exit: clone.exit ?? null,
}; };
@ -441,7 +466,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.add.text(x, y + size + 16, 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' '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 }); { 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), doors: lvl.doors.map(shift),
enemies: lvl.enemies.map(shift), enemies: lvl.enemies.map(shift),
items: lvl.items.map(shift), items: lvl.items.map(shift),
wallArt: (lvl.wallArt ?? []).map(shift),
playerStart: lvl.playerStart ? shift(lvl.playerStart) : null, playerStart: lvl.playerStart ? shift(lvl.playerStart) : null,
exit: lvl.exit ? shift(lvl.exit) : null, exit: lvl.exit ? shift(lvl.exit) : null,
}; };
@ -613,11 +640,18 @@ export default class WolfensteinEditor extends Phaser.Scene {
applyTool(x, y) { applyTool(x, y) {
const lvl = this.level; 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 (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 === '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 lvl.walls[y][x] = 0; // every remaining tool places on floor
this.clearWallArtAt(x, y);
if (this.tool === 'door') { if (this.tool === 'door') {
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); 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' }); 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 }); 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) { clearEntitiesAt(x, y) {
const lvl = this.level; const lvl = this.level;
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); 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); 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); } 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) { if (lvl.playerStart) {
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);

View File

@ -57,9 +57,15 @@ export function createState(level, rules) {
const cooldowns = {}; const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0; 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 { return {
tick: 0, accumulatorMs: 0, alpha: 0, tick: 0, accumulatorMs: 0, alpha: 0,
map: { width: level.width, height: level.height, walls }, map: { width: level.width, height: level.height, walls, wallArt },
player: { player: {
x: level.playerStart.x, y: level.playerStart.y, x: level.playerStart.x, y: level.playerStart.y,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
@ -573,11 +579,21 @@ function stepProjectiles(state, rules) {
const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]); const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]);
if (proj.friendly) { if (proj.friendly) {
// Ammo hitting an enemy both damages and stuns it (unless the // 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); damageEnemy(state, bestTarget, dmg);
if (!bestTarget.dead) { if (!bestTarget.dead) {
bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0; bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0;
state.events.push({ t: 'enemyStunned', id: bestTarget.id }); state.events.push({ t: 'enemyStunned', id: bestTarget.id });
if (bestTarget.state === 'idle') {
bestTarget.state = 'alert';
state.events.push({ t: 'enemyAlert', id: bestTarget.id });
}
} }
} else { } else {
applyDamageToPlayer(state, dmg); applyDamageToPlayer(state, dmg);
@ -647,6 +663,7 @@ export function buildLevelModel(levelJson) {
doors: (levelJson.doors ?? []).map((d) => ({ ...d })), doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })), enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })), items: (levelJson.items ?? []).map((it) => ({ ...it })),
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
exit: levelJson.exit ? { ...levelJson.exit } : null, exit: levelJson.exit ? { ...levelJson.exit } : null,
briefing: (levelJson.briefing ?? []).slice(), briefing: (levelJson.briefing ?? []).slice(),
}; };
@ -717,6 +734,9 @@ export function validateLevel(level) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) }; 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`); 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); const reachable = bfsReachable(level, startCell, exitCell);
if (!reachable) issues.push('exit is not reachable from playerStart'); if (!reachable) issues.push('exit is not reachable from playerStart');
@ -755,7 +775,7 @@ export function serialize(state) {
return JSON.stringify({ return JSON.stringify({
v: SAVE_VERSION, v: SAVE_VERSION,
tick: state.tick, accumulatorMs: state.accumulatorMs, 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 } }, player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } },
enemies: state.enemies.map((e) => ({ ...e })), enemies: state.enemies.map((e) => ({ ...e })),
projectiles: state.projectiles.map((p) => ({ ...p })), projectiles: state.projectiles.map((p) => ({ ...p })),
@ -773,7 +793,14 @@ export function deserialize(rules, raw) {
if (!data || data.v !== SAVE_VERSION) return null; if (!data || data.v !== SAVE_VERSION) return null;
return { return {
tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0, 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 }, player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false },
enemies: data.enemies.map((e) => ({ ...e })), enemies: data.enemies.map((e) => ({ ...e })),
projectiles: data.projectiles.map((p) => ({ ...p })), projectiles: data.projectiles.map((p) => ({ ...p })),

View File

@ -96,6 +96,8 @@ export default class WolfensteinView {
this.wallTexture = scene.textures.exists('wolfenstein-walls') this.wallTexture = scene.textures.exists('wolfenstein-walls')
? scene.textures.get('wolfenstein-walls') : null; ? 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') this.doorTexture = scene.textures.exists('wolfenstein-doors')
? scene.textures.get('wolfenstein-doors') : null; ? scene.textures.get('wolfenstein-doors') : null;
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet') this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
@ -134,6 +136,11 @@ export default class WolfensteinView {
ctx.fillStyle = '#4a4a4a'; ctx.fillStyle = '#4a4a4a';
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); 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); const cols = castColumns(map, camera, NUM_COLUMNS, doors);
for (let i = 0; i < NUM_COLUMNS; i++) { for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i]; const hit = cols[i];
@ -155,26 +162,43 @@ export default class WolfensteinView {
continue; 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]]; const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]];
if (frame) { if (frame) {
const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width)); 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 srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height;
const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height; const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height;
ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h); 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 { } else {
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa; ctx.fillStyle = `#${(WALL_COLORS[hit.wallType] ?? 0xaaaaaa).toString(16).padStart(6, '0')}`;
ctx.fillStyle = shadeColor(base, hit);
ctx.fillRect(x, y, w, h); 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(); this.canvasTexture.refresh();
} }

View File

@ -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) (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. 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` ## Door textures — `sheets.doors`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls. `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.