diff --git a/README.md b/README.md index a43a720..da7ca03 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Endless driving survival game. - **Phase 5** — four distinct jobs, radio texture, and work found in the field. - **Phase 6** — an inhabited world: traffic, people, patrols, and a live war. - **Phase 7** — sound, synthesised from the simulation. +- **Phase 8** — recognition: being noticed, being hunted, and getting away. ## Running @@ -275,6 +276,37 @@ is permanent in the same way everything else is. a target. They open up only past the line *and* on a road they have already checkpointed — that is, a road you personally made notorious. +## Being recognised + +You are an undercover driver, not a combatant, and that only means anything if +cover is a state you can *lose*. + +**Suspicion** builds while enemy eyes are on you — faster the closer they are, +squared with proximity, so one patrol beside the car matters more than a crowd +across the street. Line of sight is real: buildings block it. Behind your own +lines it cannot build at all. Some things skip the meter entirely and land in +full — ramming a vehicle, being shot at, and above all putting somebody under +the wheels. + +At the top of the meter you are **hunted**. Nearby enemies drop what they were +doing and come after you, and they keep recruiting as you drive past fresh +patrols — a chase you could outrun by passing more of them would be no chase. +This is also the *only* thing that makes the enemy shoot at you: being a target +is a state, not a property of the road you are on. + +**Getting away** takes one of two things: + +- **Break line of sight for fourteen seconds.** They drive to where they last + saw you, not to where you are, so turning a corner and changing direction is + the move. Any glimpse resets the clock. +- **Let your own side occupy them.** Hunters are patrols, not fanatics: an + insurgent squad on their doorstep is a better use of their time. Enough of + them peeling off ends the chase outright. + +Escaping does not hand your cover straight back. Suspicion drops to 0.6 and +decays slowly from there — they know there is a car worth looking for, and you +have to be dull for a while to be forgotten. + ## Debug Load with `?debug=1` for a `window.__dbtl` handle and a small panel in the diff --git a/src/main.ts b/src/main.ts index 6714673..c375351 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,6 +28,13 @@ import { createOpportunities, stepOpportunities } from './sim/opportunities'; import { createHeatProps } from './heatProps'; import { createUnits, dispatchTo, stepUnits } from './sim/units'; import { createCombat, dangerNear, stepCombat } from './sim/combat'; +import { + createPursuit, + PROVOCATION, + recognitionMeter, + stepPursuit, + LOSE_SECONDS, +} from './sim/pursuit'; import { createUnitView } from './render/units'; import { createUnitBodies } from './unitBodies'; import { createPersistence } from './persistence'; @@ -103,6 +110,7 @@ async function boot() { const combat = createCombat(); const unitView = createUnitView(view.scene); const unitBodies = createUnitBodies(physics); + const pursuit = createPursuit(); // Bullets stop at buildings, so combat needs a fast "is this inside a wall" // lookup. A grid built once at boot beats scanning a thousand obstacles. @@ -135,6 +143,22 @@ async function boot() { return false; }; + /** + * Can one point see another? Marched rather than swept, at a step short + * enough that the smallest building cannot be stepped over. + */ + const canSee = (from: { x: number; z: number }, to: { x: number; z: number }): boolean => { + const dx = to.x - from.x; + const dz = to.z - from.z; + const distance = Math.hypot(dx, dz); + const steps = Math.ceil(distance / 3); + for (let i = 1; i < steps; i++) { + const t = i / steps; + if (insideBuilding(from.x + dx * t, from.z + dz * t)) return false; + } + return true; + }; + /** Physics bodies for finished checkpoint towers, so they are solid cover. */ const towerBodies = new Map>(); const opportunities = createOpportunities(); @@ -168,6 +192,8 @@ async function boot() { let resetHeld = 0; /** People you have killed who were not part of anyone's war. */ let civilianDeaths = 0; + /** Suspicion earned outright this step by something the player just did. */ + let provocation = 0; const say = (text: string, seconds = 4) => { notice = text; @@ -499,6 +525,13 @@ async function boot() { const cell = areaAt(areas, x, z); if (cell !== null) heat.area[cell] = Math.max(0, heat.area[cell]! - amount); }, + // Head for the player if they are in view, otherwise for wherever + // they were last seen — which is what makes breaking line of sight + // and then changing direction actually work. + hunt: + pursuit.alert === 'hunted' + ? (pursuit.lastSeen ?? { x: at.x, z: at.z }) + : null, }, model.roads, graph, @@ -535,6 +568,10 @@ async function boot() { for (const victim of contact.ranOver) { audio.impact(26000); civilianDeaths += victim.faction === 'civilian' ? 1 : 0; + provocation += + victim.faction === 'civilian' + ? PROVOCATION.ranOverCivilian + : PROVOCATION.ranOverSoldier; say( victim.faction === 'civilian' ? 'You just put someone under the wheels.' @@ -542,17 +579,32 @@ async function boot() { 4, ); } - if (contact.rammed.length > 0 && elapsed > noticeUntil) { - say('Metal on metal. Somebody noticed that.', 3); + if (contact.rammed.length > 0) { + provocation += PROVOCATION.ram * contact.rammed.length; + if (elapsed > noticeUntil) say('Metal on metal. Somebody noticed that.', 3); } + // --- Being noticed --- + for (const event of stepPursuit(pursuit, { + dt, + player: { x: at.x, z: at.z }, + control, + units, + canSee, + provocation, + })) { + if (event.kind === 'recognised') say('They have made you. Drive.', 6); + if (event.kind === 'lost') say('You lost them.', 5); + if (event.kind === 'distracted' && event.remaining > 0) { + say('Ours have pulled some of them off you.', 4); + } + } + provocation = 0; + // --- Shooting --- - // The enemy shoots at the player only once a road has been noticed enough - // to be checkpointed, and only past the line. Undercover means undercover. - const exposed = - (control === 'occupied' || control === 'frontier') && - currentSegment !== null && - (heat.level[currentSegment] === 'barricade' || heat.level[currentSegment] === 'turret'); + // They shoot at you because they have recognised you, not because of the + // road you happen to be on. Being hunted *is* the state of being a target. + const exposed = pursuit.alert === 'hunted'; const shooting = stepCombat( combat, @@ -570,6 +622,7 @@ async function boot() { const listener = { x: at.x, z: at.z, heading: headingOf() }; for (const muzzle of shooting.fired) audio.shot(muzzle, listener); if (shooting.playerHit) { + provocation += PROVOCATION.shotAt; audio.hit(); if (elapsed > noticeUntil) say('Taking fire.', 2.5); } @@ -696,6 +749,13 @@ async function boot() { } : null, control, + pursuit: { + meter: recognitionMeter(pursuit), + alert: pursuit.alert, + hunters: pursuit.hunters.size, + // Counts down only once nobody has eyes on you. + losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0, + }, danger: dangerNear(combat, p.x, p.z, 90), completed: quests.completed, parts: quests.parts, @@ -714,7 +774,7 @@ async function boot() { view, physics, // Live state, so a script can find something interesting and go look at it. - state: { units, combat, heat, areas, intel, quests, front, model, bases }, + state: { units, combat, heat, areas, intel, quests, front, model, bases, pursuit }, condition: () => condition, /** * Force the car's condition, for looking at how a wrecked car drives @@ -749,6 +809,9 @@ async function boot() { condition, front: front.boundaries, opportunity: opportunities.current, + alert: pursuit.alert, + suspicion: +pursuit.suspicion.toFixed(2), + hunters: pursuit.hunters.size, civilianDeaths, unitBodies: unitBodies.count, units: units.units.length, diff --git a/src/sim/pursuit.test.ts b/src/sim/pursuit.test.ts new file mode 100644 index 0000000..fbfbeae --- /dev/null +++ b/src/sim/pursuit.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; +import { + createPursuit, + LOSE_SECONDS, + PROVOCATION, + recognitionMeter, + SIGHT_RANGE, + stepPursuit, + type PursuitState, + type PursuitStep, +} from './pursuit'; +import { createUnits, type Faction, type Unit, type UnitState } from './units'; +import type { Control } from './regions'; + +function place(state: UnitState, faction: Faction, x: number, z: number): Unit { + const unit: Unit = { + id: state.nextId++, + kind: 'car' as const, + faction, + role: 'patrol' as const, + x, + z, + heading: 0, + speed: 14, + hp: 60, + path: [] as number[], + expires: 999, + assigned: null, + onStation: 0, + cooldown: 0, + elevation: 1, + }; + state.units.push(unit); + return unit; +} + +/** Runs the chase for a while, collecting whatever happened. */ +function run( + state: PursuitState, + units: UnitState, + seconds: number, + overrides: Partial = {}, +) { + const events: string[] = []; + for (let i = 0; i < seconds * 20; i++) { + for (const event of stepPursuit(state, { + dt: 0.05, + player: { x: 0, z: 0 }, + control: 'occupied' as Control, + units, + canSee: () => true, + provocation: 0, + ...overrides, + })) { + events.push(event.kind); + } + } + return events; +} + +describe('being noticed', () => { + it('builds faster the closer they are', () => { + const near = createPursuit(); + const far = createPursuit(); + const nearUnits = createUnits(); + const farUnits = createUnits(); + place(nearUnits, 'enemy', 10, 0); + place(farUnits, 'enemy', SIGHT_RANGE - 10, 0); + + run(near, nearUnits, 2); + run(far, farUnits, 2); + expect(near.suspicion).toBeGreaterThan(far.suspicion * 3); + }); + + it('ignores anyone too far off to make you out', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', SIGHT_RANGE + 20, 0); + run(state, units, 10); + expect(state.suspicion).toBe(0); + }); + + it('cannot be blown behind your own lines at all', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', 5, 0); + run(state, units, 20, { control: 'liberated' }); + // Nobody there cares who is driving past. + expect(state.suspicion).toBe(0); + expect(state.alert).toBe('clear'); + }); + + it('is not built by civilians or your own side', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'civilian', 5, 0); + place(units, 'insurgent', 6, 0); + run(state, units, 20); + expect(state.suspicion).toBe(0); + }); + + it('does not build through a wall', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', 8, 0); + run(state, units, 20, { canSee: () => false }); + expect(state.suspicion).toBe(0); + }); + + it('fades once nobody is looking', () => { + const state = createPursuit(); + const units = createUnits(); + const watcher = place(units, 'enemy', 8, 0); + run(state, units, 1); + const noticed = state.suspicion; + expect(noticed).toBeGreaterThan(0); + + watcher.x = 9000; + run(state, units, 5); + expect(state.suspicion).toBeLessThan(noticed); + }); + + it('jumps outright for something nobody could miss', () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', 9000, 0); + // Nobody in sight — but you just drove over somebody. + stepPursuit(state, { + dt: 1 / 60, + player: { x: 0, z: 0 }, + control: 'occupied', + units, + canSee: () => true, + provocation: PROVOCATION.ranOverSoldier, + }); + expect(state.suspicion).toBeCloseTo(PROVOCATION.ranOverSoldier, 2); + }); +}); + +describe('being hunted', () => { + const hunted = () => { + const state = createPursuit(); + const units = createUnits(); + place(units, 'enemy', 8, 0); + const events = run(state, units, 12); + return { state, units, events }; + }; + + it('is recognised once the meter fills, and says so once', () => { + const { state, events } = hunted(); + expect(state.alert).toBe('hunted'); + expect(recognitionMeter(state)).toBe(1); + expect(events.filter((e) => e === 'recognised')).toHaveLength(1); + }); + + it('puts the nearby enemy onto the chase', () => { + const { state, units } = hunted(); + expect(state.hunters.size).toBeGreaterThan(0); + expect(units.units.filter((u) => u.hunting).length).toBe(state.hunters.size); + }); + + it('picks up fresh units you drive past mid-chase', () => { + const { state, units } = hunted(); + const before = state.hunters.size; + place(units, 'enemy', 60, 0); + run(state, units, 0.2); + // A chase you could outrun by simply passing new patrols would be no chase. + expect(state.hunters.size).toBeGreaterThan(before); + }); + + it('keeps chasing while they can still see you', () => { + const { state, units } = hunted(); + run(state, units, LOSE_SECONDS * 2); + expect(state.alert).toBe('hunted'); + }); + + it('remembers where you were last seen', () => { + const { state, units } = hunted(); + run(state, units, 0.2, { player: { x: 40, z: 25 } }); + expect(state.lastSeen).toEqual({ x: 40, z: 25 }); + }); +}); + +describe('losing them', () => { + const chased = () => { + const state = createPursuit(); + const units = createUnits(); + const hunter = place(units, 'enemy', 8, 0); + run(state, units, 12); + expect(state.alert).toBe('hunted'); + return { state, units, hunter }; + }; + + it('takes a sustained break in line of sight, not a moment', () => { + const { state, units } = chased(); + run(state, units, LOSE_SECONDS - 3, { canSee: () => false }); + // Ducked behind something, but not for long enough yet. + expect(state.alert).toBe('hunted'); + + const events = run(state, units, 4, { canSee: () => false }); + expect(events).toContain('lost'); + expect(state.alert).toBe('suspicious'); + }); + + it('resets the clock the moment they see you again', () => { + const { state, units } = chased(); + run(state, units, LOSE_SECONDS - 2, { canSee: () => false }); + run(state, units, 0.5, { canSee: () => true }); + expect(state.unseenFor).toBe(0); + + // And the earlier hiding does not count toward the next attempt. + run(state, units, LOSE_SECONDS - 2, { canSee: () => false }); + expect(state.alert).toBe('hunted'); + }); + + it('does not hand your cover straight back', () => { + const { state, units } = chased(); + run(state, units, LOSE_SECONDS + 2, { canSee: () => false }); + // They know there is a car worth looking for. + expect(state.suspicion).toBeGreaterThan(0); + expect(state.alert).toBe('suspicious'); + expect(units.units.some((u) => u.hunting)).toBe(false); + }); + + it('ends the chase if every hunter is gone', () => { + const { state, units } = chased(); + units.units = []; + const events = run(state, units, 0.2); + expect(events).toContain('lost'); + expect(state.hunters.size).toBe(0); + }); +}); + +describe('your own side pulling them off you', () => { + it('drops a hunter that has insurgents on its doorstep', () => { + const state = createPursuit(); + const units = createUnits(); + const hunter = place(units, 'enemy', 8, 0); + run(state, units, 12); + expect(state.hunters.has(hunter.id)).toBe(true); + + // Allies turn up where the hunter is. + place(units, 'insurgent', hunter.x + 5, hunter.z); + const events = run(state, units, 0.2); + + expect(events).toContain('distracted'); + expect(state.hunters.has(hunter.id)).toBe(false); + expect(hunter.hunting).toBe(false); + }); + + it('calls the chase off entirely when they are the only one on you', () => { + const state = createPursuit(); + const units = createUnits(); + const hunter = place(units, 'enemy', 8, 0); + run(state, units, 12); + + place(units, 'insurgent', hunter.x + 5, hunter.z); + const events = run(state, units, 0.5); + // Distraction is a way out of a chase, not just a smaller chase. + expect(events).toContain('lost'); + expect(state.alert).toBe('suspicious'); + }); + + it('leaves the chase on if only some of them peel away', () => { + const state = createPursuit(); + const units = createUnits(); + const first = place(units, 'enemy', 8, 0); + // Well clear of the first, or the same insurgents distract both of them. + place(units, 'enemy', 90, 0); + run(state, units, 12); + expect(state.hunters.size).toBe(2); + + place(units, 'insurgent', first.x + 4, first.z); + run(state, units, 0.2); + expect(state.hunters.size).toBe(1); + expect(state.alert).toBe('hunted'); + }); +}); diff --git a/src/sim/pursuit.ts b/src/sim/pursuit.ts new file mode 100644 index 0000000..1df3418 --- /dev/null +++ b/src/sim/pursuit.ts @@ -0,0 +1,225 @@ +/** + * Being noticed, being chased, and getting away. Pure — no engine imports. + * + * The brief's premise is that the player is an undercover driver, not a + * combatant. That only means anything if cover is a *state you can lose*. So: + * suspicion builds while enemy eyes are on you, faster the closer they are and + * the worse the thing you just did; past a threshold you are recognised and + * hunted; and you get out of it by breaking line of sight for long enough, or + * by the people chasing you finding something more urgent to shoot at. + * + * The car is never a match for them in a fight. Escape is the only win here. + */ +import type { Unit, UnitState } from './units'; +import type { Control } from './regions'; + +export type Alert = 'clear' | 'suspicious' | 'hunted'; + +export interface PursuitState { + /** 0..1. At 1 you have been recognised. */ + suspicion: number; + alert: Alert; + /** Seconds since anyone hunting you last had eyes on you. */ + unseenFor: number; + /** Where they last saw you, which is where they will go looking. */ + lastSeen: { x: number; z: number } | null; + /** Unit ids currently chasing. */ + hunters: Set; +} + +export const createPursuit = (): PursuitState => ({ + suspicion: 0, + alert: 'clear', + unseenFor: 0, + lastSeen: null, + hunters: new Set(), +}); + +// --- Tuning --------------------------------------------------------------- + +/** How far an enemy can make you out at all. */ +export const SIGHT_RANGE = 95; +/** Suspicion per second with someone right on top of you. */ +const MAX_GAIN = 0.42; +/** + * Suspicion shed per second with nobody watching. + * + * Slow on purpose. Cover is meant to be something you lose and then have to + * earn back by staying dull for a while; at a brisk decay rate you are clean + * again before you have finished the corner. + */ +const DECAY = 0.05; +/** Suspicion at which you stop being a car and start being a target. */ +const RECOGNISED = 1; +/** Above this the HUD starts warning you. */ +const SUSPICIOUS = 0.3; +/** Seconds out of sight before the chase is called off. */ +export const LOSE_SECONDS = 14; +/** + * What suspicion drops to after you shake them. Never straight back to nothing, + * and deliberately well clear of the "suspicious" line — at a hair above it the + * afterglow expired within a second and the rule may as well not have existed. + */ +const AFTER_ESCAPE = 0.6; +/** Enemies this close when you are recognised join the chase. */ +const RECRUIT_RANGE = 190; +/** A hunter with a hostile this close has better things to do than chase you. */ +const DISTRACTION_RANGE = 70; + +/** + * How much attention a place pays you. Nobody behind your own lines cares who + * is driving past, so cover cannot be blown there at all. + */ +const EXPOSURE: Record = { + liberated: 0, + contested: 0.5, + occupied: 1, + frontier: 1.2, +}; + +/** One-off jumps in suspicion, for things nobody could miss. */ +export const PROVOCATION = { + /** Shunting a vehicle in traffic. */ + ram: 0.3, + /** Putting one of theirs under the wheels. */ + ranOverSoldier: 0.75, + /** Putting a bystander under the wheels. */ + ranOverCivilian: 0.5, + /** Being shot at means somebody has already decided about you. */ + shotAt: 0.4, +} as const; + +// --- Step ----------------------------------------------------------------- + +export interface PursuitStep { + dt: number; + player: { x: number; z: number }; + control: Control; + units: UnitState; + /** Line of sight between two points; buildings block it. */ + canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean; + /** Suspicion added outright this step by things the player just did. */ + provocation: number; +} + +export type PursuitEvent = + | { kind: 'recognised' } + | { kind: 'lost' } + | { kind: 'distracted'; remaining: number }; + +/** Enemies who could see the player right now. */ +function watchers(step: PursuitStep): Unit[] { + return step.units.units.filter((unit) => { + if (unit.faction !== 'enemy') return false; + if (Math.hypot(unit.x - step.player.x, unit.z - step.player.z) > SIGHT_RANGE) return false; + return step.canSee(unit, step.player); + }); +} + +export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEvent[] { + const events: PursuitEvent[] = []; + const exposure = EXPOSURE[step.control]; + const seenBy = watchers(step); + + // --- Suspicion --- + if (exposure > 0 && seenBy.length > 0) { + // Whoever has the best look at you sets the pace; a crowd is not more + // suspicious than one person standing right next to the car. + let closest = SIGHT_RANGE; + for (const unit of seenBy) { + closest = Math.min(closest, Math.hypot(unit.x - step.player.x, unit.z - step.player.z)); + } + const proximity = (1 - closest / SIGHT_RANGE) ** 2; + state.suspicion += proximity * MAX_GAIN * exposure * step.dt; + } else if (state.alert !== 'hunted') { + state.suspicion -= DECAY * step.dt; + } + + // Provocations land whatever the range, and they land in full: driving over + // somebody is not something you get away with by being far off at the time. + if (step.provocation > 0 && exposure > 0) state.suspicion += step.provocation; + state.suspicion = Math.max(0, Math.min(RECOGNISED, state.suspicion)); + + // --- Recognition --- + if (state.alert !== 'hunted' && state.suspicion >= RECOGNISED) { + state.alert = 'hunted'; + state.unseenFor = 0; + events.push({ kind: 'recognised' }); + } else if (state.alert !== 'hunted') { + state.alert = state.suspicion >= SUSPICIOUS ? 'suspicious' : 'clear'; + } + + if (state.alert !== 'hunted') { + // Nobody is chasing, so nobody is a hunter. + for (const id of state.hunters) { + const unit = step.units.units.find((u) => u.id === id); + if (unit) unit.hunting = false; + } + state.hunters.clear(); + state.lastSeen = null; + return events; + } + + // --- The chase --- + // Anyone close enough joins, including units that arrive later. A chase that + // could only ever involve whoever was present at the start would be trivial + // to outrun by driving past fresh patrols. + for (const unit of step.units.units) { + if (unit.faction !== 'enemy' || unit.role === 'garrison') continue; + if (Math.hypot(unit.x - step.player.x, unit.z - step.player.z) > RECRUIT_RANGE) continue; + unit.hunting = true; + state.hunters.add(unit.id); + } + + // Drop anyone who died, left, or found a fight. Hunters are patrols, not + // fanatics: an insurgent squad shooting at them is a better use of their time. + let distracted = 0; + for (const id of [...state.hunters]) { + const unit = step.units.units.find((u) => u.id === id); + if (!unit) { + state.hunters.delete(id); + continue; + } + const busy = step.units.units.some( + (other) => + other.faction === 'insurgent' && + Math.hypot(other.x - unit.x, other.z - unit.z) < DISTRACTION_RANGE, + ); + if (busy) { + unit.hunting = false; + state.hunters.delete(id); + distracted++; + } + } + if (distracted > 0) events.push({ kind: 'distracted', remaining: state.hunters.size }); + + // --- Losing them --- + const stillSeen = seenBy.some((unit) => state.hunters.has(unit.id)); + if (stillSeen) { + state.unseenFor = 0; + state.lastSeen = { x: step.player.x, z: step.player.z }; + } else { + state.unseenFor += step.dt; + } + + // Either you broke line of sight for long enough, or there is nobody left + // chasing to break it from. + if (state.unseenFor >= LOSE_SECONDS || state.hunters.size === 0) { + for (const id of state.hunters) { + const unit = step.units.units.find((u) => u.id === id); + if (unit) unit.hunting = false; + } + state.hunters.clear(); + state.alert = 'suspicious'; + // Not back to nothing: they know there is a car worth looking for. + state.suspicion = AFTER_ESCAPE; + state.lastSeen = null; + state.unseenFor = 0; + events.push({ kind: 'lost' }); + } + + return events; +} + +/** Fraction of the way to being recognised, for the HUD meter. */ +export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED; diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 95456e0..1d17d2c 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -46,6 +46,7 @@ function run( heatLevel: () => 'turret', decayHeat: () => {}, decayArea: () => {}, + hunt: null, ...overrides, }, world.roads, diff --git a/src/sim/units.ts b/src/sim/units.ts index 08cae8b..ce859a2 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -57,6 +57,8 @@ export interface Unit { elevation: number; /** What an engineer was sent to put up. */ building?: BuildStage; + /** Set while this unit is chasing the player. Owned by sim/pursuit.ts. */ + hunting?: boolean; } /** @@ -438,6 +440,11 @@ export interface UnitStep { * than a district id so this file never has to know how areas are gridded. */ decayArea: (x: number, z: number, amount: number) => void; + /** + * Where hunting units should be heading: the player, or the last place they + * were seen. Null when nobody is chasing. + */ + hunt: { x: number; z: number } | null; } export interface UnitEvents { @@ -486,6 +493,26 @@ export function stepUnits( for (const unit of state.units) { unit.expires -= dt; + // Chasing overrides the job. A patrol that has recognised you is no longer + // working a road, and a hunt that politely waited its turn behind whatever + // the unit was already doing would not be a hunt. + if (unit.hunting && step.hunt) { + 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); + // 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; + } + // Being chased keeps a unit alive past its ordinary shift. + unit.expires = Math.max(unit.expires, 30); + continue; + } + switch (unit.role) { case 'traffic': { if (advance(unit, roads, dt)) { diff --git a/src/ui/hud.ts b/src/ui/hud.ts index f704f6a..f1de017 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -39,6 +39,14 @@ export interface HudModel { bearing: number; } | null; control: Control; + pursuit: { + /** 0..1 toward being recognised. */ + meter: number; + alert: 'clear' | 'suspicious' | 'hunted'; + hunters: number; + /** Seconds of staying out of sight before they give up. */ + losingIn: number; + }; /** Rounds in the air nearby. Not a health bar — a reason to keep moving. */ danger: number; completed: number; @@ -57,6 +65,24 @@ const arrowFor = (bearing: number): string => { return ARROWS[((sector % 8) + 8) % 8]!; }; +/** + * The one meter the player is meant to watch. Deliberately not a number: it is + * "how close am I to being made", and it wants reading at a glance while + * driving. + */ +function pursuitLines(pursuit: HudModel['pursuit']): string[] { + if (pursuit.alert === 'hunted') { + return [ + `HUNTED — ${pursuit.hunters} on you`, + pursuit.losingIn > 0 + ? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them` + : 'they can see you', + ]; + } + if (pursuit.meter <= 0.02) return ['cover intact']; + return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`]; +} + const CONTROL_WORDS: Record = { liberated: 'liberated — nobody is watching the roads here', contested: 'contested — the roads remember, slowly', @@ -105,6 +131,7 @@ export function createHud(seed: number) { ...(model.quest ? questLines(model.quest) : ['no mission — find a base']), '', CONTROL_WORDS[model.control], + ...pursuitLines(model.pursuit), ...(model.danger > 0 ? [`⚠ rounds in the air nearby (${model.danger})`] : []), '', // Debug only. The real game signals heat through the road itself —