Add object props (solid obstacles) to Wolfenstein E1M1

Introduces a new "Object" entity type that blocks movement but not sight/bullets, distinct from walls and pickups:

- **Data**: New `objects` array in level JSON (`{x, y, frame}`), with `data/wolfenstein-objects.json` as the frame registry (Tall Bush, Gold Eagle)
- **Artwork**: New `objects.png` spritesheet registered in `wolfenstein-artwork.json`; placeholder texture via `paintObject()` in WolfensteinArt.js
- **Logic**: Objects stored in `state.objects`, with a derived `map.objectBlocked` Set checked only by movement collision (`isWallCell`) — raycaster/sight/bullets never read it, so objects are see-through and shoot-through by construction
- **View**: Rendered as billboard sprites in `_drawSprites` with ground-anchored positioning (bottom edge on floor) and asymptotic depth sorting that stays below the weapon viewmodel
- **Editor**: New "Object" tool category with dynamic dropdown; click open floor to place/remove (toggle), click wall is a no-op; teal square indicator on board
- **Validation**: `validateLevel` checks objects sit on open floor; save/load round-trips with graceful degradation for pre-existing saves
- **Tests**: Verifies object blocks player movement but bullets pass through
This commit is contained in:
Brian Fertig 2026-08-22 12:29:55 -06:00
parent aa685dc505
commit a5b37038d2
13 changed files with 316 additions and 13 deletions

View File

@ -4,7 +4,7 @@
"name": "First Blood", "name": "First Blood",
"campaignId": null, "campaignId": null,
"missionIndex": 0, "missionIndex": 0,
"width": 19, "width": 20,
"height": 21, "height": 21,
"cellSize": 64, "cellSize": 64,
"walls": [ "walls": [
@ -27,6 +27,7 @@
1, 1,
1, 1,
1, 1,
1,
1 1
], ],
[ [
@ -48,6 +49,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -69,6 +71,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -90,6 +93,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -111,6 +115,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -132,6 +137,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -153,6 +159,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -174,6 +181,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -195,6 +203,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -216,6 +225,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -237,6 +247,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -258,6 +269,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -279,6 +291,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -300,6 +313,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -321,6 +335,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -342,6 +357,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -363,6 +379,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -384,6 +401,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -405,6 +423,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -426,6 +445,7 @@
0, 0,
0, 0,
0, 0,
1,
1 1
], ],
[ [
@ -447,6 +467,7 @@
1, 1,
1, 1,
1, 1,
1,
1 1
] ]
], ],
@ -544,5 +565,37 @@
"y": 15, "y": 15,
"frame": 1 "frame": 1
} }
],
"objects": [
{
"x": 8,
"y": 17,
"frame": 1
},
{
"x": 8,
"y": 19,
"frame": 1
},
{
"x": 12,
"y": 17,
"frame": 1
},
{
"x": 12,
"y": 19,
"frame": 1
},
{
"x": 8,
"y": 9,
"frame": 0
},
{
"x": 6,
"y": 9,
"frame": 0
}
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

View File

@ -4,7 +4,8 @@
"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 } "wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 },
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.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 objects decal sheet (assets/images/wolfenstein/objects.png, sheets.objects in wolfenstein-artwork.json, 64x64 frames, transparent PNGs). Objects are solid props placed on open floor in the editor — they block movement but not sight/bullets, and can't be picked up. To add a new object: paint the next 64x64 tile into objects.png, then append an entry here — WolfensteinEditor.js's Object dropdown reads this file directly, no code changes needed.",
"frames": [
{ "frame": 0, "id": "tall-bush", "name": "Tall Bush" },
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" }
]
}

View File

@ -22,6 +22,7 @@ export function ensureSprites(scene) {
paintBullet(scene); paintBullet(scene);
paintMuzzleFlash(scene); paintMuzzleFlash(scene);
paintWeaponPistol(scene); paintWeaponPistol(scene);
paintObject(scene);
} }
function paintGuard(scene) { function paintGuard(scene) {
@ -94,3 +95,18 @@ function paintWeaponPistol(scene) {
g.generateTexture('wolf-weapon-pistol', W, H); g.generateTexture('wolf-weapon-pistol', W, H);
g.destroy(); g.destroy();
} }
// One flat placeholder for every object frame (same "single generic
// stand-in, real art per-frame lands later" idiom as wolf-guard) — a
// rounded block reads reasonably as "a solid obstacle" regardless of which
// actual prop (bush, statue, ...) a given frame turns out to be.
function paintObject(scene) {
const S = 64;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x3a6b3a, 1);
g.fillRoundedRect(S * 0.12, S * 0.1, S * 0.76, S * 0.85, 6);
g.lineStyle(3, 0x1f3a1f, 0.8);
g.strokeRoundedRect(S * 0.12, S * 0.1, S * 0.76, S * 0.85, 6);
g.generateTexture('wolf-object', S, S);
g.destroy();
}

View File

@ -63,6 +63,7 @@ const FACING_DIRS = {
// 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 WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [ { id: 'wall', label: 'Wall', options: [
@ -81,6 +82,11 @@ const CATEGORIES = [
{ id: 'door', label: 'Door', options: [ { id: 'door', label: 'Door', options: [
{ id: 'door', label: 'Normal' }, { id: 'door', label: 'Normal' },
] }, ] },
// Same dynamic-dropdown treatment as Wall Art (populated from
// data/wolfenstein-objects.json, see refreshObjectOptions), except an
// object is placed on open FLOOR (walls[y][x] === 0) rather than an
// existing wall — the exact complementary requirement to Wall Art's.
{ id: 'object', label: 'Object', options: [] },
{ id: 'pickup', label: 'Pickup', options: [ { id: 'pickup', label: 'Pickup', options: [
{ id: 'health', label: 'Health' }, { id: 'health', label: 'Health' },
{ id: 'ammo', label: 'Ammo' }, { id: 'ammo', label: 'Ammo' },
@ -150,6 +156,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshWallArtOptions(); this.refreshWallArtOptions();
}).catch(() => {}); }).catch(() => {});
this.objectFrames = [];
fetch('data/wolfenstein-objects.json').then((r) => r.json())
.then((d) => {
this.objectFrames = d.frames ?? [];
this.refreshObjectOptions();
}).catch(() => {});
this.buildToolbar(); this.buildToolbar();
this.buildLoadPanel(); this.buildLoadPanel();
this.buildMinimap(); this.buildMinimap();
@ -169,7 +182,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: [], wallArt: [], doors: [], enemies: [], items: [], wallArt: [], objects: [],
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: [],
}; };
@ -334,6 +347,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
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), wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
objects: (this.level.objects ?? []).filter((o) => o.x < w - 1 && o.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,
}; };
@ -402,6 +416,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.innerHTML = this.wallArtFrames.map((f) => `<option value="${WALLART_PREFIX}${f.frame}">${f.name}</option>`).join(''); select.innerHTML = this.wallArtFrames.map((f) => `<option value="${WALLART_PREFIX}${f.frame}">${f.name}</option>`).join('');
} }
/** Patches the Object category's <select> once data/wolfenstein-objects.json loads — mirrors refreshWallArtOptions above. */
refreshObjectOptions() {
const select = this.toolPanelDom?.node?.querySelector('select[data-cat="object"]');
if (!select) return;
select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_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;
@ -429,6 +450,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
enemies: clone.enemies ?? [], enemies: clone.enemies ?? [],
items: clone.items ?? [], items: clone.items ?? [],
wallArt: clone.wallArt ?? [], wallArt: clone.wallArt ?? [],
objects: clone.objects ?? [],
playerStart: clone.playerStart ?? null, playerStart: clone.playerStart ?? null,
exit: clone.exit ?? null, exit: clone.exit ?? null,
}; };
@ -467,7 +489,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\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\n\n'
+ 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).', + 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).\n\n'
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.',
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 }); { fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
} }
@ -630,6 +653,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
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), wallArt: (lvl.wallArt ?? []).map(shift),
objects: (lvl.objects ?? []).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,
}; };
@ -649,6 +673,11 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); this.clearWallArtAt(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; } if (this.tool.startsWith(WALLART_PREFIX)) { this.applyWallArtTool(x, y, Number(this.tool.slice(WALLART_PREFIX.length))); return; }
// Objects need OPEN floor, not a wall — the exact opposite requirement
// of Wall Art — so this one does NOT fall through to the shared
// "every remaining tool places on floor" line below; a click on a wall
// cell is simply a no-op (erase the wall first).
if (this.tool.startsWith(OBJECT_PREFIX)) { this.applyObjectTool(x, y, Number(this.tool.slice(OBJECT_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); this.clearWallArtAt(x, y);
@ -724,6 +753,29 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.wallArt = (lvl.wallArt ?? []).filter((w) => !(w.x === x && w.y === y)); lvl.wallArt = (lvl.wallArt ?? []).filter((w) => !(w.x === x && w.y === y));
} }
/**
* Objects: only OPEN floor (walls[y][x] === 0) is a valid spot the
* mirror image of Wall Art's "only an actual wall" requirement. Clicking
* an empty floor cell applies the selected frame; clicking a cell that
* already has an object removes it same straight-toggle-regardless-
* of-selected-frame convention as Wall Art. Placing over another entity
* type (door/enemy/item) isn't specially prevented nothing else in
* this editor enforces mutual exclusivity between entity types either
* (an enemy and an item have always been able to share a cell), so
* Objects doesn't invent a stricter rule just for itself.
*/
applyObjectTool(x, y, frame) {
const lvl = this.level;
if (lvl.walls[y]?.[x] !== 0) return;
lvl.objects = lvl.objects ?? [];
const i = lvl.objects.findIndex((o) => Math.floor(o.x) === x && Math.floor(o.y) === y);
// Cell-CENTER, like every other billboard-positioned entity (enemies,
// items) — not the raw cell index, which is the cell's corner. Objects
// render as world-space sprites (unlike Wall Art, which is a per-cell
// texture lookup keyed by the raw integer index, so it's correct as-is).
if (i >= 0) lvl.objects.splice(i, 1); else lvl.objects.push({ x: x + 0.5, y: y + 0.5, frame });
}
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);
@ -732,6 +784,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y)); 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.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)); lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
lvl.objects = (lvl.objects ?? []).filter((o) => !(Math.floor(o.x) === x && Math.floor(o.y) === y));
if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null; if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null;
if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null; if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null;
} }
@ -852,6 +905,18 @@ export default class WolfensteinEditor extends Phaser.Scene {
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); } else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
} }
// Solid obstacle prop — a distinct teal square (no other marker on the
// board uses this color), same square-fill-plus-border treatment
// regardless of frame; the actual art only distinguishes frames once
// painted and loaded in-game.
g.fillStyle(0x2ec4b6, 1);
g.lineStyle(2, 0x145f57, 1);
for (const o of lvl.objects ?? []) {
const [bx, by] = this.toBoard(o.x, o.y); // o.x/o.y are the cell CENTER
g.fillRect(bx - k * 0.38, by - k * 0.38, k * 0.76, k * 0.76);
g.strokeRect(bx - k * 0.38, by - k * 0.38, k * 0.76, k * 0.76);
}
if (this.tool === 'patrol') this.drawPatrolRoutes(); if (this.tool === 'patrol') this.drawPatrolRoutes();
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker(); if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : ''); this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : '');

View File

@ -63,9 +63,19 @@ export function createState(level, rules) {
// the wall grid itself does. // the wall grid itself does.
const wallArt = (level.wallArt ?? []).map((w) => ({ x: w.x, y: w.y, frame: w.frame })); const wallArt = (level.wallArt ?? []).map((w) => ({ x: w.x, y: w.y, frame: w.frame }));
// Objects (props placed on open floor — block movement, don't block
// sight/bullets, can't be picked up) are a top-level entity list like
// enemies/pickups (rendered as billboarded sprites) that ALSO folds into
// a map-level lookup for the movement-collision path (isWallCell above)
// — the same "lives in two places" shape doors already have (state.doors
// for their own behavior, plus baked into map.walls for collision/sight),
// just without doors' slide animation since objects are inert.
const objects = (level.objects ?? []).map((o, i) => ({ id: i, x: o.x, y: o.y, frame: o.frame }));
const objectBlocked = new Set(objects.map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`));
return { return {
tick: 0, accumulatorMs: 0, alpha: 0, tick: 0, accumulatorMs: 0, alpha: 0,
map: { width: level.width, height: level.height, walls, wallArt }, map: { width: level.width, height: level.height, walls, wallArt, objectBlocked },
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,
@ -74,7 +84,7 @@ export function createState(level, rules) {
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false, pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, radius: rules.constants.playerRadius, dead: false, cooldowns, radius: rules.constants.playerRadius, dead: false,
}, },
enemies, projectiles: [], pickups, doors, enemies, projectiles: [], pickups, doors, objects,
exit: { ...level.exit }, exit: { ...level.exit },
events: [], nextProjectileId: 1, result: null, events: [], nextProjectileId: 1, result: null,
levelMeta: { levelMeta: {
@ -150,7 +160,13 @@ function wrapAngle(a) {
function isWallCell(map, cx, cy) { function isWallCell(map, cx, cy) {
if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true; if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true;
return map.walls[cy][cx] > 0; if (map.walls[cy][cx] > 0) return true;
// Objects (see state.objects in createState) block movement the same as
// a wall cell would, but — unlike a real wall — are never written into
// map.walls itself, since the raycaster (sight/bullets) must still pass
// straight through them. Only this movement-collision path checks
// map.objectBlocked; castRay/hasLineOfSight never do.
return map.objectBlocked?.has(`${cx},${cy}`) ?? false;
} }
function circleHitsWall(map, x, y, radius) { function circleHitsWall(map, x, y, radius) {
@ -664,6 +680,7 @@ export function buildLevelModel(levelJson) {
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 })), wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
objects: (levelJson.objects ?? []).map((o) => ({ ...o })),
exit: levelJson.exit ? { ...levelJson.exit } : null, exit: levelJson.exit ? { ...levelJson.exit } : null,
briefing: (levelJson.briefing ?? []).slice(), briefing: (levelJson.briefing ?? []).slice(),
}; };
@ -737,6 +754,14 @@ export function validateLevel(level) {
for (const w of level.wallArt ?? []) { 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`); if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
} }
for (const o of level.objects ?? []) {
// Floor first — an object's x/y are cell-CENTER floats (e.g. 3.5), like
// enemies/items above, not raw cell indices; walls[o.y]?.[o.x] on an
// un-floored float silently indexes nothing (arrays have no fractional
// keys) and this check would never fire.
const c = { x: Math.floor(o.x), y: Math.floor(o.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`object (${o.x},${o.y}) sits inside 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');
@ -781,6 +806,7 @@ export function serialize(state) {
projectiles: state.projectiles.map((p) => ({ ...p })), projectiles: state.projectiles.map((p) => ({ ...p })),
pickups: state.pickups.map((p) => ({ ...p })), pickups: state.pickups.map((p) => ({ ...p })),
doors: state.doors.map((d) => ({ ...d })), doors: state.doors.map((d) => ({ ...d })),
objects: state.objects.map((o) => ({ ...o })),
exit: state.exit, result: state.result, exit: state.exit, result: state.result,
nextProjectileId: state.nextProjectileId, nextProjectileId: state.nextProjectileId,
levelMeta: state.levelMeta, levelMeta: state.levelMeta,
@ -793,19 +819,23 @@ 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,
// wallArt defaults to [] for a save taken before this feature existed — // wallArt/objects default to [] for a save taken before those features
// purely cosmetic, static data, so a missing field degrades to "no // existed — both are static data (objects have collision, but that's
// decals" instead of needing a SAVE_VERSION bump. // rebuilt fresh below from the same array, not stored separately), so a
// missing field degrades to "none" instead of needing a SAVE_VERSION
// bump.
map: { map: {
width: data.map.width, height: data.map.height, width: data.map.width, height: data.map.height,
walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height), walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height),
wallArt: data.map.wallArt ?? [], wallArt: data.map.wallArt ?? [],
objectBlocked: new Set((data.objects ?? []).map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`)),
}, },
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 })),
pickups: data.pickups.map((p) => ({ ...p })), pickups: data.pickups.map((p) => ({ ...p })),
doors: data.doors.map((d) => ({ ...d })), doors: data.doors.map((d) => ({ ...d })),
objects: (data.objects ?? []).map((o) => ({ ...o })),
exit: data.exit, events: [], result: data.result ?? null, exit: data.exit, events: [], result: data.result ?? null,
nextProjectileId: data.nextProjectileId, nextProjectileId: data.nextProjectileId,
levelMeta: data.levelMeta, levelMeta: data.levelMeta,

View File

@ -36,6 +36,11 @@ export const NUM_COLUMNS = 480;
// generic per-column wall-texture path below at all. // generic per-column wall-texture path below at all.
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 }; const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
// Billboard scale for a floor-standing object prop — angle-independent (a
// single frame, no facing/walk variants like the guard sheet), so there's
// no per-object logic beyond picking the right sheet frame.
const OBJECT_SCALE = 0.8;
// Frame indices in the `wolfenstein-guard-sheet` sheet (128x128 cells, // Frame indices in the `wolfenstein-guard-sheet` sheet (128x128 cells,
// row-major — see sprites.md). Only the sheet's first row (frames 0-8) is // row-major — see sprites.md). Only the sheet's first row (frames 0-8) is
// populated; further columns/rows sit unused until more enemy types land. // populated; further columns/rows sit unused until more enemy types land.
@ -64,8 +69,19 @@ const DEATH_FADE_MS = 800;
// POV weapon viewmodel — sits above the 3D view canvas (depth 10) and the // POV weapon viewmodel — sits above the 3D view canvas (depth 10) and the
// sprite billboards, below the bottom HUD bar (depth 20, see // sprite billboards, below the bottom HUD bar (depth 20, see
// WolfensteinGame._buildHud) so it peeks up from behind the ammo bar like a // WolfensteinGame._buildHud) so it peeks up from behind the ammo bar like a
// classic FPS viewmodel, and below the crosshair (depth 15). // classic FPS viewmodel, and below the crosshair (depth 15). Sprite depths
// (see _drawSprites' SPRITE_DEPTH_* below) are kept strictly under this so a
// sprite standing right in front of the camera — most visibly a corpse the
// player is standing over, since unlike a live enemy it never moves away —
// can never render on top of the gun.
const WEAPON_DEPTH = 12; const WEAPON_DEPTH = 12;
// Sprite render-depth range (see _drawSprites): base value for an
// infinitely-far sprite, and how far above that a sprite right at the near
// clip can climb — kept well under WEAPON_DEPTH with margin to spare, both
// bounds well clear of the 3D view canvas below (depth 10) and the
// crosshair above (depth 15).
const SPRITE_DEPTH_BASE = 10;
const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1;
// Bob/sway share one phase accumulator that only advances while the player // Bob/sway share one phase accumulator that only advances while the player
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms. // is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
const WEAPON_BOB_SPEED = 0.012; const WEAPON_BOB_SPEED = 0.012;
@ -102,6 +118,8 @@ export default class WolfensteinView {
? 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')
? scene.textures.get('wolfenstein-guard-sheet') : null; ? scene.textures.get('wolfenstein-guard-sheet') : null;
this.objectsTexture = scene.textures.exists('wolfenstein-objects')
? scene.textures.get('wolfenstein-objects') : null;
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT // Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT
// (1920x1080) with the gun already positioned bottom-center against a // (1920x1080) with the gun already positioned bottom-center against a
@ -266,6 +284,12 @@ export default class WolfensteinView {
for (const proj of state.projectiles) { for (const proj of state.projectiles) {
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 }); sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 });
} }
for (const o of state.objects) {
sprites.push({
key: `object:${o.id}`, x: o.x, y: o.y, tex: 'wolf-object', scale: OBJECT_SCALE, objFrame: o.frame,
groundAnchored: true,
});
}
const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY); const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY);
for (const s of sprites) { for (const s of sprites) {
@ -292,13 +316,40 @@ export default class WolfensteinView {
if (s.guardFrame != null && this.guardTexture) { if (s.guardFrame != null && this.guardTexture) {
img.setTexture('wolfenstein-guard-sheet', s.guardFrame); img.setTexture('wolfenstein-guard-sheet', s.guardFrame);
img.setFlipX(s.guardFlip); img.setFlipX(s.guardFlip);
} else if (s.objFrame != null && this.objectsTexture) {
img.setTexture('wolfenstein-objects', s.objFrame);
img.setFlipX(false);
} else { } else {
img.setTexture(s.tex); img.setTexture(s.tex);
img.setFlipX(false); img.setFlipX(false);
} }
img.setPosition(centerX, VIEW_H / 2); // Every other sprite (enemies, pickups, bullets) is vertically
// centered on the horizon line (VIEW_H/2) — the same "camera sits at
// half wall-height" assumption a wall column's own rawStart/rawEnd
// uses, which happens to read fine for roughly player-height
// characters. A ground object needs its BOTTOM edge — not its
// center — sitting on the floor: `VIEW_H/2 + fullLineHeight/2` is
// exactly where a full-height wall's own floor edge falls at this
// depth (see _drawWalls' rawEnd), so anchoring there instead of at
// the horizon is what keeps a shorter (scale<1) object grounded
// instead of floating half its height above the floor.
const centerY = s.groundAnchored
? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2
: VIEW_H / 2;
img.setPosition(centerX, centerY);
img.setDisplaySize(spriteSize, spriteSize); img.setDisplaySize(spriteSize, spriteSize);
img.setDepth(10 + Math.max(0, 100 - s._depth)); // Asymptotic, not clamped: approaches (but can never reach)
// SPRITE_DEPTH_BASE + SPRITE_DEPTH_SPAN as s._depth -> 0, so two
// sprites at different distances always still get distinct depth
// values (nearer renders over farther when their billboards overlap
// on screen) — a hard min()/max() clamp would flatten every close
// sprite to the same ceiling value instead, at the exact distances
// (right in front of the camera) where getting the order right
// matters most. The old `10 + max(0, 100 - depth)` formula had no
// ceiling at all and could reach ~110 for a near sprite — comfortably
// over WEAPON_DEPTH (12), which is exactly how a corpse standing
// distance away used to render on top of the POV gun.
img.setDepth(SPRITE_DEPTH_BASE + SPRITE_DEPTH_SPAN / (1 + s._depth));
img.setAlpha(s.alpha ?? 1); img.setAlpha(s.alpha ?? 1);
// A sprite wide enough to span several columns can have a wall corner // A sprite wide enough to span several columns can have a wall corner

View File

@ -61,6 +61,62 @@ alongside every floor-creating branch). Decorated walls get a persistent
yellow outline on the board regardless of which tool is active, the same yellow outline on the board regardless of which tool is active, the same
"always visible" treatment doors get. "always visible" treatment doors get.
## Objects (obstacle props) — `sheets.objects`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike wall art this is a floor-standing prop, rendered as a billboard
sprite (like an enemy/pickup), not baked into a wall column. **Wired up
2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png`
(currently 512×512, 8×8 = 64 frames of headroom; only frames 0-1 assigned so
far). Frame names live in `data/wolfenstein-objects.json` — same
`{ frames: [{ frame, id, name }] }` shape and same "paint the next tile,
append one entry, no code changes" workflow as `wolfenstein-wallart.json`.
Current frames: `0` = Tall Bush, `1` = Gold Eagle.
Gameplay contract: an object blocks *movement* (player and enemies alike)
but not sight or bullets — you can shoot through one, an enemy can see and
fire through one, but nothing can walk through it. Level data:
`level.objects: [{ x, y, frame }]`, a sparse per-cell list (like
`wallArt`/`items`), validated to sit on *open floor* (`walls[y][x] === 0` —
the exact opposite requirement of wall art's "must be on a wall"). At
runtime it's a top-level `state.objects` list (rendered like `pickups`) that
also derives a `state.map.objectBlocked` Set (`"x,y"` keys) purely for
movement collision — `WolfensteinLogic.isWallCell` (used only by the
player/enemy movement-collision path, `circleHitsWall`/`moveWithCollision`)
checks this Set in addition to the wall grid, while the raycaster
(`castRay`/`hasLineOfSight`, driving both sight and every bullet's wall
check in `stepProjectiles`) never reads it at all — objects are never
written into `map.walls`, which is what keeps them see-through/shoot-through
by construction rather than by a special-case exception somewhere. Static
data — round-trips through save/load, degrading to "no objects" for a save
predating this feature (no `SAVE_VERSION` bump needed, same reasoning as
wall art, except objects DO have collision behavior — but that's rebuilt
fresh from the restored `objects` array on load, never itself serialized).
Rendering: pushed into `WolfensteinView._drawSprites`' shared sprite list
exactly like a pickup, with `objFrame` selecting the sheet frame the same
way `guardFrame` does for guards — occluded against the wall depth buffer
(and clipped by a wall corner) through the same shared per-column mechanism
every other sprite uses, no special-casing needed. Falls back to a flat
placeholder block (`WolfensteinArt.paintObject`, `wolf-object` texture) if
the sheet isn't loaded, same convention as every other sheet here.
Editor (`WolfensteinEditor.js`): an "Object" tool category, dynamic dropdown
like Wall Art's (populated from the JSON registry above). Clicking open
floor with a frame selected toggles that cell's entry in `level.objects`
(apply if absent, remove if present, regardless of which frame is currently
selected — same convention as Wall Art). A click on a wall cell is a no-op
(erase the wall first) rather than force-converting it to floor the way
door/start/exit/enemy/pickup placement does — an object sitting inside a
wall makes no sense, so this tool deliberately doesn't offer that
convenience. Placing a wall over a cell (or erasing it) clears any object
there via `clearEntitiesAt`; placing another entity type (door/enemy/item)
on the same cell as an existing object is not specially prevented — nothing
else in this editor enforces mutual exclusivity between entity types
either. Rendered on the board as a plain teal square (no per-frame visual
distinction — the editor doesn't load real object art for preview, same as
every other entity type here).
## 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.

View File

@ -175,6 +175,30 @@ section('3. Bullet swept-collision correctness');
check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health); check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health);
check('a surviving projectile is kept, not detonated', state.projectiles.length === 1); check('a surviving projectile is kept, not detonated', state.projectiles.length === 1);
} }
// Objects block movement but not bullets — the opposite of a wall, which
// blocks both. Checked on the same placed object so there's no ambiguity
// about which property is actually under test.
{
const objLevel = { ...level, objects: [{ x: 4, y: 1, frame: 0 }] };
const state = L.createState(objLevel, rules);
check('object cell is registered as movement-blocking', state.map.objectBlocked.has('4,1'));
L.setMoveIntent(state, 1, 0); // player starts at (1.5,1.5) facing east (angle 0), object's west face is at x=4
for (let i = 0; i < 300; i++) L.tick(state, rules);
check('player cannot walk through an object', state.player.x < 3.7, state.player.x);
L.setMoveIntent(state, 0, 0);
state.player.x = 1.5; state.player.y = 1.5; state.player.angle = 0;
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
let impact = null;
for (let i = 0; i < 200 && !impact; i++) {
const events = L.tick(state, rules);
impact = events.find((e) => e.t === 'impact');
}
check('bullets pass through an object instead of detonating on it', impact && impact.x > 6, impact);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------