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': {