feat(wolfenstein): add enemy vision cone, patrol loop behavior, and weapon sprites

- Implement 90° field-of-view check for idle/patrolling guards (only alerts when player is within cone, in range, and has clear line of sight)
- Change patrol behavior: 0-1 waypoints ping-pong, 2+ waypoints form a one-way loop (home → nodes → home → ...)
- Reduce patrol speed to 50% of chase speed for natural movement
- Update editor to display loop closing line when 2+ patrol nodes exist
- Add new weapon sprite images (gattling, machinegun, plasma, rocket, shotgun)
- Extend map parser to support patrol route definitions via `opts.patrols`
- Add patrol routes to e1m1 (guard at 2.5,18.5) and e1m2 (guard at 9.5,8.5)
- Add comprehensive tests for vision cone (inside/outside edge cases) and patrol loop/ping-pong behavior
This commit is contained in:
Brian Fertig 2026-08-21 20:23:02 -06:00
parent 2cc075e8be
commit 9b3dccf4b5
12 changed files with 187 additions and 11 deletions

View File

@ -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": [],

View File

@ -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": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -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);

View File

@ -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;
}

View File

@ -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', `

View File

@ -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 }] };

View File

@ -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,