From 35f5b1c4651b4047102d88bae8234d25d1b22a80 Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 07:18:39 +0200 Subject: [PATCH] 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 --- src/main.ts | 4 +++ src/sim/units.test.ts | 84 +++++++++++++++++++++++++++++++++++++++++++ src/sim/units.ts | 59 +++++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index da719e5..8925234 100644 --- a/src/main.ts +++ b/src/main.ts @@ -622,6 +622,10 @@ async function boot() { pursuit.alert === 'hunted' ? (pursuit.lastSeen ?? { x: at.x, z: at.z }) : 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, graph, diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 1d17d2c..a49d117 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -472,3 +472,87 @@ describe('heat and units together', () => { 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, + 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(); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index ce859a2..9885934 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -59,6 +59,8 @@ export interface Unit { building?: BuildStage; /** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */ 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 SPEED: Record = { 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. */ const PATROL_DURATION = 90; /** 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. */ 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 { @@ -500,18 +531,38 @@ export function stepUnits( const dx = step.hunt.x - unit.x; const dz = step.hunt.z - unit.z; 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 // at it, and a ram is the player's move, not theirs. if (gap > 12) { - const move = Math.min(gap, unit.speed * dt); - unit.x += (dx / gap) * move; - unit.z += (dz / gap) * move; + const move = Math.min(gap, unit.chaseSpeed * dt); + // Straight at the target if there is a way through, otherwise round the + // 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. unit.expires = Math.max(unit.expires, 30); continue; } + // Off the chase, so back to whatever pace the job runs at. + unit.chaseSpeed = undefined; switch (unit.role) { case 'traffic': {