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:
parent
aa685dc505
commit
a5b37038d2
|
|
@ -4,7 +4,7 @@
|
|||
"name": "First Blood",
|
||||
"campaignId": null,
|
||||
"missionIndex": 0,
|
||||
"width": 19,
|
||||
"width": 20,
|
||||
"height": 21,
|
||||
"cellSize": 64,
|
||||
"walls": [
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -69,6 +71,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -90,6 +93,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -111,6 +115,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -132,6 +137,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -153,6 +159,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -174,6 +181,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -195,6 +203,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -216,6 +225,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -237,6 +247,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -258,6 +269,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -279,6 +291,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -300,6 +313,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -321,6 +335,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -342,6 +357,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -363,6 +379,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -384,6 +401,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -405,6 +423,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -426,6 +445,7 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -447,6 +467,7 @@
|
|||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
|
|
@ -544,5 +565,37 @@
|
|||
"y": 15,
|
||||
"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.
|
|
@ -4,7 +4,8 @@
|
|||
"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 },
|
||||
"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": [
|
||||
{ "key": "wolfenstein-item-pistol", "path": null },
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export function ensureSprites(scene) {
|
|||
paintBullet(scene);
|
||||
paintMuzzleFlash(scene);
|
||||
paintWeaponPistol(scene);
|
||||
paintObject(scene);
|
||||
}
|
||||
|
||||
function paintGuard(scene) {
|
||||
|
|
@ -94,3 +95,18 @@ function paintWeaponPistol(scene) {
|
|||
g.generateTexture('wolf-weapon-pistol', W, H);
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const FACING_DIRS = {
|
|||
// 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 OBJECT_PREFIX = 'object:';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'wall', label: 'Wall', options: [
|
||||
|
|
@ -81,6 +82,11 @@ const CATEGORIES = [
|
|||
{ id: 'door', label: 'Door', options: [
|
||||
{ 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: 'health', label: 'Health' },
|
||||
{ id: 'ammo', label: 'Ammo' },
|
||||
|
|
@ -150,6 +156,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.refreshWallArtOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.objectFrames = [];
|
||||
fetch('data/wolfenstein-objects.json').then((r) => r.json())
|
||||
.then((d) => {
|
||||
this.objectFrames = d.frames ?? [];
|
||||
this.refreshObjectOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.buildToolbar();
|
||||
this.buildLoadPanel();
|
||||
this.buildMinimap();
|
||||
|
|
@ -169,7 +182,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: [], wallArt: [],
|
||||
doors: [], enemies: [], items: [], wallArt: [], objects: [],
|
||||
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
|
||||
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),
|
||||
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),
|
||||
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,
|
||||
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('');
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (!this.loadWarn) return;
|
||||
this.loadWarn.textContent = msg;
|
||||
|
|
@ -429,6 +450,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
enemies: clone.enemies ?? [],
|
||||
items: clone.items ?? [],
|
||||
wallArt: clone.wallArt ?? [],
|
||||
objects: clone.objects ?? [],
|
||||
playerStart: clone.playerStart ?? null,
|
||||
exit: clone.exit ?? null,
|
||||
};
|
||||
|
|
@ -467,7 +489,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\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 });
|
||||
}
|
||||
|
||||
|
|
@ -630,6 +653,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
enemies: lvl.enemies.map(shift),
|
||||
items: lvl.items.map(shift),
|
||||
wallArt: (lvl.wallArt ?? []).map(shift),
|
||||
objects: (lvl.objects ?? []).map(shift),
|
||||
playerStart: lvl.playerStart ? shift(lvl.playerStart) : 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 === 'patrol') { this.applyPatrolTool(x, y); 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
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const lvl = this.level;
|
||||
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.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.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.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); }
|
||||
}
|
||||
|
||||
// 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.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
|
||||
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing' : '');
|
||||
|
|
|
|||
|
|
@ -63,9 +63,19 @@ export function createState(level, rules) {
|
|||
// the wall grid itself does.
|
||||
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 {
|
||||
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: {
|
||||
x: level.playerStart.x, y: level.playerStart.y,
|
||||
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,
|
||||
cooldowns, radius: rules.constants.playerRadius, dead: false,
|
||||
},
|
||||
enemies, projectiles: [], pickups, doors,
|
||||
enemies, projectiles: [], pickups, doors, objects,
|
||||
exit: { ...level.exit },
|
||||
events: [], nextProjectileId: 1, result: null,
|
||||
levelMeta: {
|
||||
|
|
@ -150,7 +160,13 @@ function wrapAngle(a) {
|
|||
|
||||
function isWallCell(map, cx, cy) {
|
||||
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) {
|
||||
|
|
@ -664,6 +680,7 @@ export function buildLevelModel(levelJson) {
|
|||
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
|
||||
items: (levelJson.items ?? []).map((it) => ({ ...it })),
|
||||
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
|
||||
objects: (levelJson.objects ?? []).map((o) => ({ ...o })),
|
||||
exit: levelJson.exit ? { ...levelJson.exit } : null,
|
||||
briefing: (levelJson.briefing ?? []).slice(),
|
||||
};
|
||||
|
|
@ -737,6 +754,14 @@ export function validateLevel(level) {
|
|||
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`);
|
||||
}
|
||||
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);
|
||||
if (!reachable) issues.push('exit is not reachable from playerStart');
|
||||
|
|
@ -781,6 +806,7 @@ export function serialize(state) {
|
|||
projectiles: state.projectiles.map((p) => ({ ...p })),
|
||||
pickups: state.pickups.map((p) => ({ ...p })),
|
||||
doors: state.doors.map((d) => ({ ...d })),
|
||||
objects: state.objects.map((o) => ({ ...o })),
|
||||
exit: state.exit, result: state.result,
|
||||
nextProjectileId: state.nextProjectileId,
|
||||
levelMeta: state.levelMeta,
|
||||
|
|
@ -793,19 +819,23 @@ export function deserialize(rules, raw) {
|
|||
if (!data || data.v !== SAVE_VERSION) return null;
|
||||
return {
|
||||
tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0,
|
||||
// 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.
|
||||
// wallArt/objects default to [] for a save taken before those features
|
||||
// existed — both are static data (objects have collision, but that's
|
||||
// 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: {
|
||||
width: data.map.width, height: data.map.height,
|
||||
walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height),
|
||||
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 },
|
||||
enemies: data.enemies.map((e) => ({ ...e })),
|
||||
projectiles: data.projectiles.map((p) => ({ ...p })),
|
||||
pickups: data.pickups.map((p) => ({ ...p })),
|
||||
doors: data.doors.map((d) => ({ ...d })),
|
||||
objects: (data.objects ?? []).map((o) => ({ ...o })),
|
||||
exit: data.exit, events: [], result: data.result ?? null,
|
||||
nextProjectileId: data.nextProjectileId,
|
||||
levelMeta: data.levelMeta,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export const NUM_COLUMNS = 480;
|
|||
// generic per-column wall-texture path below at all.
|
||||
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,
|
||||
// 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.
|
||||
|
|
@ -64,8 +69,19 @@ const DEATH_FADE_MS = 800;
|
|||
// POV weapon viewmodel — sits above the 3D view canvas (depth 10) and the
|
||||
// sprite billboards, below the bottom HUD bar (depth 20, see
|
||||
// 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;
|
||||
// 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
|
||||
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
|
||||
const WEAPON_BOB_SPEED = 0.012;
|
||||
|
|
@ -102,6 +118,8 @@ export default class WolfensteinView {
|
|||
? scene.textures.get('wolfenstein-doors') : null;
|
||||
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
|
||||
? 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
|
||||
// (1920x1080) with the gun already positioned bottom-center against a
|
||||
|
|
@ -266,6 +284,12 @@ export default class WolfensteinView {
|
|||
for (const proj of state.projectiles) {
|
||||
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);
|
||||
for (const s of sprites) {
|
||||
|
|
@ -292,13 +316,40 @@ export default class WolfensteinView {
|
|||
if (s.guardFrame != null && this.guardTexture) {
|
||||
img.setTexture('wolfenstein-guard-sheet', s.guardFrame);
|
||||
img.setFlipX(s.guardFlip);
|
||||
} else if (s.objFrame != null && this.objectsTexture) {
|
||||
img.setTexture('wolfenstein-objects', s.objFrame);
|
||||
img.setFlipX(false);
|
||||
} else {
|
||||
img.setTexture(s.tex);
|
||||
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.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);
|
||||
|
||||
// A sprite wide enough to span several columns can have a wall corner
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"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`
|
||||
|
||||
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue