Show who is looking at you, and give them time to decide

Two complaints with one cause: cover was too easy to lose, and losing
it came out of nowhere. Suspicion started climbing the instant anybody
had line of sight, so driving *past* a checkpoint cost exactly what
loitering at one did, and the only feedback was a bar filling up with
no indication of who was filling it.

Watchers now settle on you before they start filling anything. Passing
through quickly leaves everyone at a glance; lingering is what costs
you. Measured: attention 0.35 at one second, 0.70 at two, sure at
three, hunted at six - so there is a couple of seconds where somebody
has clocked you and there is still time to be somewhere else.

And that half is now visible. A diamond floats over anybody watching,
growing and reddening as they make their mind up, so the decision -
keep going or get out of sight - has a direction and a rate attached to
it out in the world rather than in the corner of the screen. The HUD
says how many are watching and how settled the keenest of them is.

Once it goes wrong, hunters are drawn on the minimap in solid red with
a line back to the car, and the HUD carries a compass arrow per hunter.
A count tells you to panic; a set of bearings tells you which way to
go, which is the actual decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 17:48:23 +02:00
parent 18477f0e38
commit 4bec70ba38
6 changed files with 251 additions and 15 deletions

View File

@ -39,6 +39,7 @@ import { createHeatProps } from './heatProps';
import { createUnits, dispatchTo, stepUnits } from './sim/units'; import { createUnits, dispatchTo, stepUnits } from './sim/units';
import { createCombat, dangerNear, stepCombat } from './sim/combat'; import { createCombat, dangerNear, stepCombat } from './sim/combat';
import { import {
closestAttention,
createPursuit, createPursuit,
PROVOCATION, PROVOCATION,
recognitionMeter, recognitionMeter,
@ -953,7 +954,7 @@ async function boot() {
mesh.quaternion.set(q.x, q.y, q.z, q.w); mesh.quaternion.set(q.x, q.y, q.z, q.w);
} }
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }); unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z }, pursuit.eyes);
view.followSun(); view.followSun();
view.setTone(control, frameDt); view.setTone(control, frameDt);
markers.update(elapsed); markers.update(elapsed);
@ -980,6 +981,16 @@ async function boot() {
heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)), heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
objective: quest && quest.stage !== 'return' ? target : null, objective: quest && quest.stage !== 'return' ? target : null,
opportunity: opportunities.current, opportunity: opportunities.current,
// Live positions, not remembered ones: these are people currently
// looking out of a window at you.
watchers: units.units
.filter((u) => pursuit.eyes.has(u.id) || u.hunting)
.map((u) => ({
x: u.x,
z: u.z,
settled: pursuit.eyes.get(u.id) ?? 1,
hunting: u.hunting === true,
})),
}); });
} }
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
@ -1011,6 +1022,15 @@ async function boot() {
hunters: pursuit.hunters.size, hunters: pursuit.hunters.size,
// Counts down only once nobody has eyes on you. // Counts down only once nobody has eyes on you.
losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0, losingIn: pursuit.alert === 'hunted' ? Math.max(0, LOSE_SECONDS - pursuit.unseenFor) : 0,
attention: closestAttention(pursuit),
watching: pursuit.eyes.size,
bearings: units.units
.filter((u) => pursuit.hunters.has(u.id))
.map(
(u) =>
Math.atan2(u.x - p.x, u.z - p.z) -
Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)),
),
}, },
danger: dangerNear(combat, p.x, p.z, 90), danger: dangerNear(combat, p.x, p.z, 90),
completed: quests.completed, completed: quests.completed,

View File

@ -53,6 +53,15 @@ function colourOf(unit: Unit): number {
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier; return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
} }
/**
* Colours for the marker over somebody who is looking at you: a glance is
* amber and barely there, certainty is red and unmistakable.
*/
const GLANCE = new THREE.Color(0xe0b040);
const CERTAIN = new THREE.Color(0xff3a2a);
/** How high above a unit its attention marker floats. */
const EYE_HEIGHT = 3.1;
export function createUnitView(scene: THREE.Scene) { export function createUnitView(scene: THREE.Scene) {
const scratch = new THREE.Matrix4(); const scratch = new THREE.Matrix4();
const quaternion = new THREE.Quaternion(); const quaternion = new THREE.Quaternion();
@ -114,6 +123,23 @@ export function createUnitView(scene: THREE.Scene) {
MAX_UNITS, MAX_UNITS,
); );
/**
* A diamond over anybody who has their eye on you, growing and reddening as
* they settle on it.
*
* Cover used to be a single bar in the corner that filled up with no
* indication of who was filling it, so being recognised arrived from nowhere.
* The decision the player is being asked to make keep going or get out of
* sight needs a *direction* and a rate, and both of those live out in the
* world rather than in the HUD.
*/
const eyes = new THREE.InstancedMesh(
new THREE.OctahedronGeometry(0.55),
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.9 }),
MAX_UNITS,
);
eyes.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
// Towers, which only exist once someone has built them. // Towers, which only exist once someone has built them.
const towers = new THREE.InstancedMesh( const towers = new THREE.InstancedMesh(
new THREE.BoxGeometry(3, 6, 3), new THREE.BoxGeometry(3, 6, 3),
@ -128,7 +154,7 @@ export function createUnitView(scene: THREE.Scene) {
32, 32,
); );
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) { for (const mesh of [cars, people, crew, guns, eyes, tracers, towers, towerGuns]) {
mesh.frustumCulled = false; mesh.frustumCulled = false;
scene.add(mesh); scene.add(mesh);
} }
@ -139,7 +165,13 @@ export function createUnitView(scene: THREE.Scene) {
}; };
return { return {
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) { update(
units: UnitState,
rounds: Round[],
elapsed: number,
player: { x: number; z: number },
watching: Map<number, number>,
) {
let carCount = 0; let carCount = 0;
let personCount = 0; let personCount = 0;
let crewCount = 0; let crewCount = 0;
@ -200,6 +232,25 @@ export function createUnitView(scene: THREE.Scene) {
crewCount++; crewCount++;
} }
// --- Whoever currently has eyes on you ---
let eyeCount = 0;
for (const unit of units.units) {
const settled = watching.get(unit.id);
if (settled === undefined || eyeCount >= MAX_UNITS) continue;
// Grows and reddens as they make their mind up, and bobs so it reads as
// a marker rather than as something built on the roof.
const size = 0.5 + settled * 0.85;
position.set(unit.x, EYE_HEIGHT + Math.sin(elapsed * 3 + unit.id) * 0.12, unit.z);
quaternion.setFromAxisAngle(up, elapsed * 1.6);
scratch.compose(position, quaternion, scale.set(size, size, size));
eyes.setMatrixAt(eyeCount, scratch);
eyes.setColorAt(eyeCount, colour.copy(GLANCE).lerp(CERTAIN, settled));
eyeCount++;
}
park(eyes, eyeCount);
eyes.instanceMatrix.needsUpdate = true;
if (eyes.instanceColor) eyes.instanceColor.needsUpdate = true;
park(cars, carCount); park(cars, carCount);
park(people, personCount); park(people, personCount);
park(crew, crewCount); park(crew, crewCount);

View File

@ -276,3 +276,66 @@ describe('your own side pulling them off you', () => {
expect(state.alert).toBe('hunted'); expect(state.alert).toBe('hunted');
}); });
}); });
describe('who is looking at you', () => {
it('names them, rather than only counting the meter', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 2);
expect([...state.eyes.keys()]).toEqual([watcher.id]);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0);
});
it('has them settle on you rather than deciding instantly', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 0.4);
const glance = state.eyes.get(watcher.id)!;
run(state, units, 3);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(glance);
expect(state.eyes.get(watcher.id)!).toBeCloseTo(1, 1);
});
it('loses interest once it cannot see you, quicker than it gained it', () => {
const units = createUnits();
const watcher = place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 4);
expect(state.eyes.get(watcher.id)!).toBeGreaterThan(0.5);
run(state, units, 2, { canSee: () => false });
expect(state.eyes.has(watcher.id)).toBe(false);
});
it('makes lingering cost more than passing', () => {
// The complaint this answers: cover was too easy to lose, because
// suspicion started climbing the instant anybody had line of sight. Driving
// past a checkpoint cost exactly what loitering at one did.
const passing = () => {
const units = createUnits();
place(units, 'enemy', 20, 0);
const state = createPursuit();
// Seen for a moment, then gone.
run(state, units, 1.2);
run(state, units, 4, { canSee: () => false });
return state.suspicion;
};
const lingering = () => {
const units = createUnits();
place(units, 'enemy', 20, 0);
const state = createPursuit();
run(state, units, 5.2);
return state.suspicion;
};
expect(lingering()).toBeGreaterThan(passing() * 3);
});
it('still gets you made if you sit there long enough', () => {
const units = createUnits();
place(units, 'enemy', 12, 0);
const state = createPursuit();
run(state, units, 20);
expect(state.alert).toBe('hunted');
});
});

View File

@ -25,6 +25,16 @@ export interface PursuitState {
lastSeen: { x: number; z: number } | null; lastSeen: { x: number; z: number } | null;
/** Unit ids currently chasing. */ /** Unit ids currently chasing. */
hunters: Set<number>; hunters: Set<number>;
/**
* Who has their eye on you, and how settled they are about it: 0 is a glance,
* 1 is somebody who has decided you are worth watching.
*
* This is the thing the player needed to be able to see. Cover used to be a
* single number that filled up with no indication of *who* was filling it, so
* being recognised arrived out of nowhere and there was nothing to react to
* except the meter itself.
*/
eyes: Map<number, number>;
} }
export const createPursuit = (): PursuitState => ({ export const createPursuit = (): PursuitState => ({
@ -33,14 +43,32 @@ export const createPursuit = (): PursuitState => ({
unseenFor: 0, unseenFor: 0,
lastSeen: null, lastSeen: null,
hunters: new Set(), hunters: new Set(),
eyes: new Map(),
}); });
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
/** How far an enemy can make you out at all. */ /** How far an enemy can make you out at all. */
export const SIGHT_RANGE = 95; export const SIGHT_RANGE = 95;
/** Suspicion per second with someone right on top of you. */ /** Suspicion per second from somebody who has settled on you, right on top of you. */
const MAX_GAIN = 0.42; const MAX_GAIN = 0.42;
/**
* How fast somebody goes from noticing a car to being sure about it, per second,
* with the car right beside them.
*
* This exists because cover was too easy to lose. Suspicion used to start
* climbing the instant anybody had line of sight, so driving *past* a checkpoint
* cost the same as loitering at one, and being made was something that happened
* to you rather than something you could feel coming and back out of.
*
* Now a watcher has to settle first, and only then do they start filling the
* meter. Passing through quickly leaves everyone at a glance; lingering is what
* actually costs you. It roughly doubles the time to be recognised, and more
* to the point makes the first half of that time legible.
*/
const FOCUS_RATE = 0.55;
/** How fast that interest fades once they cannot see you. Quicker than suspicion. */
const FOCUS_FADE = 0.7;
/** /**
* Suspicion shed per second with nobody watching. * Suspicion shed per second with nobody watching.
* *
@ -121,16 +149,31 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
const exposure = EXPOSURE[step.control]; const exposure = EXPOSURE[step.control];
const seenBy = watchers(step); const seenBy = watchers(step);
// --- Suspicion --- // --- Who is looking, and how settled they are about it ---
if (exposure > 0 && seenBy.length > 0) { // Everyone who can see you creeps toward being sure; everyone who cannot
// loses interest, faster than they gained it.
const looking = new Set<number>();
let attention = 0;
for (const unit of seenBy) {
looking.add(unit.id);
const gap = Math.hypot(unit.x - step.player.x, unit.z - step.player.z);
const proximity = (1 - gap / SIGHT_RANGE) ** 2;
const settled = Math.min(1, (state.eyes.get(unit.id) ?? 0) + FOCUS_RATE * proximity * step.dt);
state.eyes.set(unit.id, settled);
// Whoever has the best look at you sets the pace; a crowd is not more // 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. // suspicious than one person standing right next to the car.
let closest = SIGHT_RANGE; attention = Math.max(attention, settled * proximity);
for (const unit of seenBy) { }
closest = Math.min(closest, Math.hypot(unit.x - step.player.x, unit.z - step.player.z)); for (const [id, settled] of state.eyes) {
} if (looking.has(id)) continue;
const proximity = (1 - closest / SIGHT_RANGE) ** 2; const faded = settled - FOCUS_FADE * step.dt;
state.suspicion += proximity * MAX_GAIN * exposure * step.dt; if (faded <= 0) state.eyes.delete(id);
else state.eyes.set(id, faded);
}
// --- Suspicion ---
if (exposure > 0 && attention > 0) {
state.suspicion += attention * MAX_GAIN * exposure * step.dt;
} else if (state.alert !== 'hunted') { } else if (state.alert !== 'hunted') {
state.suspicion -= DECAY * step.dt; state.suspicion -= DECAY * step.dt;
} }
@ -223,3 +266,7 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven
/** Fraction of the way to being recognised, for the HUD meter. */ /** Fraction of the way to being recognised, for the HUD meter. */
export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED; export const recognitionMeter = (state: PursuitState): number => state.suspicion / RECOGNISED;
/** How settled the most interested onlooker is, 0..1. */
export const closestAttention = (state: PursuitState): number =>
state.eyes.size === 0 ? 0 : Math.max(...state.eyes.values());

View File

@ -47,6 +47,12 @@ export interface HudModel {
hunters: number; hunters: number;
/** Seconds of staying out of sight before they give up. */ /** Seconds of staying out of sight before they give up. */
losingIn: number; losingIn: number;
/** How settled the most interested onlooker is, 0..1. */
attention: number;
/** How many people currently have eyes on you. */
watching: number;
/** Bearings to each hunter, radians from the car's nose, positive to the left. */
bearings: number[];
}; };
/** Rounds in the air nearby. Not a health bar — a reason to keep moving. */ /** Rounds in the air nearby. Not a health bar — a reason to keep moving. */
danger: number; danger: number;
@ -84,15 +90,30 @@ export const arrowFor = (bearing: number): string => {
*/ */
function pursuitLines(pursuit: HudModel['pursuit']): string[] { function pursuitLines(pursuit: HudModel['pursuit']): string[] {
if (pursuit.alert === 'hunted') { if (pursuit.alert === 'hunted') {
// Where they are, not just how many. A count tells you to panic; a set of
// bearings tells you which way to go, which is the actual decision.
const from = pursuit.bearings.length
? pursuit.bearings.map(arrowFor).join(' ')
: '—';
return [ return [
`HUNTED — ${pursuit.hunters} on you`, `HUNTED — ${pursuit.hunters} on you ${from}`,
pursuit.losingIn > 0 pursuit.losingIn > 0
? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them` ? `break line of sight · ${pursuit.losingIn.toFixed(0)}s to lose them`
: 'they can see you', : 'they can see you',
]; ];
} }
if (pursuit.meter <= 0.02) return ['cover intact']; if (pursuit.watching === 0 && pursuit.meter <= 0.02) return ['cover intact'];
return [`noticed ${bar(pursuit.meter)}${pursuit.alert === 'suspicious' ? ' — being watched' : ''}`]; const lines: string[] = [];
if (pursuit.watching > 0) {
// The half of this the player could never see: somebody has clocked you and
// is making their mind up, and there is still time to be somewhere else.
lines.push(
`${pursuit.watching} watching ${bar(pursuit.attention)}` +
(pursuit.attention > 0.75 ? ' — they have you' : ''),
);
}
if (pursuit.meter > 0.02) lines.push(`noticed ${bar(pursuit.meter)}`);
return lines.length > 0 ? lines : ['cover intact'];
} }
const CONTROL_WORDS: Record<Control, string> = { const CONTROL_WORDS: Record<Control, string> = {

View File

@ -49,6 +49,14 @@ export interface MinimapView {
opportunity: { x: number; z: number } | null; opportunity: { x: number; z: number } | null;
/** Elapsed time, for ageing observations. */ /** Elapsed time, for ageing observations. */
now: number; now: number;
/**
* Anyone with their eye on you, and how settled they are about it.
*
* Drawn from live positions rather than from Intel, which is the one place
* this map is allowed to tell the truth: these are people you can see out of
* the window right now, not something you remember about a road.
*/
watchers: Array<{ x: number; z: number; settled: number; hunting: boolean }>;
} }
export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) { export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) {
@ -135,6 +143,32 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu
ctx.stroke(); ctx.stroke();
} }
// --- Who is looking at you, and who has stopped looking and started ---
for (const w of view.watchers) {
const wx = px(w.x);
const wz = px(w.z);
if (w.hunting) {
// A hunter is not a shade of anything. Solid, and bigger.
ctx.beginPath();
ctx.arc(wx, wz, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ff3a2a';
ctx.fill();
// A line back to the car, so a glance reads as a direction rather
// than as a dot you have to find yourself on the map.
ctx.beginPath();
ctx.moveTo(px(view.x), px(view.z));
ctx.lineTo(wx, wz);
ctx.strokeStyle = 'rgba(255,58,42,.35)';
ctx.lineWidth = 1;
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(wx, wz, 2 + w.settled * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(224,176,64,${(0.35 + w.settled * 0.65).toFixed(2)})`;
ctx.fill();
}
}
// --- A field contact: marked, but not the same as an assigned target --- // --- A field contact: marked, but not the same as an assigned target ---
if (view.opportunity) { if (view.opportunity) {
ctx.beginPath(); ctx.beginPath();