From d8a1f784ae5e4790391ecfab121e88ac39dc8074 Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 15:49:38 +0200 Subject: [PATCH] Civilians with some sense of self-preservation Three things people were not doing. They wandered into the road and ambled across four lanes at the pace they cross a courtyard. That quietly made running one over the world's fault rather than yours: if people walk into traffic by themselves, hitting one costs the player nothing. They now keep off the tarmac, and anyone who does need the other side commits and hurries - holding their heading, because dithering in the middle of a road is the one thing nobody does. Measured over four minutes: none of thirty standing in a road, and crossings covered well over half again the ground per second that strolling does. A small chance of committing is deliberate; pure avoidance turns every block into a sealed pen. They only fled the sight of somebody holding a rifle, not the sound of one going off. Gunfire now raises an alarm that outlasts the noise by six seconds, because a crowd that froze the instant the last round was fired reads as a switch rather than as fear. Running from it beats keeping off the road - that is the moment people stop being careful. And traffic drove straight into a parked car and shunted it down the street, which makes every other vehicle read as weather rather than as a driver. Civilians now yield to the player as well as to each other. Parked across a road for ninety seconds: closest approach 9.2m, shoved zero metres. Unless they have just heard shooting, in which case they do not wait behind you. Co-Authored-By: Claude Opus 5 --- src/main.ts | 6 ++ src/sim/units.test.ts | 201 ++++++++++++++++++++++++++++++++++++++++++ src/sim/units.ts | 132 +++++++++++++++++++++++---- 3 files changed, 324 insertions(+), 15 deletions(-) diff --git a/src/main.ts b/src/main.ts index 945eeda..72173ab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -254,6 +254,8 @@ async function boot() { let provocation = 0; /** Times the car has been written off underneath the player. */ let kia = 0; + /** Muzzle flashes from last step, so civilians can react to being shot near. */ + let lastGunfire: Array<{ x: number; z: number }> = []; /** Seconds left of watching the wreck, or 0 when alive. */ let dying = 0; /** Where the camera has got to in its drift around it. */ @@ -758,6 +760,9 @@ async function boot() { // 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, + // Shooting is resolved further down, so this is last step's. People + // hear it and then move, which is the right way round anyway. + gunfire: lastGunfire, }, model.roads, graph, @@ -859,6 +864,7 @@ async function boot() { condition = shooting.condition; const listener = { x: at.x, z: at.z, heading: headingOf() }; for (const muzzle of shooting.fired) audio.shot(muzzle, listener); + lastGunfire = shooting.fired; if (shooting.playerHit) { provocation += PROVOCATION.shotAt; audio.hit(); diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 40aef58..f005a89 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -21,6 +21,7 @@ import { } from './units'; import { createCombat, segmentHits, stepCombat } from './combat'; import { arrowFor } from '../ui/hud'; +import { segmentAt } from './roads'; const world = generateWorld(1337); const graph = buildGraph(world.roads); @@ -945,3 +946,203 @@ describe('which way is right', () => { expect(laneRightOf(0).z).toBeCloseTo(0, 9); }); }); + +describe('civilians have some sense', () => { + const onRoad = (u: { x: number; z: number }) => segmentAt(world.roads, u.x, u.z) !== null; + + const crowd = (overrides = {}) => { + const state = createUnits(); + run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z }, ...overrides }, makeRng(44)); + return state; + }; + + it('keeps people out of the road', () => { + const people = crowd().units.filter((u) => u.role === 'pedestrian'); + expect(people.length).toBeGreaterThan(5); + // Not zero: somebody is always mid-crossing. But it has to be the exception. + expect(people.filter(onRoad).length / people.length).toBeLessThan(0.15); + }); + + it('makes the ones who are crossing hurry', () => { + // Sampled over time, since at any instant only a couple are on tarmac. + const state = createUnits(); + let crossingSteps = 0; + let crossingDistance = 0; + let strollingSteps = 0; + let strollingDistance = 0; + const before = new Map(); + for (let i = 0; i < 2400; i++) { + stepUnits( + state, + { + dt: 0.1, + now: i * 0.1, + player: { x: world.spawn.x, z: world.spawn.z }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + }, + world.roads, + graph, + makeRng(44), + ); + for (const u of state.units) { + if (u.role !== 'pedestrian') continue; + const was = before.get(u.id); + if (was) { + const moved = Math.hypot(u.x - was.x, u.z - was.z); + if (onRoad(u)) { + crossingSteps++; + crossingDistance += moved; + } else { + strollingSteps++; + strollingDistance += moved; + } + } + before.set(u.id, { x: u.x, z: u.z }); + } + } + expect(crossingSteps).toBeGreaterThan(30); + // Nobody strolls across a road. + expect(crossingDistance / crossingSteps).toBeGreaterThan( + (strollingDistance / strollingSteps) * 1.5, + ); + }); + + it('runs from gunfire, not only from the sight of a rifle', () => { + // Run the same four seconds twice, identical but for one shot fired on the + // first step with nobody visibly armed anywhere. Everything else — the + // wander, the seed, the road avoidance — is held constant, so the + // difference is the gunfire and nothing else. + const walk = (withShot: boolean) => { + const state = createUnits(); + run(state, 60, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(44)); + const person = state.units.find((u) => u.role === 'pedestrian')!; + const shot = { x: person.x + 6, z: person.z }; + for (let i = 0; i < 40; i++) { + stepUnits( + state, + { + dt: 0.1, + now: 60 + i * 0.1, + player: { x: world.spawn.x, z: world.spawn.z }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + // Fired once, on the first step only. + gunfire: withShot && i === 0 ? [shot] : [], + }, + world.roads, + graph, + makeRng(44), + ); + } + return Math.hypot(person.x - shot.x, person.z - shot.z); + }; + + // Four seconds, against a six-second panic: they are still running when the + // measurement is taken, rather than having frozen the instant it went quiet. + expect(walk(true)).toBeGreaterThan(walk(false) + 3); + }); +}); + +describe('traffic and the player', () => { + /** + * One civilian car partway down a long straight, with the player parked + * twelve metres ahead of it on the same road. + * + * Built rather than found: taking whichever car happened to exist put the + * player on its instantaneous heading, which is not the same as its lane, so + * the car would reach a junction and turn off before anything was decided. + */ + const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b)); + const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az); + + const approaching = () => { + const state = createUnits(); + const at = 0.25; + const car = { + id: state.nextId++, + kind: 'car' as const, + faction: 'civilian' as const, + role: 'traffic' as const, + x: straight.ax + (straight.bx - straight.ax) * at, + z: straight.az + (straight.bz - straight.az) * at, + heading: along, + speed: 12, + hp: 60, + path: [straight.b], + lastNode: straight.a, + expires: 1e6, + assigned: null, + onStation: 0, + cooldown: 1, + elevation: 1, + }; + state.units.push(car); + return { + state, + car, + player: { x: car.x + Math.sin(along) * 12, z: car.z + Math.cos(along) * 12 }, + }; + }; + + const drive = ( + state: ReturnType, + car: { x: number; z: number; heading: number }, + player: { x: number; z: number }, + gunfire = false, + ) => { + // Fired from *behind* the car, so panic pushes it on toward the player + // rather than simply away from the noise — otherwise the test only proves + // that people run from gunfire, which is a different test. + const shot = { x: car.x - Math.sin(along) * 10, z: car.z - Math.cos(along) * 10 }; + // The closest it ever gets. End-distance is useless here: a car that does + // not stop drives straight past and ends up further away than one that did. + let closest = Infinity; + for (let i = 0; i < 40; i++) { + stepUnits( + state, + { + dt: 0.1, + now: 60 + i * 0.1, + player, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + gunfire: gunfire && i === 0 ? [shot] : [], + }, + world.roads, + graph, + makeRng(2), + ); + closest = Math.min(closest, Math.hypot(car.x - player.x, car.z - player.z)); + } + return closest; + }; + + it('stops rather than shunting a parked car down the road', () => { + const { state, car, player } = approaching(); + // Never gets within ramming distance of a car sitting in its way. Traffic + // used to drive straight into a stationary player and shove them along, + // which makes every other vehicle read as weather rather than as a driver. + expect(drive(state, car, player)).toBeGreaterThan(3); + }); + + it('does not stop for you when it is running from gunfire', () => { + const calm = approaching(); + const kept = drive(calm.state, calm.car, calm.player); + + const panicked = approaching(); + const closed = drive(panicked.state, panicked.car, panicked.player, true); + + // A driver getting out of a firefight is not going to wait behind you. + expect(closed).toBeLessThan(kept); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index c2a1f81..510a91b 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -11,7 +11,7 @@ */ import type { Rng } from '../core/rng'; import type { RoadNetwork, RoadSegment } from './roads'; -import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads'; +import { ROAD_SPEED, pointOnSegment, projectOntoSegment, segmentAt } from './roads'; import { findRoute, travelTime, type Graph } from './routing'; import type { Control, Front } from './regions'; import { controlAt, depthAt } from './regions'; @@ -100,6 +100,14 @@ export interface UnitState { dispatched: Set; nextId: number; lastSkirmishAt: number; + /** + * Where shooting was last heard, and how long people keep running from it. + * + * Held here rather than read per frame because panic outlasts the noise: a + * crowd that stopped dead the instant the last round was fired would read as + * a switch being flipped rather than as people being frightened. + */ + alarm: { x: number; z: number; until: number } | null; } export const createUnits = (): UnitState => ({ @@ -108,6 +116,7 @@ export const createUnits = (): UnitState => ({ dispatched: new Set(), nextId: 1, lastSkirmishAt: -999, + alarm: null, }); // --- Tuning --------------------------------------------------------------- @@ -119,6 +128,25 @@ export const PEDESTRIAN_TARGET = 30; export const SIM_RADIUS = 640; /** Civilians keep clear of the shooting. */ const CIVILIAN_FLEE_RANGE = 70; +/** + * How much quicker somebody moves while they are in the road. + * + * Pedestrians used to amble across four lanes of traffic at the same pace they + * wander a courtyard, which reads as a world where nobody minds being run over. + * Nobody strolls across a road: you either stay off it or you get over it. + */ +const CROSSING_HASTE = 2.3; +/** How far ahead somebody looks before stepping into the road. */ +const KERB_LOOKAHEAD = 4; +/** + * Chance per second that somebody actually needs the other side. + * + * Without this they never cross at all and each block becomes a sealed pen, + * which looks even more wrong than wandering into traffic did. + */ +const CROSSING_CHANCE = 0.12; +/** Seconds people keep running after the shooting stops. */ +const PANIC_SECONDS = 6; const SPEED: Record = { car: 14, soldier: 2.4 }; /** @@ -367,23 +395,41 @@ function turnToward(from: number, to: number, limit: number): number { * never reaches the road it was sent to, and the whole escalation chain is * built on dispatched units actually arriving. */ -function carAhead(state: UnitState, unit: Unit): number | null { +function carAhead(state: UnitState, unit: Unit, player: { x: number; z: number }): number | null { const forwardX = Math.sin(unit.heading); const forwardZ = Math.cos(unit.heading); let nearest: number | null = null; + + /** Is this thing in my way, and how far off is it? */ + const inTheWay = (x: number, z: number): number | null => { + const dx = x - unit.x; + const dz = z - unit.z; + const ahead = dx * forwardX + dz * forwardZ; + if (ahead <= 0 || ahead > FOLLOW_RANGE) return null; + if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) return null; + return ahead; + }; + for (const other of state.units) { if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue; if (Math.cos(other.heading - unit.heading) < 0) continue; - const dx = other.x - unit.x; - const dz = other.z - unit.z; - const ahead = dx * forwardX + dz * forwardZ; - if (ahead <= 0 || ahead > FOLLOW_RANGE) continue; - if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) continue; - if (nearest === null || ahead < nearest) nearest = ahead; + const ahead = inTheWay(other.x, other.z); + if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead; } + + // And the player, whichever way they happen to be pointing. Traffic used to + // drive straight into a stationary car and shunt it down the road, which + // makes every other vehicle feel like weather rather than like a driver. + const atPlayer = inTheWay(player.x, player.z); + if (atPlayer !== null && (nearest === null || atPlayer < nearest)) nearest = atPlayer; + return nearest; } +/** Is this civilian close enough to the shooting to have stopped being careful? */ +const panicking = (state: UnitState, unit: Unit): boolean => + state.alarm !== null && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE; + /** * Steps a unit along its path. Returns true when the path is exhausted. * @@ -392,7 +438,13 @@ function carAhead(state: UnitState, unit: Unit): number | null { * nose round rather than snapping it, and it lifts off for whatever is in * front instead of driving through it. */ -function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean { +function advance( + unit: Unit, + roads: RoadNetwork, + state: UnitState, + player: { x: number; z: number }, + dt: number, +): boolean { const next = unit.path[0]; if (next === undefined) return true; @@ -470,9 +522,13 @@ function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): const desired = Math.atan2(aimX - unit.x, aimZ - unit.z); unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt); - // Close on the car in front and lift off. Only civilians defer; anyone with - // somewhere to be leans on the horn and keeps going. - const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null; + // Close on whatever is in front and lift off. Only civilians defer; anyone + // with somewhere to be leans on the horn and keeps going — and neither does + // anybody who has just heard shooting, because a driver getting out of a + // firefight is not going to wait behind you. + const yields = + (unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit); + const gap = yields ? carAhead(state, unit, player) : null; const allowed = gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed); const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt); @@ -848,6 +904,13 @@ export interface UnitStep { * were seen. Null when nobody is chasing. */ hunt: { x: number; z: number } | null; + /** + * Muzzle positions from the last step, so civilians run from gunfire itself + * rather than only from the sight of somebody holding a rifle. Shooting is + * resolved after this runs, so these are one step old — which is fine, and + * arguably right: you hear it, then you move. + */ + gunfire?: Array<{ x: number; z: number }>; /** * Is this point inside a building? Hunters drive round them rather than * through them, which is the entire reason turning a corner works. @@ -943,7 +1006,7 @@ export function stepUnits( switch (unit.role) { case 'traffic': case 'convoy': { - if (advance(unit, roads, state, dt)) { + if (advance(unit, roads, state, player, dt)) { // Somewhere else to be. A convoy stays on ground its own side holds; // a taxi does not care. const pool = @@ -966,7 +1029,26 @@ export function stepUnits( // A slow wander. People are not going anywhere in particular — but they // do go *round* the buildings, and take the turn as their new heading // so they walk along a wall rather than repeatedly into it. + // + // And they keep off the road. Standing in traffic used to cost a + // pedestrian nothing, which quietly made running one over cost the + // player nothing either: if people wander into the road by themselves, + // hitting one is the world's fault rather than yours. + if (segmentAt(roads, unit.x, unit.z)) { + // Already out there. Hold the heading — dithering in the middle of a + // road is the one thing nobody does — and get across at a run. + unit.heading = moveThrough(unit, unit.heading, unit.speed * CROSSING_HASTE * dt, step.blocked); + break; + } + if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5; + + // At the kerb: step off it, unless this is somebody who actually wants + // the other side, in which case they commit and hurry. + const kerbX = unit.x + Math.sin(unit.heading) * KERB_LOOKAHEAD; + const kerbZ = unit.z + Math.cos(unit.heading) * KERB_LOOKAHEAD; + const crossing = segmentAt(roads, kerbX, kerbZ) !== null && rng() > dt * CROSSING_CHANCE; + if (crossing) unit.heading += Math.PI * (0.5 + rng() * 0.5) * (rng() < 0.5 ? 1 : -1); unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked); break; } @@ -979,7 +1061,7 @@ export function stepUnits( break; } if (unit.path.length > 0) { - advance(unit, roads, state, dt); + advance(unit, roads, state, player, dt); break; } @@ -1114,13 +1196,33 @@ export function stepUnits( } // --- Civilians get out of the way of a firefight --- + // Anything fired near enough to be heard resets the clock, and people keep + // running for a few seconds after it stops. A crowd that froze the instant + // the last round went off would read as a switch, not as fear. + for (const muzzle of step.gunfire ?? []) { + if (distance(muzzle, player) > SIM_RADIUS) continue; + state.alarm = { x: muzzle.x, z: muzzle.z, until: step.now + PANIC_SECONDS }; + } + if (state.alarm && step.now > state.alarm.until) state.alarm = null; + for (const unit of state.units) { if (unit.faction !== 'civilian') continue; - const danger = state.units.find( + // Either somebody visibly holding a rifle, or the sound of one going off. + const armed = state.units.find( (other) => other.role === 'fighter' && distance(other, unit) < CIVILIAN_FLEE_RANGE, ); + const heard = + state.alarm && distance(state.alarm, unit) < CIVILIAN_FLEE_RANGE ? state.alarm : null; + const danger = + armed && heard + ? distance(armed, unit) < distance(heard, unit) + ? armed + : heard + : (armed ?? heard); if (!danger) continue; const away = Math.atan2(unit.x - danger.x, unit.z - danger.z); + // Running from gunfire beats keeping off the road: the point of the road + // rule is that people are careful, and this is the moment they are not. unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked); }