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 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 15:49:38 +02:00
parent 2ffef66ef8
commit d8a1f784ae
3 changed files with 324 additions and 15 deletions

View File

@ -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();

View File

@ -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<number, { x: number; z: number }>();
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<typeof createUnits>,
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);
});
});

View File

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