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:
parent
7d9729e594
commit
c0ab9155a3
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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 <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: 'Normal' },
|
||||
] },
|
||||
|
|
@ -134,6 +143,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.refreshLoadOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.wallArtFrames = [];
|
||||
fetch('data/wolfenstein-wallart.json').then((r) => r.json())
|
||||
.then((d) => {
|
||||
this.wallArtFrames = d.frames ?? [];
|
||||
this.refreshWallArtOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.buildToolbar();
|
||||
this.buildLoadPanel();
|
||||
this.buildMinimap();
|
||||
|
|
@ -153,7 +169,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
campaignId: null, missionIndex: 0,
|
||||
width: W, height: H, cellSize: 64,
|
||||
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 },
|
||||
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),
|
||||
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),
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
} else {
|
||||
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';
|
||||
} else {
|
||||
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
|
||||
ctx.fillStyle = shadeColor(base, hit);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
this.canvasTexture.refresh();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue