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",
"campaignId": null,
"missionIndex": 0,
"width": 12,
"height": 20,
"width": 19,
"height": 21,
"cellSize": 64,
"walls": [
[
@ -20,6 +20,13 @@
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
[
@ -34,6 +41,13 @@
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
@ -48,62 +62,13 @@
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1
],
[
1,
3,
3,
3,
3,
3,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1
],
[
@ -118,6 +83,13 @@
0,
0,
0,
1,
1,
1,
1,
0,
0,
0,
1
],
[
@ -132,20 +104,13 @@
0,
0,
0,
1
],
[
1,
1,
1,
1,
0,
0,
0,
0,
3,
1,
0,
1,
1,
1,
1
],
[
@ -155,12 +120,124 @@
3,
3,
3,
2,
0,
2,
2,
2,
2
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,
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,
@ -169,12 +246,19 @@
2,
2,
2,
2,
0,
2,
2,
2,
2,
1,
1,
1,
0,
0,
0,
0,
0,
2
1
],
[
1,
@ -188,77 +272,14 @@
0,
0,
0,
2
],
[
2,
1,
2,
0,
0,
0,
0,
2,
2,
2,
2,
2,
2
],
[
1,
2,
0,
0,
2,
2,
0,
0,
0,
0,
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,
2,
0,
0,
2,
4,
0,
0,
0,
0,
0,
4
1
],
[
1,
@ -272,7 +293,119 @@
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,
@ -280,13 +413,41 @@
2,
2,
2,
4,
4,
4,
4,
4,
4,
4
1,
1,
1,
0,
1,
1,
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": {
@ -306,13 +467,13 @@
"orientation": "vertical"
},
{
"x": 7,
"y": 9,
"x": 4,
"y": 18,
"orientation": "vertical"
},
{
"x": 4,
"y": 18,
"x": 7,
"y": 10,
"orientation": "vertical"
}
],
@ -336,12 +497,19 @@
"y": 18.5
}
]
},
{
"type": "guard",
"x": 8.5,
"y": 3.5,
"facing": 0,
"patrol": []
}
],
"items": [],
"exit": {
"x": 10.5,
"y": 14.5,
"x": 13.5,
"y": 18.5,
"radius": 0.6
},
"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,
"projectileCap": 64
},
"ammoTypes": [
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 }
],
"weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500 },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "damage": 20, "speed": 11, "cooldownMs": 350, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
{ "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": [
{ "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": [
{ "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]]),
);
// 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
// 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:
@ -101,11 +113,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.panState = null;
this._validateTimer = null;
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(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
.setStrokeStyle(2, COLORS.accent);
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
// (panned/zoomed independently) — clip strictly to the board rect so a
// 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 select = el.querySelector(`select[data-cat="${radio.value}"]`);
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
});
});
@ -271,6 +286,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.addEventListener('change', () => {
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
this.tool = select.value;
this.pendingFacingEnemy = null;
this.draw();
});
});
@ -312,6 +328,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.pushUndo();
this.level = this.defaultLevel();
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.metaName.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -391,6 +408,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
exit: clone.exit ?? null,
};
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.metaName?.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -502,6 +520,26 @@ export default class WolfensteinEditor extends Phaser.Scene {
handleClick(p, isDown) {
if (!this.onBoard(p)) return;
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']);
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);
if (i >= 0) {
if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null;
if (lvl.enemies[i] === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
lvl.enemies.splice(i, 1);
} 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') {
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 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.pendingFacingEnemy) this.pendingFacingEnemy = null;
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
@ -666,6 +713,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (!snap) return;
this.level = JSON.parse(snap);
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.rebuildModel();
}
@ -726,8 +774,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y);
g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35);
}
g.fillStyle(0xd83030, 1);
for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); }
for (const e of lvl.enemies) {
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);
for (const it of lvl.items) {
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.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : '');
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
* applyPatrolTool) gets a bright highlighted line plus a ring around the

View File

@ -10,7 +10,12 @@
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,
// 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) => ({
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
// home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever,
// until it spots the player (see stepPatrol). homeX/Y is the spawn point
@ -49,6 +54,8 @@ export function createState(level, rules) {
const ammo = {};
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 {
tick: 0, accumulatorMs: 0, alpha: 0,
@ -58,8 +65,8 @@ export function createState(level, rules) {
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false,
weaponCooldownMs: 0, radius: rules.constants.playerRadius, dead: false,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, radius: rules.constants.playerRadius, dead: false,
},
enemies, projectiles: [], pickups, doors,
exit: { ...level.exit },
@ -173,8 +180,15 @@ function stepPlayer(state, rules) {
const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed;
moveWithCollision(state.map, p, mvx, mvy, p.radius);
if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs;
if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules);
const w = rules.weaponById[p.weapon];
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) {
if (e.dead) continue;
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; }
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
@ -285,12 +304,18 @@ function stepEnemyAI(state, rules) {
e.cooldownMs = def.meleeCooldownMs;
state.events.push({ t: 'enemyMelee', id: e.id });
} 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, {
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot',
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius,
ttlSec: 3, friendly: false,
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: def.rangedWeapon,
speed: gunDef.speed, ammoType: gunDef.ammoType, hitRadius: gunDef.hitRadius,
ttlSec: gunDef.ttlSec, friendly: false,
});
e.cooldownMs = def.fireCooldownMs;
e.cooldownMs = gunDef.cooldownMs;
}
}
} else {
@ -427,11 +452,11 @@ export function fireWeapon(state, rules) {
if (!w) return;
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 });
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 });
if (w.kind === 'melee') {
@ -439,6 +464,8 @@ export function fireWeapon(state, rules) {
if (e.dead) continue;
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)) {
// 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);
state.events.push({ t: 'meleeHit', id: e.id });
break;
@ -450,7 +477,7 @@ export function fireWeapon(state, rules) {
p.ammo[w.id] -= w.ammoCost ?? 1;
spawnProjectile(state, rules, {
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);
}
@ -462,11 +489,16 @@ function spawnProjectile(state, rules, opts) {
ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly,
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,
damage: opts.damage, hitRadius: opts.hitRadius,
ammoType: opts.ammoType, hitRadius: opts.hitRadius,
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) {
e.health -= dmg;
if (e.health <= 0 && !e.dead) {
@ -538,8 +570,18 @@ function stepProjectiles(state, rules) {
detonate = true;
hitX = proj.px + (proj.x - proj.px) * bestT;
hitY = proj.py + (proj.y - proj.py) * bestT;
if (proj.friendly) damageEnemy(state, bestTarget, proj.damage);
else applyDamageToPlayer(state, proj.damage);
const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]);
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 {
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) },
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 })),
projectiles: data.projectiles.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 items = raw.items.map((i) => ({ ...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 dt = 1 / constants.tickHz;
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.
const GUARD_FRAME = {
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.
@ -346,6 +346,10 @@ export default class WolfensteinView {
* this one sign; nothing else needs to change.
*/
_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 };
const isMoving = e.state === 'chase' || e.state === 'alert'

View File

@ -99,20 +99,27 @@ entered — no per-frame lookup cost.
## Guard enemy — `sheets.guard`
**Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON:
`frameWidth: 128, frameHeight: 128`, painted sheet is
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
artwork JSON: `frameWidth: 128, frameHeight: 128`, painted sheet is
`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
used; the remaining rows sit unused until a second enemy type lands there).
Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1`
facing forward (idle), `2` facing forward walking, `3` facing right (idle,
profile), `4` facing right walking, `5` facing away (idle), `6` facing away
walking, `7` falling (death), `8` on the ground (death). 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 are likewise angle-independent — no directional death poses.
plain row-major Phaser spritesheet — only the first row plus the first cell
of the second row, frames 0-9, is used; the rest sit unused until a second
enemy type lands there). Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`):
`0` shooting, `1` facing forward (idle), `2` facing forward walking, `3`
facing right (idle, profile), `4` facing right walking, `5` facing away
(idle), `6` facing away walking, `7` falling (death), `8` on the ground
(death), `9` stunned (flinching from a non-lethal ranged hit — see
`stunMs` in `WolfensteinLogic.js`'s `stepEnemyAI`/`stepProjectiles`).
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:
- `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,
regardless of viewing angle — there's only one shooting pose, and
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
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,
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[]`

View File

@ -36,13 +36,22 @@ section('1. Rules integrity');
check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol);
check('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
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 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) {
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);
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('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.
{
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 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);
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 startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
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 impact = events.find((e) => e.t === 'impact');
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.
{
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.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.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, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
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 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 state = L.createState(level, rules);
L.setFireHeld(state, true);
L.switchWeapon(state, 'pistol');
let deaths = 0;
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
L.setFireHeld(state, i % 2 === 0);
const events = L.tick(state, rules);
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);
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}`);
}
}
// ---------------------------------------------------------------------------