Wolfenstein: Add semi-auto fire modes, ammo types, enemy stun, and editor facing picker

This refactors weapon/enemy combat systems to support per-weapon cooldowns,
fire modes (auto/semi), and data-driven damage rolls via ammo types. Guards
now use the pistol's stats for ranged attacks and can be stunned by non-lethal
hits. The level editor now includes a directional facing picker for newly placed
enemies and displays enemy orientation as arrows instead of dots.

Key changes:
- Logic: Replaced single weaponCooldownMs with per-weapon cooldowns map; added
  prevFireHeld for semi-auto trigger-edge detection; guards now reference real
  weapon definitions via rangedWeapon; projectiles carry ammoType and resolve
  damage at hit time using rollDamage(); non-lethal ranged hits apply stunMs to
  enemies, freezing their AI until it expires.
- Rules: Added ammoTypes array with damageMin/damageMax; weapons now specify
  fireMode and ammoType (projectiles) or damage (melee); enemy definitions
  include stunMs and reference weapon IDs instead of hardcoded projectile stats.
- Editor: Added FACING_DIRS constants and drawFacingPicker() overlay that lets
  users click N/S/E/W neighbor cells to set a just-placed enemy's facing;
  enemies now render as directional arrows showing their spawn orientation;
  pendingFacingEnemy state tracks the in-progress pick across tool switches,
  undo/redo, and level resets.
- View: Added GUARD_FRAME.stunned (frame 9) which takes visual priority over
  all other states when e.stunMs > 0, ensuring the flinch pose displays even if
  the enemy's internal state still reads 'attack' or 'chase'.
- Data: Expanded level-e1m1 to 19x21 grid with repositioned doors/enemies/exit;
  bumped SAVE_VERSION to 2 since player.cooldowns and prevFireHeld are new
  required fields absent from v1 saves.
- Tests: Added coverage for semi-auto trigger-edge behavior, stun application
  and duration, melee non-stun behavior, damage roll range bounds, and guard
  projectile ammo type/cooldown spacing; updated existing tests to use new
  cooldowns/ammoType fields and toggle fireHeld for repeated shots.
This commit is contained in:
Brian Fertig 2026-08-22 09:39:09 -06:00
parent 74f8d0c926
commit a6f6b647da
10 changed files with 642 additions and 193 deletions

View File

@ -4,8 +4,8 @@
"name": "First Blood", "name": "First Blood",
"campaignId": null, "campaignId": null,
"missionIndex": 0, "missionIndex": 0,
"width": 12, "width": 19,
"height": 20, "height": 21,
"cellSize": 64, "cellSize": 64,
"walls": [ "walls": [
[ [
@ -20,6 +20,13 @@
1, 1,
1, 1,
1, 1,
1,
1,
1,
1,
1,
1,
1,
1 1
], ],
[ [
@ -34,6 +41,13 @@
0, 0,
0, 0,
0, 0,
1,
1,
1,
1,
0,
0,
0,
1 1
], ],
[ [
@ -48,62 +62,13 @@
0, 0,
0, 0,
0, 0,
1
],
[
1, 1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1, 1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1
],
[
1, 1,
3,
3,
3,
3,
3,
0,
0,
0,
0,
0,
1
],
[
1, 1,
0, 0,
0, 0,
0, 0,
0,
3,
0,
0,
0,
0,
0,
1 1
], ],
[ [
@ -118,6 +83,13 @@
0, 0,
0, 0,
0, 0,
1,
1,
1,
1,
0,
0,
0,
1 1
], ],
[ [
@ -132,20 +104,13 @@
0, 0,
0, 0,
0, 0,
1 1,
], 1,
[ 1,
1, 1,
0, 0,
0, 0,
0, 0,
0,
3,
1,
0,
1,
1,
1,
1 1
], ],
[ [
@ -155,12 +120,124 @@
3, 3,
3, 3,
3, 3,
2,
0, 0,
2, 0,
2, 0,
2, 0,
2 0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
3,
3,
3,
3,
3,
1,
0,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
1
], ],
[ [
1, 1,
@ -169,12 +246,19 @@
2, 2,
2, 2,
2, 2,
2,
0,
2,
2,
2,
2,
1,
1,
1,
0, 0,
0, 0,
0, 0,
0, 1
0,
2
], ],
[ [
1, 1,
@ -188,77 +272,14 @@
0, 0,
0, 0,
0, 0,
2 2,
],
[
1, 1,
2,
0,
0,
0,
0,
2,
2,
2,
2,
2,
2
],
[
1, 1,
2,
0,
0,
2,
2,
0,
0,
0,
0,
0,
4
],
[
1, 1,
2,
0,
0,
2,
4,
0, 0,
0, 0,
0, 0,
0, 1
0,
4
],
[
1,
2,
0,
0,
2,
4,
0,
0,
0,
0,
0,
4
],
[
1,
2,
0,
0,
2,
4,
0,
0,
0,
0,
0,
4
], ],
[ [
1, 1,
@ -272,7 +293,119 @@
0, 0,
0, 0,
0, 0,
4 2,
1,
1,
1,
0,
0,
0,
1
],
[
1,
2,
0,
0,
2,
2,
2,
2,
2,
2,
2,
2,
1,
1,
1,
0,
0,
0,
1
],
[
1,
2,
0,
0,
2,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
2,
0,
0,
2,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
1
],
[
1,
2,
0,
0,
2,
1,
1,
1,
0,
1,
1,
1,
0,
1,
1,
0,
0,
0,
1
],
[
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
1
], ],
[ [
1, 1,
@ -280,13 +413,41 @@
2, 2,
2, 2,
2, 2,
4, 1,
4, 1,
4, 1,
4, 0,
4, 1,
4, 1,
4 1,
0,
1,
1,
0,
0,
0,
1
],
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
] ]
], ],
"playerStart": { "playerStart": {
@ -306,13 +467,13 @@
"orientation": "vertical" "orientation": "vertical"
}, },
{ {
"x": 7, "x": 4,
"y": 9, "y": 18,
"orientation": "vertical" "orientation": "vertical"
}, },
{ {
"x": 4, "x": 7,
"y": 18, "y": 10,
"orientation": "vertical" "orientation": "vertical"
} }
], ],
@ -336,12 +497,19 @@
"y": 18.5 "y": 18.5
} }
] ]
},
{
"type": "guard",
"x": 8.5,
"y": 3.5,
"facing": 0,
"patrol": []
} }
], ],
"items": [], "items": [],
"exit": { "exit": {
"x": 10.5, "x": 13.5,
"y": 14.5, "y": 18.5,
"radius": 0.6 "radius": 0.6
}, },
"briefing": [] "briefing": []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -11,12 +11,15 @@
"doorAutoCloseMs": 2500, "doorAutoCloseMs": 2500,
"projectileCap": 64 "projectileCap": 64
}, },
"ammoTypes": [
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 }
],
"weapons": [ "weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500 }, { "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "damage": 20, "speed": 11, "cooldownMs": 350, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 } { "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
], ],
"enemies": [ "enemies": [
{ "id": "guard", "name": "Guard", "health": 40, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "fireCooldownMs": 950, "projectileSpeed": 9, "projectileDamage": 12, "hitRadius": 0.18 } { "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" }
], ],
"items": [ "items": [
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 }, { "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },

View File

@ -44,6 +44,18 @@ const MINIMAP_WALL_RGB = Object.fromEntries(
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]), Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
); );
// Degrees match WolfensteinLogic's facing->angle convention (0=east,
// 90=south, 180=west, 270=north — standard screen coords, y grows downward)
// and tools/wolfensteinMap.js's ASCII-map comments. Used both for the
// facing-picker's N/S/E/W neighbor highlight and for orienting the enemy
// arrow icon.
const FACING_DIRS = {
E: { dx: 1, dy: 0, deg: 0 },
S: { dx: 0, dy: 1, deg: 90 },
W: { dx: -1, dy: 0, deg: 180 },
N: { dx: 0, dy: -1, deg: 270 },
};
// One radio row per category; a category with `options` gets a dropdown // One radio row per category; a category with `options` gets a dropdown
// next to its radio (even a category with only one option today, per // next to its radio (even a category with only one option today, per
// Door/Enemy — more sub-types land there later), a category with `options: // Door/Enemy — more sub-types land there later), a category with `options:
@ -101,11 +113,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.panState = null; this.panState = null;
this._validateTimer = null; this._validateTimer = null;
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
this.pendingFacingEnemy = null; // an object reference — the just-placed enemy still awaiting its N/S/E/W facing pick, see applyTool's 'enemy' branch + drawFacingPicker
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018); this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000) this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
.setStrokeStyle(2, COLORS.accent); .setStrokeStyle(2, COLORS.accent);
this.g = this.add.graphics().setDepth(5); this.g = this.add.graphics().setDepth(5);
this.facingHintText = this.add.text(BOARD_X, BOARD_Y - 24, '', { fontFamily: FONT, fontSize: '14px', color: COLORS.goldHex }).setDepth(6);
// The board is now a fixed-size window onto a possibly much larger grid // The board is now a fixed-size window onto a possibly much larger grid
// (panned/zoomed independently) — clip strictly to the board rect so a // (panned/zoomed independently) — clip strictly to the board rect so a
// fractional pan offset never bleeds a partial cell past its border. // fractional pan offset never bleeds a partial cell past its border.
@ -264,6 +278,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
const cat = CATEGORIES.find((c) => c.id === radio.value); const cat = CATEGORIES.find((c) => c.id === radio.value);
const select = el.querySelector(`select[data-cat="${radio.value}"]`); const select = el.querySelector(`select[data-cat="${radio.value}"]`);
this.tool = select ? select.value : cat.id; this.tool = select ? select.value : cat.id;
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick
this.draw(); // patrol-route overlay only shows while the Patrol tool is active this.draw(); // patrol-route overlay only shows while the Patrol tool is active
}); });
}); });
@ -271,6 +286,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.addEventListener('change', () => { select.addEventListener('change', () => {
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true; el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
this.tool = select.value; this.tool = select.value;
this.pendingFacingEnemy = null;
this.draw(); this.draw();
}); });
}); });
@ -312,6 +328,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.pushUndo(); this.pushUndo();
this.level = this.defaultLevel(); this.level = this.defaultLevel();
this.selectedEnemy = null; this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.metaName.setLabel(`Name: ${this.level.name}`); this.metaName.setLabel(`Name: ${this.level.name}`);
this.fitToGrid(); this.fitToGrid();
this.rebuildModel(); this.rebuildModel();
@ -391,6 +408,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
exit: clone.exit ?? null, exit: clone.exit ?? null,
}; };
this.selectedEnemy = null; this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.metaName?.setLabel(`Name: ${this.level.name}`); this.metaName?.setLabel(`Name: ${this.level.name}`);
this.fitToGrid(); this.fitToGrid();
this.rebuildModel(); this.rebuildModel();
@ -502,6 +520,26 @@ export default class WolfensteinEditor extends Phaser.Scene {
handleClick(p, isDown) { handleClick(p, isDown) {
if (!this.onBoard(p)) return; if (!this.onBoard(p)) return;
let [cx, cy] = this.toCell(p.x, p.y); let [cx, cy] = this.toCell(p.x, p.y);
// Mid-pick for a just-placed enemy's facing: a click on one of its four
// highlighted N/S/E/W neighbor cells (see drawFacingPicker) sets that
// direction and consumes the click entirely — it must never also fall
// through to painting/placing on that same cell. A click anywhere else
// just finalizes the pick at whatever facing the enemy already has
// (its placement default) and then falls through to handle the click
// normally, so the user never "wastes" a click dismissing the picker.
if (isDown && this.pendingFacingEnemy && this.level.enemies.includes(this.pendingFacingEnemy)) {
const enemy = this.pendingFacingEnemy;
const ex = Math.floor(enemy.x), ey = Math.floor(enemy.y);
const dir = Object.values(FACING_DIRS).find((d) => cx === ex + d.dx && cy === ey + d.dy);
this.pendingFacingEnemy = null;
if (dir) {
enemy.facing = dir.deg;
this.draw();
return;
}
}
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']); const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
if (!isDown && !paintTools.has(this.tool)) return; if (!isDown && !paintTools.has(this.tool)) return;
@ -591,9 +629,17 @@ export default class WolfensteinEditor extends Phaser.Scene {
const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
if (i >= 0) { if (i >= 0) {
if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null; if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null;
if (lvl.enemies[i] === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
lvl.enemies.splice(i, 1); lvl.enemies.splice(i, 1);
} else { } else {
lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] }); // Defaults to the old fixed 180° (west) facing; drawFacingPicker
// lets the user immediately override it by clicking a highlighted
// N/S/E/W neighbor square. Only affects initial spawn pose — a
// patrolling/alerted guard's angle is overwritten every tick to
// match its travel/aim direction regardless of this value.
const enemy = { type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] };
lvl.enemies.push(enemy);
this.pendingFacingEnemy = enemy;
} }
} else if (this.tool === 'ammo' || this.tool === 'health') { } else if (this.tool === 'ammo' || this.tool === 'health') {
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y); const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
@ -625,6 +671,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
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);
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null; if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
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));
@ -666,6 +713,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (!snap) return; if (!snap) return;
this.level = JSON.parse(snap); this.level = JSON.parse(snap);
this.selectedEnemy = null; // level was wholesale-replaced; old reference can't match anything in it this.selectedEnemy = null; // level was wholesale-replaced; old reference can't match anything in it
this.pendingFacingEnemy = null;
this.setView(this.viewX, this.viewY); // re-clamp in case the undone edit resized the grid this.setView(this.viewX, this.viewY); // re-clamp in case the undone edit resized the grid
this.rebuildModel(); this.rebuildModel();
} }
@ -726,8 +774,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y); const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y);
g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35); g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35);
} }
g.fillStyle(0xd83030, 1); for (const e of lvl.enemies) {
for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); } const [bx, by] = this.toBoard(e.x, e.y);
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
}
g.fillStyle(0xd4a017, 1); g.fillStyle(0xd4a017, 1);
for (const it of lvl.items) { for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y); const [bx, by] = this.toBoard(it.x, it.y);
@ -736,10 +786,54 @@ export default class WolfensteinEditor extends Phaser.Scene {
} }
if (this.tool === 'patrol') this.drawPatrolRoutes(); if (this.tool === 'patrol') this.drawPatrolRoutes();
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : '');
this.drawMinimapBox(); this.drawMinimapBox();
} }
/**
* Thick directional-arrow enemy icon (replaces a plain dot) so a guard's
* spawn facing is visible at a glance on the board. `angleRad` uses the
* same convention as WolfensteinLogic's facing->angle (0=east, 90=south,
* 180=west, 270=north y grows downward). A simple filled dart: a tip
* out front and two swept-back corners.
*/
drawEnemyArrow(bx, by, radius, angleRad, color) {
const g = this.g;
const tipX = bx + Math.cos(angleRad) * radius;
const tipY = by + Math.sin(angleRad) * radius;
const back1X = bx + Math.cos(angleRad + Math.PI * 0.78) * radius;
const back1Y = by + Math.sin(angleRad + Math.PI * 0.78) * radius;
const back2X = bx + Math.cos(angleRad - Math.PI * 0.78) * radius;
const back2Y = by + Math.sin(angleRad - Math.PI * 0.78) * radius;
g.fillStyle(color, 1);
g.fillTriangle(tipX, tipY, back1X, back1Y, back2X, back2Y);
}
/**
* Facing picker overlay for `this.pendingFacingEnemy` a highlighted
* square on each of the enemy's four grid-adjacent (N/S/E/W) neighbor
* cells. handleClick() matches a click against these same FACING_DIRS
* offsets, so this overlay always shows exactly what's clickable.
*/
drawFacingPicker() {
const g = this.g;
const k = this.zoom;
const enemy = this.pendingFacingEnemy;
const ex = Math.floor(enemy.x), ey = Math.floor(enemy.y);
const [cx, cy] = this.toBoard(enemy.x, enemy.y);
g.lineStyle(3, 0xffe066, 1);
g.strokeCircle(cx, cy, k * 0.55);
for (const { dx, dy } of Object.values(FACING_DIRS)) {
const [bx, by] = this.toBoard(ex + dx, ey + dy);
g.fillStyle(0xffe066, 0.35);
g.fillRect(bx, by, k - 1, k - 1);
g.lineStyle(2, 0xffe066, 0.9);
g.strokeRect(bx, by, k - 1, k - 1);
}
}
/** /**
* Every enemy with a route gets a dim line; the selected one (see * Every enemy with a route gets a dim line; the selected one (see
* applyPatrolTool) gets a bright highlighted line plus a ring around the * applyPatrolTool) gets a bright highlighted line plus a ring around the

View File

@ -10,7 +10,12 @@
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js'; import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
export const SAVE_VERSION = 1; // v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields
// a v1 save structurally lacks — bumped so deserialize's version gate
// cleanly rejects old saves instead of them limping through with undefined
// fields (no migration path exists anywhere in this file).
export const SAVE_VERSION = 2;
// A door cell reads as a solid wall to the raycaster/collision while closed, // A door cell reads as a solid wall to the raycaster/collision while closed,
// but — unlike a real wall — must count as passable for level-reachability // but — unlike a real wall — must count as passable for level-reachability
@ -36,7 +41,7 @@ export function createState(level, rules) {
const enemies = (level.enemies ?? []).map((e, i) => ({ const enemies = (level.enemies ?? []).map((e, i) => ({
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180, id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false, health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false,
// Patrol route, editor-authored: an idle (never-yet-alerted) enemy walks // Patrol route, editor-authored: an idle (never-yet-alerted) enemy walks
// home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever, // home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever,
// until it spots the player (see stepPatrol). homeX/Y is the spawn point // until it spots the player (see stepPatrol). homeX/Y is the spawn point
@ -49,6 +54,8 @@ export function createState(level, rules) {
const ammo = {}; const ammo = {};
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0; for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0;
return { return {
tick: 0, accumulatorMs: 0, alpha: 0, tick: 0, accumulatorMs: 0, alpha: 0,
@ -58,8 +65,8 @@ export function createState(level, rules) {
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: rules.constants.playerMaxHealth, health: rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo, weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
weaponCooldownMs: 0, radius: rules.constants.playerRadius, dead: false, cooldowns, radius: rules.constants.playerRadius, dead: false,
}, },
enemies, projectiles: [], pickups, doors, enemies, projectiles: [], pickups, doors,
exit: { ...level.exit }, exit: { ...level.exit },
@ -173,8 +180,15 @@ function stepPlayer(state, rules) {
const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed; const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed;
moveWithCollision(state.map, p, mvx, mvy, p.radius); moveWithCollision(state.map, p, mvx, mvy, p.radius);
if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs; const w = rules.weaponById[p.weapon];
if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules); if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
// Automatic weapons refire the instant cooldown clears as long as the
// trigger is held; semi-automatic weapons need a fresh press each shot —
// a rising edge of fireHeld — even if cooldown expired while still held.
const pulledTrigger = p.fireHeld && !p.prevFireHeld;
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
p.prevFireHeld = p.fireHeld;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -261,6 +275,11 @@ function stepEnemyAI(state, rules) {
for (const e of state.enemies) { for (const e of state.enemies) {
if (e.dead) continue; if (e.dead) continue;
const def = rules.enemyById[e.defId]; const def = rules.enemyById[e.defId];
// A stunned enemy flinches for its type's stunMs: no perception,
// movement, or attack-cooldown progress that tick. e.state is left
// exactly as it was, so chase/attack resumes where it left off once
// stunMs reaches 0.
if (e.stunMs > 0) { e.stunMs = Math.max(0, e.stunMs - rules.stepMs); continue; }
if (p.dead) { e.state = 'idle'; continue; } if (p.dead) { e.state = 'idle'; continue; }
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y); const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
@ -285,12 +304,18 @@ function stepEnemyAI(state, rules) {
e.cooldownMs = def.meleeCooldownMs; e.cooldownMs = def.meleeCooldownMs;
state.events.push({ t: 'enemyMelee', id: e.id }); state.events.push({ t: 'enemyMelee', id: e.id });
} else { } else {
// def.rangedWeapon references the real weapon def (guards
// "carry" it) for damage/ammoType/speed/cooldown — but AI fire
// never goes through fireWeapon's trigger-edge logic, so the
// weapon's fireMode is not consulted here; a guard fires
// automatically whenever its own cooldown clears.
const gunDef = rules.weaponById[def.rangedWeapon];
spawnProjectile(state, rules, { spawnProjectile(state, rules, {
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot', ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: def.rangedWeapon,
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius, speed: gunDef.speed, ammoType: gunDef.ammoType, hitRadius: gunDef.hitRadius,
ttlSec: 3, friendly: false, ttlSec: gunDef.ttlSec, friendly: false,
}); });
e.cooldownMs = def.fireCooldownMs; e.cooldownMs = gunDef.cooldownMs;
} }
} }
} else { } else {
@ -427,11 +452,11 @@ export function fireWeapon(state, rules) {
if (!w) return; if (!w) return;
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) { if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
p.weaponCooldownMs = 150; p.cooldowns[w.id] = 150;
state.events.push({ t: 'weaponEmpty', weapon: w.id }); state.events.push({ t: 'weaponEmpty', weapon: w.id });
return; return;
} }
p.weaponCooldownMs = w.cooldownMs; p.cooldowns[w.id] = w.cooldownMs;
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y }); state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
if (w.kind === 'melee') { if (w.kind === 'melee') {
@ -439,6 +464,8 @@ export function fireWeapon(state, rules) {
if (e.dead) continue; if (e.dead) continue;
if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180) if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180)
&& hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) { && hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) {
// Flat weapon damage, no ammo-type roll and no stun — fists aren't
// "ammo" (see stepProjectiles' hit resolution for the ranged path).
damageEnemy(state, e, w.damage); damageEnemy(state, e, w.damage);
state.events.push({ t: 'meleeHit', id: e.id }); state.events.push({ t: 'meleeHit', id: e.id });
break; break;
@ -450,7 +477,7 @@ export function fireWeapon(state, rules) {
p.ammo[w.id] -= w.ammoCost ?? 1; p.ammo[w.id] -= w.ammoCost ?? 1;
spawnProjectile(state, rules, { spawnProjectile(state, rules, {
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id, ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true, speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
}); });
alertEnemiesInPlayerRoom(state); alertEnemiesInPlayerRoom(state);
} }
@ -462,11 +489,16 @@ function spawnProjectile(state, rules, opts) {
ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly, ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly,
x: opts.x, y: opts.y, px: opts.x, py: opts.y, x: opts.x, y: opts.y, px: opts.x, py: opts.y,
vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed, vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed,
damage: opts.damage, hitRadius: opts.hitRadius, ammoType: opts.ammoType, hitRadius: opts.hitRadius,
ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz), ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz),
}); });
} }
/** Inclusive integer roll in [ammoType.damageMin, ammoType.damageMax], resolved at the moment of hit (see stepProjectiles), not at fire time. */
function rollDamage(ammoType) {
return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1));
}
function damageEnemy(state, e, dmg) { function damageEnemy(state, e, dmg) {
e.health -= dmg; e.health -= dmg;
if (e.health <= 0 && !e.dead) { if (e.health <= 0 && !e.dead) {
@ -538,8 +570,18 @@ function stepProjectiles(state, rules) {
detonate = true; detonate = true;
hitX = proj.px + (proj.x - proj.px) * bestT; hitX = proj.px + (proj.x - proj.px) * bestT;
hitY = proj.py + (proj.y - proj.py) * bestT; hitY = proj.py + (proj.y - proj.py) * bestT;
if (proj.friendly) damageEnemy(state, bestTarget, proj.damage); const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]);
else applyDamageToPlayer(state, proj.damage); if (proj.friendly) {
// Ammo hitting an enemy both damages and stuns it (unless the
// hit was lethal — a dead enemy has nothing left to flinch).
damageEnemy(state, bestTarget, dmg);
if (!bestTarget.dead) {
bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0;
state.events.push({ t: 'enemyStunned', id: bestTarget.id });
}
} else {
applyDamageToPlayer(state, dmg);
}
} }
} }
@ -732,7 +774,7 @@ export function deserialize(rules, raw) {
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) }, map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) },
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: 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 })),

View File

@ -9,8 +9,13 @@ export function compileRules(raw) {
const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e])); const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e]));
const items = raw.items.map((i) => ({ ...i })); const items = raw.items.map((i) => ({ ...i }));
const itemById = Object.fromEntries(items.map((i) => [i.id, i])); const itemById = Object.fromEntries(items.map((i) => [i.id, i]));
const ammoTypes = (raw.ammoTypes ?? []).map((a) => ({ ...a }));
const ammoTypeById = Object.fromEntries(ammoTypes.map((a) => [a.id, a]));
const stepMs = 1000 / constants.tickHz; const stepMs = 1000 / constants.tickHz;
const dt = 1 / constants.tickHz; const dt = 1 / constants.tickHz;
const fov = (constants.fovDeg * Math.PI) / 180; const fov = (constants.fovDeg * Math.PI) / 180;
return { constants, weapons, weaponById, enemies, enemyById, items, itemById, stepMs, dt, fov }; return {
constants, weapons, weaponById, enemies, enemyById, items, itemById,
ammoTypes, ammoTypeById, stepMs, dt, fov,
};
} }

View File

@ -38,7 +38,7 @@ const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
// populated; further columns/rows sit unused until more enemy types land. // populated; further columns/rows sit unused until more enemy types land.
const GUARD_FRAME = { const GUARD_FRAME = {
shoot: 0, frontIdle: 1, frontWalk: 2, sideIdle: 3, sideWalk: 4, backIdle: 5, backWalk: 6, shoot: 0, frontIdle: 1, frontWalk: 2, sideIdle: 3, sideWalk: 4, backIdle: 5, backWalk: 6,
fall: 7, ground: 8, fall: 7, ground: 8, stunned: 9,
}; };
// 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.
@ -346,6 +346,10 @@ export default class WolfensteinView {
* this one sign; nothing else needs to change. * this one sign; nothing else needs to change.
*/ */
_guardFacing(e, camera, now) { _guardFacing(e, camera, now) {
// Stun takes visual priority over e.state — stepEnemyAI freezes state
// entirely while stunned, so it may still read 'attack'/'chase' from
// right before the hit landed.
if (e.stunMs > 0) return { frame: GUARD_FRAME.stunned, flip: false };
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false }; if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
const isMoving = e.state === 'chase' || e.state === 'alert' const isMoving = e.state === 'chase' || e.state === 'alert'

View File

@ -99,20 +99,27 @@ entered — no per-frame lookup cost.
## Guard enemy — `sheets.guard` ## Guard enemy — `sheets.guard`
**Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON: **Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
`frameWidth: 128, frameHeight: 128`, painted sheet is artwork JSON: `frameWidth: 128, frameHeight: 128`, painted sheet is
`assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a `assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a
plain row-major Phaser spritesheet — only the first row, frames 0-8, is plain row-major Phaser spritesheet — only the first row plus the first cell
used; the remaining rows sit unused until a second enemy type lands there). of the second row, frames 0-9, is used; the rest sit unused until a second
Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1` enemy type lands there). Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`):
facing forward (idle), `2` facing forward walking, `3` facing right (idle, `0` shooting, `1` facing forward (idle), `2` facing forward walking, `3`
profile), `4` facing right walking, `5` facing away (idle), `6` facing away facing right (idle, profile), `4` facing right walking, `5` facing away
walking, `7` falling (death), `8` on the ground (death). There is no "facing (idle), `6` facing away walking, `7` falling (death), `8` on the ground
left" pose — `WolfensteinView` shows the "facing right" frames with (death), `9` stunned (flinching from a non-lethal ranged hit — see
`img.setFlipX(true)` for that case instead, per the sheet's own convention. `stunMs` in `WolfensteinLogic.js`'s `stepEnemyAI`/`stepProjectiles`).
Frames 7/8 are likewise angle-independent — no directional death poses. There is no "facing left" pose — `WolfensteinView` shows the "facing right"
frames with `img.setFlipX(true)` for that case instead, per the sheet's own
convention. Frames 7/8/9 are likewise angle-independent — no directional
death or stun poses.
`WolfensteinView._guardFacing(e, camera, now)` picks the frame every render: `WolfensteinView._guardFacing(e, camera, now)` picks the frame every render:
- `e.stunMs > 0` always shows the stunned pose (frame 9), unflipped, and
takes priority over every other check below it — `stepEnemyAI` freezes
`e.state` entirely while stunned, so it may still read `'attack'`/`'chase'`
from right before the hit landed, and the stun pose must win regardless.
- `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped, - `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped,
regardless of viewing angle — there's only one shooting pose, and regardless of viewing angle — there's only one shooting pose, and
attacking guards stand still and roughly face the player anyway. attacking guards stand still and roughly face the player anyway.
@ -156,7 +163,9 @@ procedural `wolf-guard` texture, always frame 0, never flipped) if the sheet
isn't loaded, same convention as the wall/door texture fallbacks — this isn't loaded, same convention as the wall/door texture fallbacks — this
applies to the death sequence too (dead guards would just render as the applies to the death sequence too (dead guards would just render as the
one flat placeholder frame, never flipped, for the same three phases/timing, one flat placeholder frame, never flipped, for the same three phases/timing,
alpha fade included, until removed). alpha fade included, until removed) and to stun (a stunned guard just reads
as the same flat placeholder frame — no visible flinch — until frame 9 is
painted).
## Standalone images — `artwork[]` ## Standalone images — `artwork[]`

View File

@ -36,13 +36,22 @@ section('1. Rules integrity');
check('fists weapon defined', !!rules.weaponById.fists); check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol); check('pistol weapon defined', !!rules.weaponById.pistol);
check('guard enemy defined', !!rules.enemyById.guard); check('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
for (const w of rules.weapons) { for (const w of rules.weapons) {
check(`${w.id} has positive damage`, w.damage > 0);
check(`${w.id} has positive cooldown`, w.cooldownMs > 0); check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode);
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
if (w.kind === 'projectile') {
const at = rules.ammoTypeById[w.ammoType];
check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
if (at) check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
}
} }
for (const e of rules.enemies) { for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0); check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange); check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
if (e.rangedWeapon) check(`${e.id}'s rangedWeapon resolves to a defined weapon`, !!rules.weaponById[e.rangedWeapon], e.rangedWeapon);
} }
check('tickHz positive', rules.constants.tickHz > 0); check('tickHz positive', rules.constants.tickHz > 0);
check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz)); check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz));
@ -130,10 +139,10 @@ section('3. Bullet swept-collision correctness');
// them passes right through it. // them passes right through it.
{ {
const state = L.createState(level, rules); const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius }); state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end
const vx = (endGuessX - startX) * rules.constants.tickHz; const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules); L.tick(state, rules);
check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`); check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`);
} }
@ -149,7 +158,7 @@ section('3. Bullet swept-collision correctness');
const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules); const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules);
const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
const vx = (endGuessX - startX) * rules.constants.tickHz; const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
const events = L.tick(state, rules); const events = L.tick(state, rules);
const impact = events.find((e) => e.t === 'impact'); const impact = events.find((e) => e.t === 'impact');
check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events)); check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events));
@ -160,8 +169,8 @@ section('3. Bullet swept-collision correctness');
// A clean miss must not falsely register a hit. // A clean miss must not falsely register a hit.
{ {
const state = L.createState(level, rules); const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius }); state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules); L.tick(state, rules);
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);
@ -324,14 +333,17 @@ section('4. Enemy AI');
} }
} }
// A dead guard stops acting and can't be hit/killed twice. // A dead guard stops acting and can't be hit/killed twice. The pistol is
// semi-automatic now, so holding fireHeld continuously only fires once —
// toggle it every tick to simulate repeated trigger pulls, same as a
// player clicking as fast as the cooldown allows.
{ {
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] }; const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules); const state = L.createState(level, rules);
L.setFireHeld(state, true);
L.switchWeapon(state, 'pistol'); L.switchWeapon(state, 'pistol');
let deaths = 0; let deaths = 0;
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) { for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
L.setFireHeld(state, i % 2 === 0);
const events = L.tick(state, rules); const events = L.tick(state, rules);
deaths += events.filter((e) => e.t === 'enemyDied').length; deaths += events.filter((e) => e.t === 'enemyDied').length;
} }
@ -341,6 +353,118 @@ section('4. Enemy AI');
for (let i = 0; i < 60; i++) L.tick(state, rules); for (let i = 0; i < 60; i++) L.tick(state, rules);
check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead'); check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead');
} }
// Semi-automatic pistol: holding the trigger continuously through a
// cooldown expiry must NOT refire — only a fresh press (a release then a
// new hold) does.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
let fires = 0;
const ticksPerShot = Math.ceil(rules.weaponById.pistol.cooldownMs / rules.stepMs);
for (let i = 0; i < ticksPerShot * 3; i++) {
const events = L.tick(state, rules);
fires += events.filter((e) => e.t === 'weaponFired').length;
}
check('holding the trigger through a semi-auto weapon\'s cooldown fires only once', fires === 1, `fired ${fires} times`);
L.setFireHeld(state, false);
L.tick(state, rules);
L.setFireHeld(state, true);
const events = L.tick(state, rules);
fires += events.filter((e) => e.t === 'weaponFired').length;
check('a fresh release-then-press fires again', fires === 2, `fired ${fires} times total`);
}
// A non-lethal ranged (ammo) hit stuns the guard for its type's stunMs,
// freezing it in place, and it resumes normal behavior once stunMs clears.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
state.enemies[0].health = 1000; // survives many hits so it can't die mid-check
L.switchWeapon(state, 'pistol');
L.setFireHeld(state, true);
L.tick(state, rules); // fires — the projectile is now in flight
let stunnedAt = null;
for (let i = 0; i < 30 && stunnedAt === null; i++) {
L.setFireHeld(state, false); // don't keep firing while we watch the stun window
L.tick(state, rules);
if (state.enemies[0].stunMs > 0) stunnedAt = i;
}
check('a non-lethal ranged hit stuns the guard', stunnedAt !== null && state.enemies[0].stunMs <= rules.enemyById.guard.stunMs, state.enemies[0].stunMs);
const frozenX = state.enemies[0].x, frozenY = state.enemies[0].y, frozenState = state.enemies[0].state;
let movedWhileStunned = false;
while (state.enemies[0].stunMs > 0) {
L.tick(state, rules);
if (state.enemies[0].stunMs > 0 && (state.enemies[0].x !== frozenX || state.enemies[0].y !== frozenY)) movedWhileStunned = true;
}
check('a stunned guard does not move while stunMs > 0', !movedWhileStunned);
let resumed = false;
for (let i = 0; i < 30 && !resumed; i++) {
L.tick(state, rules);
resumed = state.enemies[0].state !== 'idle';
}
check('the guard resumes normal behavior once the stun clears', resumed, frozenState);
}
// A melee (fists) hit damages but does NOT stun — stun is scoped to ammo
// (ranged) hits only, per fireWeapon's melee branch.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
const healthBefore = state.enemies[0].health;
L.fireWeapon(state, rules);
check('a melee hit damages the guard', state.enemies[0].health < healthBefore, state.enemies[0].health);
check('a melee hit does not stun the guard', state.enemies[0].stunMs === 0, state.enemies[0].stunMs);
}
// Ammo-type damage rolls stay within [damageMin, damageMax], and both
// bounds actually get hit across enough trials (catches an off-by-one in
// rollDamage's inclusive-range formula).
{
const at = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'pistol');
let minSeen = Infinity, maxSeen = -Infinity, outOfRange = 0;
const trials = QUICK ? 100 : 500;
for (let i = 0; i < trials; i++) {
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0;
const before = state.enemies[0].health;
L.fireWeapon(state, rules);
// fireWeapon only spawns the projectile; step it forward to resolve the hit.
for (let t = 0; t < 30 && state.enemies[0].health === before; t++) L.tick(state, rules);
const dmg = before - state.enemies[0].health;
if (dmg < at.damageMin || dmg > at.damageMax) outOfRange++;
minSeen = Math.min(minSeen, dmg); maxSeen = Math.max(maxSeen, dmg);
}
check('every rolled damage falls within the ammo type\'s range', outOfRange === 0, `${outOfRange}/${trials} out of range`);
check('the roll reaches both the low and high end of the range', minSeen === at.damageMin && maxSeen === at.damageMax, `saw [${minSeen}, ${maxSeen}], expected [${at.damageMin}, ${at.damageMax}]`);
}
// "Guards carry pistols": a guard's own fired projectile references the
// real pistol weapon's ammo type and refires on the pistol's cooldown, not
// a separate hardcoded stat block.
{
const level = { ...baseLevel, playerStart: { x: 6.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0 }] };
const state = L.createState(level, rules);
let firstProjTick = null, secondProjTick = null;
for (let i = 0; i < 300 && secondProjTick === null; i++) {
const before = state.projectiles.length;
L.tick(state, rules);
if (state.projectiles.length > before) {
if (firstProjTick === null) firstProjTick = i; else secondProjTick = i;
}
}
check('a guard\'s fired projectile carries the pistol\'s ammo type', state.projectiles.at(-1)?.ammoType === rules.weaponById.pistol.ammoType, state.projectiles.at(-1)?.ammoType);
const expectedTicks = Math.round(rules.weaponById.pistol.cooldownMs / rules.stepMs);
check('consecutive guard shots are spaced by the pistol\'s cooldown', firstProjTick !== null && secondProjTick !== null && near(secondProjTick - firstProjTick, expectedTicks, 2), `${secondProjTick - firstProjTick} ticks, expected ~${expectedTicks}`);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------