Compare commits
4 Commits
a6f6b647da
...
a5b37038d2
| Author | SHA1 | Date |
|---|---|---|
|
|
a5b37038d2 | |
|
|
aa685dc505 | |
|
|
c0ab9155a3 | |
|
|
7d9729e594 |
|
|
@ -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.
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 guard’s facing' : '');
|
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing' : '');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue