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:
parent
2ffef66ef8
commit
d8a1f784ae
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
132
src/sim/units.ts
132
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<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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user