diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json index 8876cc8..9f04966 100644 --- a/assets/gamedata/wolfenstein/level-e1m1.json +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -322,7 +322,20 @@ "x": 2.5, "y": 18.5, "facing": 180, - "patrol": [] + "patrol": [ + { + "x": 2.5, + "y": 12.5 + }, + { + "x": 3.5, + "y": 12.5 + }, + { + "x": 3.5, + "y": 18.5 + } + ] } ], "items": [], diff --git a/assets/gamedata/wolfenstein/level-e1m2.json b/assets/gamedata/wolfenstein/level-e1m2.json index fd81f07..9e0a7bf 100644 --- a/assets/gamedata/wolfenstein/level-e1m2.json +++ b/assets/gamedata/wolfenstein/level-e1m2.json @@ -208,7 +208,17 @@ "type": "guard", "x": 9.5, "y": 8.5, - "facing": 180 + "facing": 180, + "patrol": [ + { + "x": 7.5, + "y": 8.5 + }, + { + "x": 12.5, + "y": 8.5 + } + ] } ], "items": [ diff --git a/assets/images/wolfenstein/weapon_gattling.png b/assets/images/wolfenstein/weapon_gattling.png new file mode 100644 index 0000000..f68d88c Binary files /dev/null and b/assets/images/wolfenstein/weapon_gattling.png differ diff --git a/assets/images/wolfenstein/weapon_machinegun.png b/assets/images/wolfenstein/weapon_machinegun.png new file mode 100644 index 0000000..3edb0fa Binary files /dev/null and b/assets/images/wolfenstein/weapon_machinegun.png differ diff --git a/assets/images/wolfenstein/weapon_plasma.png b/assets/images/wolfenstein/weapon_plasma.png new file mode 100644 index 0000000..1f9a8d6 Binary files /dev/null and b/assets/images/wolfenstein/weapon_plasma.png differ diff --git a/assets/images/wolfenstein/weapon_rocket.png b/assets/images/wolfenstein/weapon_rocket.png new file mode 100644 index 0000000..6a5a6ec Binary files /dev/null and b/assets/images/wolfenstein/weapon_rocket.png differ diff --git a/assets/images/wolfenstein/weapon_shotgun.png b/assets/images/wolfenstein/weapon_shotgun.png new file mode 100644 index 0000000..a5c8841 Binary files /dev/null and b/assets/images/wolfenstein/weapon_shotgun.png differ diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js index c78f840..443b8a5 100644 --- a/src/games/wolfenstein/WolfensteinEditor.js +++ b/src/games/wolfenstein/WolfensteinEditor.js @@ -423,7 +423,7 @@ export default class WolfensteinEditor extends Phaser.Scene { this.add.text(x, y + size + 16, 'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n' - + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes', + + 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop', { fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 }); } @@ -746,6 +746,14 @@ export default class WolfensteinEditor extends Phaser.Scene { * enemy itself, so it's obvious which guard you're currently editing. * Home (the enemy's own spawn point) is always node 0 of the walked path, * even though it isn't stored in `patrol` — matches stepPatrol(). + * + * There's no separate "loop" toggle: stepPatrol() closes the route into a + * one-way loop automatically once 2+ waypoints are authored (a 0- or + * 1-waypoint route ping-pongs, which looks identical to a loop for that + * few points anyway), so the overlay draws the same way — an extra + * closing segment from the last waypoint back to home whenever + * `e.patrol.length >= 2`, so what you see here always matches how the + * guard will actually walk it. */ drawPatrolRoutes() { const g = this.g; @@ -754,6 +762,7 @@ export default class WolfensteinEditor extends Phaser.Scene { for (const e of lvl.enemies) { if (!e.patrol || !e.patrol.length) continue; const selected = e === this.selectedEnemy; + const looped = e.patrol.length >= 2; const path = [{ x: e.x, y: e.y }, ...e.patrol]; g.lineStyle(selected ? 3 : 2, selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.55); for (let i = 0; i < path.length - 1; i++) { @@ -761,6 +770,11 @@ export default class WolfensteinEditor extends Phaser.Scene { const [bx, by] = this.toBoard(path[i + 1].x, path[i + 1].y); g.lineBetween(ax, ay, bx, by); } + if (looped) { + const [ax, ay] = this.toBoard(path[path.length - 1].x, path[path.length - 1].y); + const [bx, by] = this.toBoard(path[0].x, path[0].y); + g.lineBetween(ax, ay, bx, by); + } g.fillStyle(selected ? 0xffe066 : 0x887a3a, selected ? 1 : 0.7); for (const node of e.patrol) { const [nx, ny] = this.toBoard(node.x, node.y); diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index 3870a1d..049c86f 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -247,6 +247,15 @@ function stepDoors(state, rules) { // Enemy AI — idle -> alert -> chase -> attack -> dead // --------------------------------------------------------------------------- +// Total field-of-view width an idle/patrolling guard can spot the player +// in, centered on its own heading (e.angle) — ±45° either side. Only +// matters for the idle->alert transition below: once alerted, e.angle gets +// re-pointed straight at the player every tick (see the unconditional +// assignment further down), so it trivially stays inside its own cone for +// the rest of a chase/attack — this never causes an alerted guard to "lose" +// the player just because they circled behind it. +const ENEMY_FOV_RAD = Math.PI / 2; + function stepEnemyAI(state, rules) { const p = state.player; for (const e of state.enemies) { @@ -255,7 +264,9 @@ function stepEnemyAI(state, rules) { if (p.dead) { e.state = 'idle'; continue; } const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y); - const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y); + const angleToPlayer = Math.atan2(p.y - e.y, p.x - e.x); + const inFov = Math.abs(angleDiff(angleToPlayer, e.angle)) <= ENEMY_FOV_RAD / 2; + const canSee = distToPlayer <= def.detectRange && inFov && hasLineOfSight(state.map, e.x, e.y, p.x, p.y); if (e.state === 'idle') { if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); continue; } @@ -292,8 +303,20 @@ function stepEnemyAI(state, rules) { } const PATROL_ARRIVE_DIST = 0.12; +// Patrolling is a leisurely walk, not the urgent pace of a chase — half of +// def.speed (which chase/attack-approach movement still uses at full rate). +const PATROL_SPEED_MULT = 0.5; -/** Ping-pong an idle enemy along [home, ...patrol]; a no-op if it has no route. */ +/** + * Walk an idle enemy along [home, ...patrol]; a no-op if it has no route. + * With 2+ authored waypoints (3+ points counting home), the route closes + * into a one-way loop: home -> node1 -> ... -> nodeN -> home -> node1 -> ... + * forever, via patrolIndex wrapping (patrolIndex + 1) % path.length, never + * reversing. With 0 or 1 waypoints, home and the sole node are the same + * "loop" either way round, so it just ping-pongs between them via + * patrolDir (kept only for that 2-point case) — no editor UI to pick + * loop-vs-ping-pong; it's implicit in how many waypoints were authored. + */ function stepPatrol(state, rules, e, def) { if (!e.patrol || !e.patrol.length) return; const path = [{ x: e.homeX, y: e.homeY }, ...e.patrol]; @@ -302,13 +325,18 @@ function stepPatrol(state, rules, e, def) { const dx = target.x - e.x, dy = target.y - e.y; const dist = Math.hypot(dx, dy); if (dist <= PATROL_ARRIVE_DIST) { - let next = e.patrolIndex + e.patrolDir; - if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; } + let next; + if (e.patrol.length >= 2) { + next = (e.patrolIndex + 1) % path.length; + } else { + next = e.patrolIndex + e.patrolDir; + if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; } + } e.patrolIndex = next; return; } - const mvx = (dx / dist) * def.speed * rules.dt; - const mvy = (dy / dist) * def.speed * rules.dt; + const mvx = (dx / dist) * def.speed * PATROL_SPEED_MULT * rules.dt; + const mvy = (dy / dist) * def.speed * PATROL_SPEED_MULT * rules.dt; moveWithCollision(state.map, e, mvx, mvy, def.radius); e.angle = Math.atan2(dy, dx); } @@ -317,12 +345,17 @@ function stepPatrol(state, rules, e, def) { // Weapons // --------------------------------------------------------------------------- +/** Smallest signed difference a-b, wrapped to [-pi, pi] — shared by the melee arc check below and the enemy vision cone above. */ +function angleDiff(a, b) { + const d = a - b; + return Math.atan2(Math.sin(d), Math.cos(d)); +} + function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) { const dx = tx - px, dy = ty - py; if (Math.hypot(dx, dy) > range) return false; const toTarget = Math.atan2(dy, dx); - let diff = toTarget - pAngle; - diff = Math.atan2(Math.sin(diff), Math.cos(diff)); + const diff = angleDiff(toTarget, pAngle); return Math.abs(diff) <= arcRad / 2; } diff --git a/tools/genWolfenstein.js b/tools/genWolfenstein.js index 1197fe2..8bd6001 100644 --- a/tools/genWolfenstein.js +++ b/tools/genWolfenstein.js @@ -50,6 +50,11 @@ lvl('e1m2', 'The Armory', ` `, { campaignId: 'episode1', missionIndex: 1, briefing: ['Tougher resistance ahead.', 'The armory door only opens from this side.'], + // The second guard (E index 1, at 9,8 — reading order top-to-bottom, + // left-to-right) walks the open floor along its own row; the first guard + // stays put. See wolfensteinMap.js's legend comment for the opts.patrols + // convention. + patrols: { 1: [[7, 8], [12, 8]] }, }); lvl('e2m1', 'Descent', ` diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js index 221d1da..583ad40 100644 --- a/tools/verifyWolfenstein.js +++ b/tools/verifyWolfenstein.js @@ -195,6 +195,89 @@ section('4. Enemy AI'); check('guard with clear line of sight alerts promptly', alerted); } + // Clear line of sight, but the player is behind the guard (facing 0 = east, + // player is to the west) — outside the 90 degree vision cone, so it should + // never alert no matter how long it stands there. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 0 }] }; + const state = L.createState(level, rules); + for (let i = 0; i < 300; i++) L.tick(state, rules); + check('guard with player outside its vision cone stays idle', state.enemies[0].state === 'idle', state.enemies[0].state); + } + + // Same idea, but the player sits just inside the +-45 degree cone edge + // (~44 degrees off the guard's heading, facing 180 = west) — should still + // alert. Kept away from the row-3 internal wall so LOS itself stays clear. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 6.4, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 4.5, facing: 180 }] }; + const state = L.createState(level, rules); + let alerted = false; + for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; } + check('guard alerts to a player near the cone edge but still inside it', alerted); + } + + // Patrol routes (stepPatrol): an idle guard with no player interference + // should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard + // and its whole route stay strictly west of the row-3 wall (x < 3), player + // stays strictly east of it (x = 6.5) — a straight line between them at + // y=3.5 always crosses the wall cells at x=3/4 regardless of where along + // its route the guard currently is, so this isolates pure patrol movement + // from AI detection (never alerts here, unlike the two tests above). + { + const level = { + ...baseLevel, playerStart: { x: 6.5, y: 3.5, angle: 0 }, + enemies: [{ type: 'guard', x: 1.5, y: 3.5, facing: 0, patrol: [{ x: 2.5, y: 3.5 }] }], + }; + const state = L.createState(level, rules); + let minX = Infinity, maxX = -Infinity, everAlerted = false; + for (let i = 0; i < 2000; i++) { + L.tick(state, rules); + const e = state.enemies[0]; + if (e.state !== 'idle') everAlerted = true; + minX = Math.min(minX, e.x); maxX = Math.max(maxX, e.x); + } + check('patrolling guard never alerts (LOS blocked its whole route)', !everAlerted); + check('a 1-waypoint route ping-pongs between home and the node', minX <= 1.6 && maxX >= 2.3, `x range [${minX.toFixed(2)}, ${maxX.toFixed(2)}]`); + } + + // A route with 2+ authored waypoints closes into a one-way loop instead + // (home -> node1 -> node2 -> home -> ..., wrapping via + // (patrolIndex + 1) % path.length, never reversing — see stepPatrol's + // doc comment). Isolated from AI detection by distance alone here (a + // bigger, wall-free map, player far outside detectRange=8 the whole + // route) rather than the wall trick above, since the route touches more + // than one row/column this time. + { + const W = 14, H = 14; + const walls = []; + for (let y = 0; y < H; y++) { + const row = []; + for (let x = 0; x < W; x++) row.push(x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0); + walls.push(row); + } + const level = { + width: W, height: H, cellSize: 64, walls, doors: [], items: [], + playerStart: { x: 12.5, y: 12.5, angle: 0 }, + enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0, patrol: [{ x: 3.5, y: 1.5 }, { x: 1.5, y: 3.5 }] }], + exit: { x: 12.5, y: 1.5, radius: 0.6 }, + }; + const state = L.createState(level, rules); + const indices = [state.enemies[0].patrolIndex]; + let everAlerted = false; + for (let i = 0; i < 3000; i++) { + L.tick(state, rules); + const e = state.enemies[0]; + if (e.state !== 'idle') everAlerted = true; + if (e.patrolIndex !== indices[indices.length - 1]) indices.push(e.patrolIndex); + } + let cyclesForward = indices.length >= 6; + for (let i = 1; i < indices.length && cyclesForward; i++) { + if (indices[i] !== (indices[i - 1] + 1) % 3) cyclesForward = false; + } + check('a 2-waypoint route never alerts (player far outside detectRange)', !everAlerted); + check('a 2-waypoint route closes into a one-way loop, never reversing', cyclesForward, indices.slice(0, 9).join(',')); + } + // A dead guard stops acting and can't be hit/killed twice. { const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] }; diff --git a/tools/wolfensteinMap.js b/tools/wolfensteinMap.js index 610b5fc..05b6e21 100644 --- a/tools/wolfensteinMap.js +++ b/tools/wolfensteinMap.js @@ -13,6 +13,18 @@ // Every row must be the same length; the outer ring must be entirely wall/door // (auditLevel below hard-rejects an open boundary or an unreachable exit — // the same BFS-solvability gate tools/genBloxorz.js uses). +// +// Patrol routes can't be expressed as single ASCII characters (a route is a +// variable-length list of waypoints, not a single cell), so they're passed +// separately via `opts.patrols` instead: `{ 0: [[x,y], [x,y]], ... }`, keyed +// by each guard's 0-based index in reading order (top-to-bottom, +// left-to-right scan of the 'E' characters — the same order `enemies` gets +// built in below). Coordinates are cell coords, auto-offset to the cell +// center exactly like every other symbol here. Matches WolfensteinLogic's +// `patrol` field (an idle guard ping-pongs home -> patrol[0] -> ... -> home, +// see stepPatrol) and the editor's Patrol tool — a level authored here and +// one with a patrol route drawn in the editor are indistinguishable to +// WolfensteinLogic either way. import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js'; @@ -53,6 +65,12 @@ export function parseMap(id, name, art, opts = {}) { if (!playerStart) throw new Error(`level ${id}: missing S (player start)`); if (!exit) throw new Error(`level ${id}: missing X (exit)`); + for (const [idx, nodes] of Object.entries(opts.patrols ?? {})) { + const enemy = enemies[Number(idx)]; + if (!enemy) throw new Error(`level ${id}: patrols[${idx}] has no matching E (only ${enemies.length} enemies)`); + enemy.patrol = nodes.map(([nx, ny]) => ({ x: nx + 0.5, y: ny + 0.5 })); + } + return { version: 1, id, name, campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0,