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;
|
let provocation = 0;
|
||||||
/** Times the car has been written off underneath the player. */
|
/** Times the car has been written off underneath the player. */
|
||||||
let kia = 0;
|
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. */
|
/** Seconds left of watching the wreck, or 0 when alive. */
|
||||||
let dying = 0;
|
let dying = 0;
|
||||||
/** Where the camera has got to in its drift around it. */
|
/** 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
|
// 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.
|
// of sight and change direction" is advice the world does not honour.
|
||||||
blocked: insideBuilding,
|
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,
|
model.roads,
|
||||||
graph,
|
graph,
|
||||||
@ -859,6 +864,7 @@ async function boot() {
|
|||||||
condition = shooting.condition;
|
condition = shooting.condition;
|
||||||
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
||||||
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
||||||
|
lastGunfire = shooting.fired;
|
||||||
if (shooting.playerHit) {
|
if (shooting.playerHit) {
|
||||||
provocation += PROVOCATION.shotAt;
|
provocation += PROVOCATION.shotAt;
|
||||||
audio.hit();
|
audio.hit();
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
} from './units';
|
} from './units';
|
||||||
import { createCombat, segmentHits, stepCombat } from './combat';
|
import { createCombat, segmentHits, stepCombat } from './combat';
|
||||||
import { arrowFor } from '../ui/hud';
|
import { arrowFor } from '../ui/hud';
|
||||||
|
import { segmentAt } from './roads';
|
||||||
|
|
||||||
const world = generateWorld(1337);
|
const world = generateWorld(1337);
|
||||||
const graph = buildGraph(world.roads);
|
const graph = buildGraph(world.roads);
|
||||||
@ -945,3 +946,203 @@ describe('which way is right', () => {
|
|||||||
expect(laneRightOf(0).z).toBeCloseTo(0, 9);
|
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 { Rng } from '../core/rng';
|
||||||
import type { RoadNetwork, RoadSegment } from './roads';
|
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 { findRoute, travelTime, type Graph } from './routing';
|
||||||
import type { Control, Front } from './regions';
|
import type { Control, Front } from './regions';
|
||||||
import { controlAt, depthAt } from './regions';
|
import { controlAt, depthAt } from './regions';
|
||||||
@ -100,6 +100,14 @@ export interface UnitState {
|
|||||||
dispatched: Set<number>;
|
dispatched: Set<number>;
|
||||||
nextId: number;
|
nextId: number;
|
||||||
lastSkirmishAt: 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 => ({
|
export const createUnits = (): UnitState => ({
|
||||||
@ -108,6 +116,7 @@ export const createUnits = (): UnitState => ({
|
|||||||
dispatched: new Set(),
|
dispatched: new Set(),
|
||||||
nextId: 1,
|
nextId: 1,
|
||||||
lastSkirmishAt: -999,
|
lastSkirmishAt: -999,
|
||||||
|
alarm: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Tuning ---------------------------------------------------------------
|
// --- Tuning ---------------------------------------------------------------
|
||||||
@ -119,6 +128,25 @@ export const PEDESTRIAN_TARGET = 30;
|
|||||||
export const SIM_RADIUS = 640;
|
export const SIM_RADIUS = 640;
|
||||||
/** Civilians keep clear of the shooting. */
|
/** Civilians keep clear of the shooting. */
|
||||||
const CIVILIAN_FLEE_RANGE = 70;
|
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 };
|
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
|
* never reaches the road it was sent to, and the whole escalation chain is
|
||||||
* built on dispatched units actually arriving.
|
* 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 forwardX = Math.sin(unit.heading);
|
||||||
const forwardZ = Math.cos(unit.heading);
|
const forwardZ = Math.cos(unit.heading);
|
||||||
let nearest: number | null = null;
|
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) {
|
for (const other of state.units) {
|
||||||
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
||||||
if (Math.cos(other.heading - unit.heading) < 0) continue;
|
if (Math.cos(other.heading - unit.heading) < 0) continue;
|
||||||
const dx = other.x - unit.x;
|
const ahead = inTheWay(other.x, other.z);
|
||||||
const dz = other.z - unit.z;
|
if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
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.
|
* 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
|
* nose round rather than snapping it, and it lifts off for whatever is in
|
||||||
* front instead of driving through it.
|
* 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];
|
const next = unit.path[0];
|
||||||
if (next === undefined) return true;
|
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);
|
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
||||||
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
||||||
|
|
||||||
// Close on the car in front and lift off. Only civilians defer; anyone with
|
// Close on whatever is in front and lift off. Only civilians defer; anyone
|
||||||
// somewhere to be leans on the horn and keeps going.
|
// with somewhere to be leans on the horn and keeps going — and neither does
|
||||||
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
|
// 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 =
|
const allowed =
|
||||||
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
|
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);
|
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.
|
* were seen. Null when nobody is chasing.
|
||||||
*/
|
*/
|
||||||
hunt: { x: number; z: number } | null;
|
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
|
* Is this point inside a building? Hunters drive round them rather than
|
||||||
* through them, which is the entire reason turning a corner works.
|
* through them, which is the entire reason turning a corner works.
|
||||||
@ -943,7 +1006,7 @@ export function stepUnits(
|
|||||||
switch (unit.role) {
|
switch (unit.role) {
|
||||||
case 'traffic':
|
case 'traffic':
|
||||||
case 'convoy': {
|
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;
|
// Somewhere else to be. A convoy stays on ground its own side holds;
|
||||||
// a taxi does not care.
|
// a taxi does not care.
|
||||||
const pool =
|
const pool =
|
||||||
@ -966,7 +1029,26 @@ export function stepUnits(
|
|||||||
// A slow wander. People are not going anywhere in particular — but they
|
// 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
|
// do go *round* the buildings, and take the turn as their new heading
|
||||||
// so they walk along a wall rather than repeatedly into it.
|
// 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;
|
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);
|
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -979,7 +1061,7 @@ export function stepUnits(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (unit.path.length > 0) {
|
if (unit.path.length > 0) {
|
||||||
advance(unit, roads, state, dt);
|
advance(unit, roads, state, player, dt);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1114,13 +1196,33 @@ export function stepUnits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Civilians get out of the way of a firefight ---
|
// --- 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) {
|
for (const unit of state.units) {
|
||||||
if (unit.faction !== 'civilian') continue;
|
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,
|
(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;
|
if (!danger) continue;
|
||||||
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
|
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);
|
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user