Hunters that can actually catch you

A patrol drove at 14 m/s whether it was on its rounds or chasing. A
healthy car does 29. Being hunted therefore meant holding W until the
95m sight radius did the rest: no cornering, no routing, no decision
anywhere in it.

Chasing cars wind up to 21 m/s, which sits deliberately between the
two - faster than anyone averages through a grid of junctions and
buildings, slower than the car's top end on a clear run. A long trunk
straight is an escape and the lanes are not, so the road hierarchy is
under real pressure for the first time: the fast conspicuous road is
the one you want when they are behind you, and it is the one they will
be looking on next time.

They also go round buildings now instead of through them. Line of
sight was already blocked by them and movement was not, which made
"break line of sight and change direction" advice the world did not
honour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 07:18:39 +02:00
parent 9e476087d4
commit 35f5b1c465
3 changed files with 143 additions and 4 deletions

View File

@ -622,6 +622,10 @@ async function boot() {
pursuit.alert === 'hunted' pursuit.alert === 'hunted'
? (pursuit.lastSeen ?? { x: at.x, z: at.z }) ? (pursuit.lastSeen ?? { x: at.x, z: at.z })
: null, : null,
// Hunters come round the buildings, not through them. Line of sight
// is already blocked by them; movement has to be too, or "break line
// of sight and change direction" is advice the world does not honour.
blocked: insideBuilding,
}, },
model.roads, model.roads,
graph, graph,

View File

@ -472,3 +472,87 @@ describe('heat and units together', () => {
expect(heat.value[4]).toBe(0.5); expect(heat.value[4]).toBe(0.5);
}); });
}); });
describe('a hunter', () => {
/** One enemy car, chasing, some way off to one side. */
const chaser = () => {
const state = createUnits();
const unit = dispatchTo(
state,
world.roads,
graph,
world.roads.segments[4]!,
'patrol',
makeRng(7),
)!;
unit.x = 0;
unit.z = 0;
unit.path = [];
unit.hunting = true;
return { state, unit };
};
const chase = (
state: ReturnType<typeof createUnits>,
seconds: number,
target: { x: number; z: number },
blocked?: (x: number, z: number) => boolean,
) => {
for (let i = 0; i < seconds * 60; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: target,
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: target,
blocked,
},
world.roads,
graph,
makeRng(5),
);
}
};
it('drives faster chasing than it does on its rounds', () => {
const { state, unit } = chaser();
const patrolSpeed = unit.speed;
chase(state, 6, { x: 0, z: 600 });
// Covered more ground than a patrol at its working pace ever could, and
// still nowhere near the 29 m/s a healthy car will do on a clear run.
expect(unit.z).toBeGreaterThan(patrolSpeed * 6);
expect(unit.z).toBeLessThan(29 * 6);
});
it('winds up rather than starting at chase speed', () => {
const { state, unit } = chaser();
chase(state, 0.25, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeLessThan(21);
chase(state, 5, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeCloseTo(21, 1);
});
it('goes round a building instead of through it', () => {
const { state, unit } = chaser();
// A wall straight across the path, with open ground either side of it.
const wall = (x: number, z: number) => z > 40 && z < 60 && Math.abs(x) < 30;
chase(state, 12, { x: 0, z: 300 }, wall);
expect(wall(unit.x, unit.z)).toBe(false);
// It went round the end rather than sitting against the face of it.
expect(Math.abs(unit.x)).toBeGreaterThan(10);
});
it('drops back to its working pace once the chase is off', () => {
const { state, unit } = chaser();
chase(state, 5, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeGreaterThan(unit.speed);
unit.hunting = false;
chase(state, 1, { x: 0, z: 600 });
expect(unit.chaseSpeed).toBeUndefined();
});
});

View File

@ -59,6 +59,8 @@ export interface Unit {
building?: BuildStage; building?: BuildStage;
/** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */ /** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */
hunting?: boolean; hunting?: boolean;
/** Current speed while chasing, wound up from a standing start. */
chaseSpeed?: number;
} }
/** /**
@ -113,6 +115,30 @@ export const SIM_RADIUS = 640;
const CIVILIAN_FLEE_RANGE = 70; const CIVILIAN_FLEE_RANGE = 70;
const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 }; const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
/**
* How fast a car drives when it is chasing you rather than working.
*
* A patrol on its rounds does 14 m/s. A healthy player tops out around 29, so
* at ordinary speed a chase was won by holding W until the 95m sight radius
* did the rest there was no decision in it anywhere.
*
* 21 m/s sits deliberately between the two: faster than anyone actually
* averages through a grid of buildings and junctions, slower than the car's
* top end on a clear run. So a long trunk straight is an escape and the lanes
* are not, which puts the road hierarchy under real pressure for the first
* time the fast conspicuous road becomes the one you *want* when they are
* behind you, and it is the one they will look on next time.
*/
const HUNT_SPEED = 21;
/** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */
const HUNT_ACCELERATION = 7;
/**
* Headings a hunter will try, in order, when the straight line is blocked:
* dead ahead, then further and further round either side of the obstruction.
* Alternating sides means they take whichever way round is actually open
* rather than committing to a direction and grinding along a wall.
*/
const HUNT_DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
/** Seconds a dispatched patrol works its road before leaving. */ /** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90; const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */ /** Heat removed per second by a patrol actually driving its assigned road. */
@ -445,6 +471,11 @@ export interface UnitStep {
* were seen. Null when nobody is chasing. * were seen. Null when nobody is chasing.
*/ */
hunt: { x: number; z: number } | null; hunt: { x: number; z: number } | null;
/**
* Is this point inside a building? Hunters drive round them rather than
* through them, which is the entire reason turning a corner works.
*/
blocked?: (x: number, z: number) => boolean;
} }
export interface UnitEvents { export interface UnitEvents {
@ -500,18 +531,38 @@ export function stepUnits(
const dx = step.hunt.x - unit.x; const dx = step.hunt.x - unit.x;
const dz = step.hunt.z - unit.z; const dz = step.hunt.z - unit.z;
const gap = Math.hypot(dx, dz); const gap = Math.hypot(dx, dz);
unit.heading = Math.atan2(dx, dz); const wanted = Math.atan2(dx, dz);
unit.heading = wanted;
// Cars wind up to chase speed; people on foot chase at the pace they walk.
const top = unit.kind === 'car' ? HUNT_SPEED : unit.speed;
unit.chaseSpeed = Math.min(top, (unit.chaseSpeed ?? unit.speed) + HUNT_ACCELERATION * dt);
// Hold off a little rather than piling into the car; they want to shoot // Hold off a little rather than piling into the car; they want to shoot
// at it, and a ram is the player's move, not theirs. // at it, and a ram is the player's move, not theirs.
if (gap > 12) { if (gap > 12) {
const move = Math.min(gap, unit.speed * dt); const move = Math.min(gap, unit.chaseSpeed * dt);
unit.x += (dx / gap) * move; // Straight at the target if there is a way through, otherwise round the
unit.z += (dz / gap) * move; // side of whatever is in the way. Without this they drive through the
// buildings, and a chase you cannot lose by turning a corner is not a
// chase — it is a timer.
for (const offset of HUNT_DETOURS) {
const heading = wanted + offset;
const x = unit.x + Math.sin(heading) * move;
const z = unit.z + Math.cos(heading) * move;
if (step.blocked?.(x, z)) continue;
unit.x = x;
unit.z = z;
unit.heading = heading;
break;
}
} }
// Being chased keeps a unit alive past its ordinary shift. // Being chased keeps a unit alive past its ordinary shift.
unit.expires = Math.max(unit.expires, 30); unit.expires = Math.max(unit.expires, 30);
continue; continue;
} }
// Off the chase, so back to whatever pace the job runs at.
unit.chaseSpeed = undefined;
switch (unit.role) { switch (unit.role) {
case 'traffic': { case 'traffic': {