diff --git a/src/main.ts b/src/main.ts index 72173ab..85d9c70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -39,6 +39,7 @@ import { createHeatProps } from './heatProps'; import { createUnits, dispatchTo, stepUnits } from './sim/units'; import { createCombat, dangerNear, stepCombat } from './sim/combat'; import { + closestAttention, createPursuit, PROVOCATION, recognitionMeter, @@ -953,7 +954,7 @@ async function boot() { 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.setTone(control, frameDt); 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)), objective: quest && quest.stage !== 'return' ? target : null, 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, { @@ -1011,6 +1022,15 @@ async function boot() { 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, + 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), completed: quests.completed, diff --git a/src/render/units.ts b/src/render/units.ts index 0650dfb..6e62ed2 100644 --- a/src/render/units.ts +++ b/src/render/units.ts @@ -53,6 +53,15 @@ function colourOf(unit: Unit): number { 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) { const scratch = new THREE.Matrix4(); const quaternion = new THREE.Quaternion(); @@ -114,6 +123,23 @@ export function createUnitView(scene: THREE.Scene) { 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. const towers = new THREE.InstancedMesh( new THREE.BoxGeometry(3, 6, 3), @@ -128,7 +154,7 @@ export function createUnitView(scene: THREE.Scene) { 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; scene.add(mesh); } @@ -139,7 +165,13 @@ export function createUnitView(scene: THREE.Scene) { }; 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, + ) { let carCount = 0; let personCount = 0; let crewCount = 0; @@ -200,6 +232,25 @@ export function createUnitView(scene: THREE.Scene) { 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(people, personCount); park(crew, crewCount); diff --git a/src/sim/pursuit.test.ts b/src/sim/pursuit.test.ts index fbfbeae..8fb93b5 100644 --- a/src/sim/pursuit.test.ts +++ b/src/sim/pursuit.test.ts @@ -276,3 +276,66 @@ describe('your own side pulling them off you', () => { 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'); + }); +}); diff --git a/src/sim/pursuit.ts b/src/sim/pursuit.ts index 1df3418..40a5660 100644 --- a/src/sim/pursuit.ts +++ b/src/sim/pursuit.ts @@ -25,6 +25,16 @@ export interface PursuitState { lastSeen: { x: number; z: number } | null; /** Unit ids currently chasing. */ hunters: Set; + /** + * 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; } export const createPursuit = (): PursuitState => ({ @@ -33,14 +43,32 @@ export const createPursuit = (): PursuitState => ({ unseenFor: 0, lastSeen: null, hunters: new Set(), + eyes: new Map(), }); // --- 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. */ +/** Suspicion per second from somebody who has settled on you, right on top of you. */ 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. * @@ -121,16 +149,31 @@ export function stepPursuit(state: PursuitState, step: PursuitStep): PursuitEven const exposure = EXPOSURE[step.control]; const seenBy = watchers(step); - // --- Suspicion --- - if (exposure > 0 && seenBy.length > 0) { + // --- Who is looking, and how settled they are about it --- + // Everyone who can see you creeps toward being sure; everyone who cannot + // loses interest, faster than they gained it. + const looking = new Set(); + 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 // 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; + attention = Math.max(attention, settled * proximity); + } + for (const [id, settled] of state.eyes) { + if (looking.has(id)) continue; + const faded = settled - FOCUS_FADE * 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') { 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. */ 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()); diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 9177670..87ab815 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -47,6 +47,12 @@ export interface HudModel { hunters: number; /** Seconds of staying out of sight before they give up. */ 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. */ danger: number; @@ -84,15 +90,30 @@ export const arrowFor = (bearing: number): string => { */ function pursuitLines(pursuit: HudModel['pursuit']): string[] { 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 [ - `HUNTED — ${pursuit.hunters} on you`, + `HUNTED — ${pursuit.hunters} on you ${from}`, 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' : ''}`]; + if (pursuit.watching === 0 && pursuit.meter <= 0.02) return ['cover intact']; + 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 = { diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts index 3fc7021..a66f3b9 100644 --- a/src/ui/minimap.ts +++ b/src/ui/minimap.ts @@ -49,6 +49,14 @@ export interface MinimapView { opportunity: { x: number; z: number } | null; /** Elapsed time, for ageing observations. */ 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) { @@ -135,6 +143,32 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu 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 --- if (view.opportunity) { ctx.beginPath();