diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 5bd9674..29c3088 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index ae47ad9..b3a4868 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -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>(); +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;