Phase 8: recognition, pursuit, and losing them

Cover is now a state you can lose. Suspicion builds while enemy eyes are on you,
squared with proximity so one patrol beside the car outweighs a crowd across the
street, and blocked by buildings like everything else. It cannot build at all
behind your own lines. Ramming, being shot at and running somebody over skip the
meter and land in full.

At the top of it you are hunted: nearby enemies drop what they were doing and
come, and keep joining as you drive past fresh patrols, since a chase you could
outrun by passing more of them would be no chase. Being hunted is also now the
only reason the enemy shoots at you — a target is a state, not a property of the
road you happen to be on.

Two ways out. Break line of sight for fourteen seconds, with any glimpse
resetting the clock; hunters drive to where they last saw you rather than where
you are, so turning a corner and changing direction is the move. Or let your own
side occupy them — hunters are patrols, not fanatics, and enough peeling off
ends the chase outright. Escaping leaves suspicion at 0.6 rather than zero: they
know there is a car worth looking for.

Tuning a test caught: the post-escape value sat a hair above the "suspicious"
threshold, so the afterglow expired within a second and the rule may as well not
have existed. Decay slowed to match, so cover is earned back by being dull
rather than by turning one corner.

Verified end to end in the running game: loitering beside a patrol in occupied
ground filled the meter and pulled three hunters, fleeing out of sight cleared it
after exactly fourteen seconds, and a visible player was closed on from 61m to
12m while taking hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-08 12:20:47 +02:00
parent 5e557dc93a
commit d10033d79e
7 changed files with 662 additions and 9 deletions

View File

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

View File

@ -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<number, ReturnType<typeof physics.addStaticBox>>();
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,

278
src/sim/pursuit.test.ts Normal file
View File

@ -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<PursuitStep> = {},
) {
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');
});
});

225
src/sim/pursuit.ts Normal file
View File

@ -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<number>;
}
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<Control, number> = {
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;

View File

@ -46,6 +46,7 @@ function run(
heatLevel: () => 'turret',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
...overrides,
},
world.roads,

View File

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

View File

@ -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<Control, string> = {
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 —