Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig a5b37038d2 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
2026-08-22 12:29:55 -06:00
Brian Fertig aa685dc505 The user wants a git commit message based on the provided diff.
**Analysis of the Diff:**
1.  **Binary Files:** `walls.png` and `walls.psd` are updated. This suggests an asset update, likely related to the "wolfenstein" theme mentioned in the paths.
2.  **JavaScript File:** `tools/verifyWolfenstein.js` is modified.
    *   A new test case is added under `section('4. Enemy AI');`.
    *   The test verifies that an enemy (guard) alerts when hit by a projectile, specifically in a scenario where:
        *   Visual detection (LOS+FOV) is blocked/blind (guard facing away).
        *   "Heard gunfire" alert path is blocked (different room/door logic).
    *   The test sets up a specific level layout, forces a door open, fires a pistol, and checks if the enemy's state changes from 'idle' to something else (alerted) and health drops.
    *   It explicitly mentions fixing an issue with `stepDoors` auto-close countdown underflow by setting `timer`.

**Synthesis:**
The primary functional change is adding a test case to ensure that direct hits alert enemies even when other alert mechanisms (vision, sound through doors) fail. The asset changes are likely incidental or part of the same feature branch (Wolfenstein theme).

**Drafting the Commit Message:**
*   **Subject:** Add test for enemy alert on direct hit bypassing vision/sound checks
*   **Body:**
    *   Update Wolfenstein wall assets (PNG/PSD).
    *   Add verification test in `verifyWolfenstein.js` ensuring enemies alert when shot, even if they cannot see the player or hear gunfire through doors.
    *   Fix door timer initialization in the test to prevent auto-close interference during projectile flight.

**Refining for Conciseness:**
Subject: Add enemy hit-alert test and update Wolfenstein assets

Body:
- Update `walls.png` and `walls.psd`.
- Add a test case in `verifyWolfenstein.js` verifying that direct hits alert enemies unconditionally, independent of vision cone or room-based sound alerts.
- Ensure door state is correctly initialized in the test to prevent auto-close interference with projectile simulation.

Let's make it standard conventional commit style if possible, or just clear and descriptive.

**Final Polish:**
Subject: Add test for unconditional enemy alert on direct hit

Body:
Update Wolfenstein wall assets (`walls.png`, `walls.psd`).

Add a new test case in `verifyWolfenstein.js` to verify that enemies alert when directly hit by a projectile, even when visual detection (LOS/FOV) and sound-based alerts (through doors) are inactive. This ensures the direct-hit alert path works independently.

Also fixes door timer initialization in the test setup to prevent auto-close logic from interfering with the projectile simulation.

This looks good. I will provide just the message as requested.
2026-08-22 10:51:42 -06:00
Brian Fertig c0ab9155a3 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
2026-08-22 10:32:59 -06:00
Brian Fertig 7d9729e594 Improve sprite occlusion handling in Wolfenstein view
Replace all-or-nothing per-sprite depth checks with per-column occlusion
testing. This allows walls to correctly hide only the parts of sprites
(like dead guards) that are behind them, rather than hiding the entire
sprite if any part is occluded.

Implementation details:
- Walk outward from the sprite's center column to determine the visible
  span based on the wall depth buffer.
- Use Phaser's `setCrop` to clip the sprite to the visible columns, which
  is more performant than redrawing per-column like walls.
- Add `OCCLUSION_EPS` tolerance to prevent flickering when sprites are
  positioned exactly against walls due to float precision issues.
2026-08-22 09:58:21 -06:00
18 changed files with 623 additions and 30 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
] ]
], ],
@ -512,5 +533,69 @@
"y": 18.5, "y": 18.5,
"radius": 0.6 "radius": 0.6
}, },
"briefing": [] "briefing": [],
"wallArt": [
{
"x": 11,
"y": 2,
"frame": 0
},
{
"x": 11,
"y": 4,
"frame": 0
},
{
"x": 11,
"y": 6,
"frame": 0
},
{
"x": 11,
"y": 8,
"frame": 0
},
{
"x": 7,
"y": 14,
"frame": 1
},
{
"x": 1,
"y": 15,
"frame": 1
}
],
"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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

View File

@ -3,7 +3,9 @@
"sheets": { "sheets": {
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, "walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 },
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 }, "guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 },
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 } "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 },
"wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 },
"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

@ -0,0 +1,7 @@
{
"_readme": "Frame registry for the wall-art decal sheet (assets/images/wolfenstein/wall-art.png, sheets.wallArt in wolfenstein-artwork.json, 64x64 frames, transparent PNGs painted on top of a wall's own texture). To add a new decal: paint the next 64x64 tile into wall-art.png, then append an entry here — WolfensteinEditor.js's Wall Art dropdown reads this file directly, no code changes needed.",
"frames": [
{ "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" },
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" }
]
}

View File

@ -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

@ -62,6 +62,9 @@ const FACING_DIRS = {
// null` (just Erase) gets its label alone. The tool id actually used by // null` (just Erase) gets its label alone. The tool id actually used by
// applyTool()/WALL_TOOLS is either the selected option's id, or — for a // applyTool()/WALL_TOOLS is either the selected option's id, or — for a
// dropdown-less category — the category's own id. // dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [ { id: 'wall', label: 'Wall', options: [
{ id: 'wall1', label: 'Stone' }, { id: 'wall1', label: 'Stone' },
@ -69,9 +72,21 @@ const CATEGORIES = [
{ id: 'wall3', label: 'Blue' }, { id: 'wall3', label: 'Blue' },
{ id: 'wall4', label: 'Green' }, { id: 'wall4', label: 'Green' },
] }, ] },
// Options populated from data/wolfenstein-wallart.json once it loads (see
// create()'s fetch + refreshWallArtOptions) — empty here just gives
// buildToolPanel a <select> to patch into rather than a hardcoded list,
// since new decal frames are meant to be added to that JSON alone, no
// code changes. Each option's id is `${WALLART_PREFIX}${frame}` so
// applyTool can recover which frame is selected without a second lookup.
{ id: 'wallart', label: 'Wall Art', options: [] },
{ id: 'door', label: 'Door', options: [ { id: 'door', label: 'Door', options: [
{ id: 'door', label: 'Normal' }, { id: 'door', label: 'Normal' },
] }, ] },
// 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' },
@ -134,6 +149,20 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshLoadOptions(); this.refreshLoadOptions();
}).catch(() => {}); }).catch(() => {});
this.wallArtFrames = [];
fetch('data/wolfenstein-wallart.json').then((r) => r.json())
.then((d) => {
this.wallArtFrames = d.frames ?? [];
this.refreshWallArtOptions();
}).catch(() => {});
this.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();
@ -153,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: [], 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: [],
}; };
@ -317,6 +346,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1), doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1), enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1), items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
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,
}; };
@ -378,6 +409,20 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.loadSelect.innerHTML = opts; this.loadSelect.innerHTML = opts;
} }
/** Patches the Wall Art category's <select> once data/wolfenstein-wallart.json loads — mirrors refreshLoadOptions above. */
refreshWallArtOptions() {
const select = this.toolPanelDom?.node?.querySelector('select[data-cat="wallart"]');
if (!select) return;
select.innerHTML = this.wallArtFrames.map((f) => `<option value="${WALLART_PREFIX}${f.frame}">${f.name}</option>`).join('');
}
/** 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;
@ -404,6 +449,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
doors: clone.doors ?? [], doors: clone.doors ?? [],
enemies: clone.enemies ?? [], enemies: clone.enemies ?? [],
items: clone.items ?? [], items: clone.items ?? [],
wallArt: clone.wallArt ?? [],
objects: clone.objects ?? [],
playerStart: clone.playerStart ?? null, playerStart: clone.playerStart ?? null,
exit: clone.exit ?? null, exit: clone.exit ?? null,
}; };
@ -441,7 +488,9 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.add.text(x, y + size + 16, this.add.text(x, y + size + 16,
'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n' 'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n'
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop', + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop\n\n'
+ 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).\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 });
} }
@ -603,6 +652,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
doors: lvl.doors.map(shift), doors: lvl.doors.map(shift),
enemies: lvl.enemies.map(shift), enemies: lvl.enemies.map(shift),
items: lvl.items.map(shift), items: lvl.items.map(shift),
wallArt: (lvl.wallArt ?? []).map(shift),
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,
}; };
@ -613,11 +664,23 @@ export default class WolfensteinEditor extends Phaser.Scene {
applyTool(x, y) { applyTool(x, y) {
const lvl = this.level; const lvl = this.level;
// Repainting a wall's own type/color keeps any wall art on it — the
// decal isn't tied to a specific wall type, only to the cell being a
// wall at all — so this is the one wall-touching branch that does NOT
// clear wall art (contrast erase and every floor-placing tool below,
// which all turn the cell into non-wall and so must clear it).
if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; } if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; }
if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; } if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); this.clearWallArtAt(x, y); return; }
if (this.tool === 'patrol') { this.applyPatrolTool(x, y); return; } if (this.tool === 'patrol') { this.applyPatrolTool(x, y); return; }
if (this.tool.startsWith(WALLART_PREFIX)) { this.applyWallArtTool(x, y, Number(this.tool.slice(WALLART_PREFIX.length))); return; }
// 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);
if (this.tool === 'door') { if (this.tool === 'door') {
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); const i = lvl.doors.findIndex((d) => d.x === x && d.y === y);
if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' }); if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' });
@ -667,6 +730,52 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (i >= 0) enemy.patrol.splice(i, 1); else enemy.patrol.push({ x: x + 0.5, y: y + 0.5 }); if (i >= 0) enemy.patrol.splice(i, 1); else enemy.patrol.push({ x: x + 0.5, y: y + 0.5 });
} }
/**
* Wall Art: only an actual wall cell (walls[y][x] > 0 floor, and door
* cells too since a door leaves walls[y][x] at 0 in editor-authored level
* JSON, both fail this check the same way) is a valid decal surface.
* Clicking one with no art applies the selected frame; clicking one that
* already has art removes it a straight toggle, regardless of which
* frame is currently selected in the dropdown, so switching an
* already-decorated wall from one decal to another is two clicks
* (remove, then apply) rather than an implicit replace.
*/
applyWallArtTool(x, y, frame) {
const lvl = this.level;
if (!(lvl.walls[y]?.[x] > 0)) return;
lvl.wallArt = lvl.wallArt ?? [];
const i = lvl.wallArt.findIndex((w) => w.x === x && w.y === y);
if (i >= 0) lvl.wallArt.splice(i, 1); else lvl.wallArt.push({ x, y, frame });
}
clearWallArtAt(x, y) {
const lvl = this.level;
lvl.wallArt = (lvl.wallArt ?? []).filter((w) => !(w.x === x && w.y === y));
}
/**
* 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);
@ -675,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;
} }
@ -766,6 +876,16 @@ export default class WolfensteinEditor extends Phaser.Scene {
g.fillStyle(0xb08040, 1); g.fillStyle(0xb08040, 1);
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); } for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
// Decorated walls get a yellow outline at all times (not just while the
// Wall Art tool is active) — same "always visible, not tool-scoped"
// treatment as doors above, since it's level content worth seeing no
// matter which tool you're currently using.
g.lineStyle(2, 0xffe066, 1);
for (const w of lvl.wallArt ?? []) {
const [bx, by] = this.toBoard(w.x, w.y);
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
}
if (lvl.playerStart) { if (lvl.playerStart) {
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
@ -785,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

@ -57,9 +57,25 @@ export function createState(level, rules) {
const cooldowns = {}; const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0; for (const w of rules.weapons) cooldowns[w.id] = 0;
// Wall art (decal painted on top of a wall's own texture) is static,
// level-authored decoration with no runtime behavior of its own — it just
// rides along on state.map for WolfensteinView to sample, the same way
// the wall grid itself does.
const wallArt = (level.wallArt ?? []).map((w) => ({ x: w.x, y: w.y, frame: w.frame }));
// 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 }, 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,
@ -68,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: {
@ -144,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) {
@ -573,11 +595,21 @@ function stepProjectiles(state, rules) {
const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]); const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]);
if (proj.friendly) { if (proj.friendly) {
// Ammo hitting an enemy both damages and stuns it (unless the // Ammo hitting an enemy both damages and stuns it (unless the
// hit was lethal — a dead enemy has nothing left to flinch). // hit was lethal — a dead enemy has nothing left to flinch). Being
// shot is also unconditional proof of the player's presence — it
// alerts the enemy the same way spotting the player or hearing
// gunfire in its room does, regardless of range/LOS/facing-cone
// (a shot from outside its own vision cone, e.g. from behind,
// still counts). Only a genuinely idle enemy transitions here;
// one already alert/chasing/attacking has nothing to escalate.
damageEnemy(state, bestTarget, dmg); damageEnemy(state, bestTarget, dmg);
if (!bestTarget.dead) { if (!bestTarget.dead) {
bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0; bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0;
state.events.push({ t: 'enemyStunned', id: bestTarget.id }); state.events.push({ t: 'enemyStunned', id: bestTarget.id });
if (bestTarget.state === 'idle') {
bestTarget.state = 'alert';
state.events.push({ t: 'enemyAlert', id: bestTarget.id });
}
} }
} else { } else {
applyDamageToPlayer(state, dmg); applyDamageToPlayer(state, dmg);
@ -647,6 +679,8 @@ export function buildLevelModel(levelJson) {
doors: (levelJson.doors ?? []).map((d) => ({ ...d })), doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })), enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })), items: (levelJson.items ?? []).map((it) => ({ ...it })),
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
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(),
}; };
@ -717,6 +751,17 @@ export function validateLevel(level) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) }; const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`); if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
} }
for (const w of level.wallArt ?? []) {
if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
}
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');
@ -755,12 +800,13 @@ export function serialize(state) {
return JSON.stringify({ return JSON.stringify({
v: SAVE_VERSION, v: SAVE_VERSION,
tick: state.tick, accumulatorMs: state.accumulatorMs, tick: state.tick, accumulatorMs: state.accumulatorMs,
map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) }, map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls), wallArt: state.map.wallArt },
player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } }, player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } },
enemies: state.enemies.map((e) => ({ ...e })), enemies: state.enemies.map((e) => ({ ...e })),
projectiles: state.projectiles.map((p) => ({ ...p })), projectiles: state.projectiles.map((p) => ({ ...p })),
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,
@ -773,12 +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,
map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) }, // 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 }, 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

@ -15,8 +15,11 @@
// //
// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser // Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser
// Images, positioned via the same camera-space projection math as the walls, // Images, positioned via the same camera-space projection math as the walls,
// occluded per-sprite (not per-pixel) against the wall depth buffer — an // occluded per-column (not per-pixel) against the wall depth buffer built by
// approximation, acceptable for this MVP. // _drawWalls — cheap enough to stay a single quad per sprite (via
// Image.setCrop, not a redraw-per-column like the walls themselves), while
// still letting a wall corner hide just the part of a sprite behind it
// instead of an all-or-nothing per-sprite test (see _drawSprites).
import { castColumns } from './WolfensteinRaycaster.js'; import { castColumns } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
@ -33,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.
@ -44,6 +52,14 @@ const GUARD_FRAME = {
// How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms. // How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms.
const WALK_CYCLE_MS = 300; const WALK_CYCLE_MS = 300;
// Occlusion tolerance, in world units, for sprite-vs-wall depth comparisons
// (both the initial per-sprite gate and the per-column walk in
// _drawSprites) — without slack, a sprite sitting exactly against a wall
// (its own depth equal to the wall's, e.g. a body that died mid-collision
// against it) would flicker in and out as float error tips the compare
// either way.
const OCCLUSION_EPS = 0.15;
// Death sequence timing, ms: fall pose -> hold on the ground -> fade out. // Death sequence timing, ms: fall pose -> hold on the ground -> fade out.
// Angle-independent (no directional fall/ground art), same as the shoot pose. // Angle-independent (no directional fall/ground art), same as the shoot pose.
const DEATH_FALL_MS = 350; const DEATH_FALL_MS = 350;
@ -53,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;
@ -85,10 +112,14 @@ export default class WolfensteinView {
this.wallTexture = scene.textures.exists('wolfenstein-walls') this.wallTexture = scene.textures.exists('wolfenstein-walls')
? scene.textures.get('wolfenstein-walls') : null; ? scene.textures.get('wolfenstein-walls') : null;
this.wallArtTexture = scene.textures.exists('wolfenstein-wall-art')
? scene.textures.get('wolfenstein-wall-art') : null;
this.doorTexture = scene.textures.exists('wolfenstein-doors') this.doorTexture = scene.textures.exists('wolfenstein-doors')
? scene.textures.get('wolfenstein-doors') : null; ? scene.textures.get('wolfenstein-doors') : null;
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet') this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
? 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
@ -123,6 +154,11 @@ export default class WolfensteinView {
ctx.fillStyle = '#4a4a4a'; ctx.fillStyle = '#4a4a4a';
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2);
// x,y -> wall-art frame index. map.wallArt is small, level-authored,
// static data — cheap enough to rebuild this lookup every call rather
// than caching it across frames.
const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame]));
const cols = castColumns(map, camera, NUM_COLUMNS, doors); const cols = castColumns(map, camera, NUM_COLUMNS, doors);
for (let i = 0; i < NUM_COLUMNS; i++) { for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i]; const hit = cols[i];
@ -144,26 +180,43 @@ export default class WolfensteinView {
continue; continue;
} }
// rawStart/rawEnd (not drawStart/drawEnd) are the true, unclipped
// extent of this column — up close that's far taller than the screen.
// Crop the source by the same fraction that got clipped off the
// destination so the vertical zoom keeps pace with the horizontal one
// instead of freezing once the wall overflows VIEW_H. Wall art below
// reuses this exact same fraction so a decal lines up pixel-for-pixel
// with the wall face beneath it at any distance.
const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]]; const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]];
if (frame) { if (frame) {
const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width)); const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width));
// rawStart/rawEnd (not drawStart/drawEnd) are the true, unclipped
// extent of this column — up close that's far taller than the
// screen. Crop the source by the same fraction that got clipped off
// the destination so the vertical zoom keeps pace with the
// horizontal one instead of freezing once the wall overflows VIEW_H.
const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height; const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height;
const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height; const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height;
ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h); ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h);
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = shadeGrey(hit);
ctx.fillRect(x, y, w, h);
ctx.globalCompositeOperation = 'source-over';
} else { } else {
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa; ctx.fillStyle = `#${(WALL_COLORS[hit.wallType] ?? 0xaaaaaa).toString(16).padStart(6, '0')}`;
ctx.fillStyle = shadeColor(base, hit);
ctx.fillRect(x, y, w, h); ctx.fillRect(x, y, w, h);
} }
// Wall art: an optional decal painted on top of the wall's own
// texture (a poster/flag, not a different wall type), sampled with
// the identical per-column source-texel technique above so it lines
// up with the wall face beneath it, drawn BEFORE the shading pass
// below so it darkens with distance/side the same as the wall does —
// otherwise it'd read as a flat unlit sticker glued onto a dim wall.
const artFrameIndex = wallArtByCell.get(`${hit.mapX},${hit.mapY}`);
const artFrame = artFrameIndex != null ? this.wallArtTexture?.frames[artFrameIndex] : null;
if (artFrame) {
const artSrcX = artFrame.cutX + Math.min(artFrame.width - 1, Math.floor(hit.textureX * artFrame.width));
const artSrcY = artFrame.cutY + ((drawStart - rawStart) / lineHeight) * artFrame.height;
const artSrcH = ((drawEnd - drawStart) / lineHeight) * artFrame.height;
ctx.drawImage(artFrame.source.image, artSrcX, artSrcY, 1, artSrcH, x, y, w, h);
}
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = shadeGrey(hit);
ctx.fillRect(x, y, w, h);
ctx.globalCompositeOperation = 'source-over';
} }
this.canvasTexture.refresh(); this.canvasTexture.refresh();
} }
@ -231,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) {
@ -244,23 +303,87 @@ export default class WolfensteinView {
live.add(s.key); live.add(s.key);
if (s._depth <= 0.05) { this._hideSprite(s.key); continue; } if (s._depth <= 0.05) { this._hideSprite(s.key); continue; }
const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth); const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth);
const col = Math.floor(screenColF); const centerCol = Math.floor(screenColF);
if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) { if (centerCol < 0 || centerCol >= NUM_COLUMNS || s._depth > this.depthBuffer[centerCol] + OCCLUSION_EPS) {
this._hideSprite(s.key); continue; this._hideSprite(s.key); continue;
} }
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
const centerX = screenColF * this.colWidth;
const leftPx = centerX - spriteSize / 2;
const rightPx = centerX + spriteSize / 2;
const img = this._ensureSprite(s.key, s.tex); const img = this._ensureSprite(s.key, s.tex);
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(screenColF * this.colWidth, 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
// cut across it — the depth-buffer test above only proved its CENTER
// column is unoccluded, so a wide sprite (most visibly a dead guard's
// body, sitting still against a wall for seconds) could otherwise
// render fully on top of a wall that should hide part of it. Walk
// outward from the known-visible center column and stop at the first
// occluded neighbor on each side, then crop the image to just that
// visible span — cheaper than a per-pixel/per-column redraw (unlike
// the wall pass, which already draws per column) while still
// resolving the common single-corner case correctly.
const colStart = Math.max(0, Math.floor(leftPx / this.colWidth));
const colEnd = Math.min(NUM_COLUMNS - 1, Math.floor((rightPx - 1e-6) / this.colWidth));
let visLeftCol = centerCol, visRightCol = centerCol;
while (visLeftCol > colStart && s._depth <= this.depthBuffer[visLeftCol - 1] + OCCLUSION_EPS) visLeftCol--;
while (visRightCol < colEnd && s._depth <= this.depthBuffer[visRightCol + 1] + OCCLUSION_EPS) visRightCol++;
if (visLeftCol === colStart && visRightCol === colEnd) {
img.setCrop(); // fully visible — clear any crop left over from a previous frame
} else {
const visLeftPx = Math.max(leftPx, visLeftCol * this.colWidth);
const visRightPx = Math.min(rightPx, (visRightCol + 1) * this.colWidth);
const fracLeft = Math.max(0, Math.min(1, (visLeftPx - leftPx) / spriteSize));
const fracRight = Math.max(0, Math.min(1, (visRightPx - leftPx) / spriteSize));
const fw = img.frame.width, fh = img.frame.height;
// setCrop's x/width are in the frame's own (unflipped) texture
// space — Phaser accounts for setFlipX internally when mapping that
// rect to screen UVs, so a flipped sprite's screen-left visible
// fraction corresponds to the frame's RIGHT edge, not its left.
const cropX = (s.guardFlip ? 1 - fracRight : fracLeft) * fw;
const cropW = Math.max(1, (fracRight - fracLeft) * fw);
img.setCrop(cropX, 0, cropW, fh);
}
img.setVisible(true); img.setVisible(true);
} }
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false); for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);

View File

@ -15,6 +15,108 @@ order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9) (blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
are **not** part of this sheet at all — see `sheets.doors` below. are **not** part of this sheet at all — see `sheets.doors` below.
## Wall art decals — `sheets.wallArt`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike walls/doors this isn't a wall *type*, it's an optional decal
(poster/flag/etc.) painted on top of whichever wall type is already there.
**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png`
(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are
assigned so far). Frame *names* (not just indices) live in a separate JSON
registry, `data/wolfenstein-wallart.json``{ frames: [{ frame, id, name }] }`
— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at
startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
To add a new decal: paint the next tile into `wall-art.png`, append one
entry to that registry — no code changes needed on either side. Current
frames: `0` = Nazi Flag, `1` = Hitler Pic.
Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like
`doors`/`items`, not a dense grid) — one entry per decorated wall cell,
folded into `buildLevelModel`/`validateLevel` in `WolfensteinLogic.js` (an
entry must sit on an actual wall cell, `walls[y][x] > 0`) and carried
statically on `state.map.wallArt` (round-trips through save/load; a save
predating this feature just deserializes to `[]`, no `SAVE_VERSION` bump
needed since it's purely cosmetic data with no simulation behavior).
Rendering (`WolfensteinView._drawWalls`): sampled with the *exact same*
per-column source-texel technique as the wall texture itself (same `srcX`
from `hit.textureX`, same vertical crop math), so a decal lines up
pixel-for-pixel with the wall face beneath it at any distance — it's drawn
directly on top of the base wall draw, before the shared `multiply` shading
pass, so it darkens with distance/side exactly like the wall instead of
reading as a flat unlit sticker. Falls back to showing nothing (just the
plain wall) if the sheet isn't loaded — there's no procedural placeholder
for wall art, unlike walls/doors/guard.
Editor (`WolfensteinEditor.js`): a "Wall Art" tool category whose dropdown
is the one populated dynamically (from the JSON registry above) rather than
hardcoded in `CATEGORIES` — every other category's options are static.
Clicking an existing wall cell with a frame selected toggles that cell's
entry in `level.wallArt` (apply if absent, remove if present, regardless of
which frame is currently selected — switching an already-decorated wall to
a different decal is two clicks). Repainting a wall's type/color keeps its
wall art (`applyTool`'s `WALL_TOOLS` branch); erasing a wall or placing
anything that turns the cell to floor clears it (`clearWallArtAt`, called
alongside every floor-creating branch). Decorated walls get a persistent
yellow outline on the board regardless of which tool is active, the same
"always visible" treatment doors get.
## 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);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -331,6 +355,44 @@ section('4. Enemy AI');
L.fireWeapon(state, rules); L.fireWeapon(state, rules);
check('gunshot does not alert a guard through an open door either', state.enemies[0].state === 'idle', state.enemies[0].state); check('gunshot does not alert a guard through an open door either', state.enemies[0].state === 'idle', state.enemies[0].state);
} }
// Actually landing a hit alerts an enemy unconditionally, independent
// of BOTH other alert paths above: visual detection (LOS+FOV+range) and
// "heard gunfire in the room" (which the door-forced-open case just
// proved does NOT reach across a door, open or not). Guard sits on the
// same row as the door gap (y=2.5) so a bullet fired straight down that
// row travels through the open door and can actually connect, but
// facing east (away from the westward player) keeps it outside its own
// vision cone, and it's in the room-alert-blind "other room" the whole
// time — so alerting here can only be the direct-hit path itself.
{
const level = {
width: W, height: H, cellSize: 64, walls, doors, items: [],
playerStart: { x: 2.5, y: 2.5, angle: 0 },
enemies: [{ type: 'guard', x: 7.5, y: 2.5, facing: 0 }],
exit: { x: 9.5, y: 1.5, radius: 0.6 },
};
const state = L.createState(level, rules);
// Forcing the door open (unlike the fireWeapon-only sub-tests above,
// this one calls L.tick(), which runs stepDoors) also needs `timer`
// set — left at its createState default of 0, stepDoors' auto-close
// countdown underflows on the very first tick and starts sliding the
// door shut again right under the in-flight bullet.
state.doors[0].slide = 1; state.doors[0].target = 1; state.doors[0].timer = rules.constants.doorAutoCloseMs;
state.map.walls[2][5] = 0;
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
L.tick(state, rules); // fires — projectile now in flight through the open door
let alerted = false, healthDropped = false;
for (let i = 0; i < 60 && !alerted; i++) {
L.setFireHeld(state, false); // one shot only — a second room-alert-free shot isn't the point here
L.tick(state, rules);
if (state.enemies[0].health < rules.enemyById.guard.health) healthDropped = true;
alerted = state.enemies[0].state !== 'idle';
}
check('the shot actually connected (sanity check for the assertion below)', healthDropped);
check('being shot alerts a guard even with room-alert and vision-cone both blind to it', alerted, state.enemies[0].state);
}
} }
// A dead guard stops acting and can't be hit/killed twice. The pistol is // A dead guard stops acting and can't be hit/killed twice. The pistol is