diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json index 9f04966..204b801 100644 --- a/assets/gamedata/wolfenstein/level-e1m1.json +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -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": [] diff --git a/assets/images/wolfenstein/enemies.png b/assets/images/wolfenstein/enemies.png index 3324be7..0f8e499 100644 Binary files a/assets/images/wolfenstein/enemies.png and b/assets/images/wolfenstein/enemies.png differ diff --git a/assets/images/wolfenstein/enemies.psd b/assets/images/wolfenstein/enemies.psd index 0a5e8fe..a2f773c 100644 Binary files a/assets/images/wolfenstein/enemies.psd and b/assets/images/wolfenstein/enemies.psd differ diff --git a/data/wolfenstein-rules.json b/data/wolfenstein-rules.json index abd72ba..dc37b02 100644 --- a/data/wolfenstein-rules.json +++ b/data/wolfenstein-rules.json @@ -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 }, diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js index 443b8a5..a5e1d7c 100644 --- a/src/games/wolfenstein/WolfensteinEditor.js +++ b/src/games/wolfenstein/WolfensteinEditor.js @@ -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 guard’s 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 diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index 9979612..eb7b767 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -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 })), diff --git a/src/games/wolfenstein/WolfensteinRules.js b/src/games/wolfenstein/WolfensteinRules.js index 70bfcba..461f743 100644 --- a/src/games/wolfenstein/WolfensteinRules.js +++ b/src/games/wolfenstein/WolfensteinRules.js @@ -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, + }; } diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 576362a..2696273 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -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' diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index a2487e1..5794377 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -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[]` diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js index 2637c08..b035beb 100644 --- a/tools/verifyWolfenstein.js +++ b/tools/verifyWolfenstein.js @@ -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}`); + } } // ---------------------------------------------------------------------------