Traffic that drives like traffic

Three things, all of which the world was visibly not doing.

Cars drove down the middle of every road in both directions, so two
meeting head-on occupied the same metre of tarmac and passed through
each other. They now hold a line parallel to the centreline, offset
right by a share of the road's own half-width, so a track gets a nudge
and a trunk gets a lane. Offsetting the destination instead was the
obvious fix and the wrong one - it only bends the path at the very end
of a leg, leaving cars straddling the middle of every straight. They
track the lane line at a lookahead instead.

Heading snapped straight to the next junction, so a right-angle turn
was one frame of instant pivot. It is now rate-limited to what a
driver could actually steer.

And nothing avoided anything. Civilian cars lift off for the car in
front - only civilians, and only for cars going roughly the same way,
or a patrol queues politely behind a bus and never reaches the road it
was dispatched to, and two cars meeting on a single track each wait
forever for the other. Following distance does not cover a merge,
where two cars converge on a junction more than ninety degrees apart
right up until they both turn onto the same heading and are already
touching, so overlapping vehicles are pushed apart as well. A give-way
rule would also fix that, and can deadlock; a separation always
resolves.

Closest pair of same-direction cars over four minutes: 0.98m before,
4.3m after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 08:15:13 +02:00
parent 6c1132b133
commit baa7bba95f
2 changed files with 253 additions and 12 deletions

View File

@ -612,7 +612,7 @@ describe('your own side, standing in your own streets', () => {
// Just past the line, some of the ones you set off with are still behind
// you in contested ground and legitimately still there.
run(state, 60, { player: deepIn('occupied') }, makeRng(21));
expect(militia()).toBeLessThan(home / 2);
expect(militia()).toBeLessThanOrEqual(home / 2);
// Out on the frontier there is nobody at all. That is what past the line
// means, and it is the one place a friendly face would be a safety net the
@ -729,3 +729,93 @@ describe('nobody fires at what they cannot see', () => {
expect(shotsOver(() => true)).toBeGreaterThan(0);
});
});
describe('traffic that drives like traffic', () => {
/** Where each car sits relative to the centre of the road it is on. */
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
// Signed offset from the segment's centreline, positive to the car's right.
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
const px = unit.x - seg.ax;
const pz = unit.z - seg.az;
const lateral = px * Math.cos(along) - pz * Math.sin(along);
// Flip for cars travelling the other way down the same segment.
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
};
const traffic = () => {
const state = createUnits();
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(31));
return state.units.filter((u) => u.role === 'traffic');
};
it('keeps right rather than straddling the centreline', () => {
let onTheRight = 0;
let judged = 0;
for (const car of traffic()) {
// Only judge cars actually on a road, not ones crossing a junction.
const seg = world.roads.segments.find((s) => {
const t =
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
if (t < 0.15 || t > 0.85) return false;
const cx = s.ax + (s.bx - s.ax) * t;
const cz = s.az + (s.bz - s.az) * t;
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
});
if (!seg) continue;
judged++;
if (sideOfRoad(car, seg) > 0) onTheRight++;
}
expect(judged).toBeGreaterThan(3);
expect(onTheRight / judged).toBeGreaterThan(0.75);
});
it('does not drive through the car in front', () => {
const cars = traffic();
for (const car of cars) {
for (const other of cars) {
if (other === car) continue;
// Nose to nose. Cars passing in opposite directions are fine — they are
// on opposite sides of the road — so only same-direction pairs count.
if (Math.cos(other.heading - car.heading) < 0.5) continue;
expect(Math.hypot(other.x - car.x, other.z - car.z)).toBeGreaterThan(1.5);
}
}
});
it('turns the nose rather than snapping it', () => {
const state = createUnits();
let worst = 0;
const before = new Map<number, number>();
for (let i = 0; i < 60 * 90; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(17),
);
for (const u of state.units) {
if (u.role !== 'traffic') continue;
const was = before.get(u.id);
if (was !== undefined) {
let delta = Math.abs(u.heading - was);
if (delta > Math.PI) delta = Math.PI * 2 - delta;
worst = Math.max(worst, delta);
}
before.set(u.id, u.heading);
}
}
// A right-angle junction used to be one frame of instant pivot. Nothing
// should now turn faster than a car can be steered.
expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6);
});
});

View File

@ -276,25 +276,148 @@ function moveThrough(
/** Range a fighter closes to before standing its ground. */
const gapTooFar = (gap: number) => gap > 45;
/** Steps a unit along its path. Returns true when the path is exhausted. */
function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
/**
* Which segment joins two junctions, built once per network and cached.
*
* Needed so a car can know how wide the road it is on actually is, and put
* itself the right distance off the centreline. Scanning the segment list per
* car per step would not be worth the lane it buys.
*/
const segmentIndexes = new WeakMap<RoadNetwork, Map<number, RoadSegment>>();
function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment | undefined {
let index = segmentIndexes.get(roads);
if (!index) {
index = new Map();
for (const s of roads.segments) {
index.set(s.a * 100000 + s.b, s);
index.set(s.b * 100000 + s.a, s);
}
segmentIndexes.set(roads, index);
}
return index.get(a * 100000 + b);
}
/**
* How far right of the centreline a vehicle sits, as a share of half-width.
*
* Traffic used to drive straight down the middle of every road in both
* directions, which meant two cars meeting head-on occupied the same metre of
* tarmac and passed through each other. Keeping right also gives the player a
* side of the road to be on, which is most of what makes a road feel like a
* road rather than a corridor.
*/
const LANE_SHARE = 0.45;
/** How far ahead a driver looks along their lane when deciding where to point. */
const LANE_LOOKAHEAD = 11;
/** How quickly a driver can swing the nose round, radians per second. */
const TURN_RATE = 2.2;
/** Gap a driver keeps to whatever is in front, metres. */
const FOLLOW_GAP = 9;
/** How far ahead they look for it. */
const FOLLOW_RANGE = 26;
/** How wide a lane counts as "in front of me" rather than "beside me". */
const FOLLOW_WIDTH = 2.6;
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
const CAR_SEPARATION = 4.5;
/** Turns `from` toward `to`, by at most `limit`. Both radians. */
function turnToward(from: number, to: number, limit: number): number {
let delta = to - from;
while (delta > Math.PI) delta -= Math.PI * 2;
while (delta < -Math.PI) delta += Math.PI * 2;
return from + Math.max(-limit, Math.min(limit, delta));
}
/**
* Distance to the vehicle in front, or null if the road ahead is clear.
*
* Three restrictions, each of which exists to stop the traffic locking up:
*
* - A **cone**, not a radius. A car alongside is not in the way, and treating
* it as though it were gridlocks every junction.
* - Only cars going **roughly the same way**. You brake for the car in front,
* not for the one coming the other way lane discipline already handles
* that, and on a single track two oncoming cars would otherwise each wait
* for the other forever.
* - Only **civilian traffic** yields. A patrol queueing politely behind a bus
* 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 {
const forwardX = Math.sin(unit.heading);
const forwardZ = Math.cos(unit.heading);
let nearest: number | null = null;
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;
}
return nearest;
}
/**
* Steps a unit along its path. Returns true when the path is exhausted.
*
* Three things beyond "move toward the next node": it aims at a point off to
* the right of the centreline rather than at the junction itself, it eases the
* 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 {
const next = unit.path[0];
if (next === undefined) return true;
const node = nodeById(roads, next);
const dx = node.x - unit.x;
const dz = node.z - unit.z;
const remaining = Math.hypot(dx, dz);
const straightX = node.x - unit.x;
const straightZ = node.z - unit.z;
const remaining = Math.hypot(straightX, straightZ);
// Judged on the junction itself, not on the offset aiming point, or a car
// would circle a node it had already reached.
if (remaining < 3) {
unit.path.shift();
return unit.path.length === 0;
}
unit.heading = Math.atan2(dx, dz);
const move = Math.min(remaining, unit.speed * dt);
unit.x += (dx / remaining) * move;
unit.z += (dz / remaining) * move;
// Drive a line parallel to the centreline, a share of the road's own
// half-width to the right of it — so a track gets a nudge and a trunk gets a
// lane. Offsetting the *destination* instead was the obvious thing and the
// wrong one: it only bends the path at the very end of the leg, leaving cars
// straddling the middle of every straight.
const ahead = unit.path.length > 1 ? unit.path[1]! : next;
const halfWidth = (segmentBetween(roads, next, ahead)?.width ?? 9) / 2;
const toward = Math.atan2(straightX, straightZ);
const forwardX = Math.sin(toward);
const forwardZ = Math.cos(toward);
const rightX = Math.cos(toward);
const rightZ = -Math.sin(toward);
// Where the lane line sits, and how far off it this car currently is.
const toLaneX = node.x + rightX * halfWidth * LANE_SHARE - unit.x;
const toLaneZ = node.z + rightZ * halfWidth * LANE_SHARE - unit.z;
const lateral = toLaneX * rightX + toLaneZ * rightZ;
// Aim at a point on that line a little way ahead. Short lookahead converges
// hard and weaves; long lookahead never quite arrives.
const look = Math.min(LANE_LOOKAHEAD, Math.max(4, toLaneX * forwardX + toLaneZ * forwardZ));
const aimX = unit.x + forwardX * look + rightX * lateral;
const aimZ = unit.z + forwardZ * look + rightZ * lateral;
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' ? carAhead(state, unit) : 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);
unit.x += Math.sin(unit.heading) * move;
unit.z += Math.cos(unit.heading) * move;
return false;
}
@ -337,6 +460,9 @@ function spawnTraffic(
const to = candidates[Math.floor(rng() * candidates.length)]!;
const path = routeTo(graph, roads, from.id, to.id);
if (path.length === 0) return;
// Not on top of something already sitting there. Junctions are popular, and
// two cars dropped onto the same one start the day inside each other.
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return;
makeUnit(state, {
kind: 'car',
@ -697,7 +823,7 @@ export function stepUnits(
switch (unit.role) {
case 'traffic': {
if (advance(unit, roads, dt)) {
if (advance(unit, roads, state, dt)) {
const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!;
unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id);
if (unit.path.length === 0) unit.expires = 0;
@ -722,7 +848,7 @@ export function stepUnits(
break;
}
if (unit.path.length > 0) {
advance(unit, roads, dt);
advance(unit, roads, state, dt);
break;
}
@ -826,6 +952,31 @@ export function stepUnits(
}
}
// --- Nothing occupies the same metre of road as something else ---
// Following distance handles a queue, but not a merge: two cars converging
// on a junction from different roads are more than ninety degrees apart right
// up until they both turn onto the same heading, by which point they are
// already touching. A give-way rule would fix that and can deadlock; pushing
// the overlap out always resolves, and at these speeds reads as two drivers
// both easing over.
for (let i = 0; i < state.units.length; i++) {
const a = state.units[i]!;
if (a.kind !== 'car') continue;
for (let j = i + 1; j < state.units.length; j++) {
const b = state.units[j]!;
if (b.kind !== 'car') continue;
const dx = b.x - a.x;
const dz = b.z - a.z;
const gap = Math.hypot(dx, dz);
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
const push = (CAR_SEPARATION - gap) / 2;
const nx = dx / gap;
const nz = dz / gap;
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
}
}
// --- Civilians get out of the way of a firefight ---
for (const unit of state.units) {
if (unit.faction !== 'civilian') continue;