diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index f005a89..7139a68 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -1146,3 +1146,94 @@ describe('traffic and the player', () => { expect(closed).toBeLessThan(kept); }); }); + +describe('a car knocked off its lane', () => { + /** + * The failure this pins is not subtle to watch and is very easy to reintroduce: + * traffic driving tight circles forever, one car after another, because the + * aim point it is steering at cannot be reached. + */ + 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 displaced = (metresOff: number) => { + const state = createUnits(); + // Right of the direction of travel, matching sim/units.ts. + const offX = -Math.cos(along); + const offZ = Math.sin(along); + const car = { + id: state.nextId++, + kind: 'car' as const, + faction: 'civilian' as const, + role: 'traffic' as const, + x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff, + z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff, + 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); + + let turned = 0; + let previousHeading = car.heading; + // Closest it ever gets to its lane while still driving that leg. Measuring + // at the end is useless: it reaches the junction, picks a new destination, + // and the original lane stops meaning anything. + let closestToLane = Infinity; + for (let i = 0; i < 300; i++) { + stepUnits( + state, + { + dt: 0.1, + now: i * 0.1, + // On the road, not far away: units well outside SIM_RADIUS of the + // player are culled, and a culled car proves nothing. + player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + }, + world.roads, + graph, + makeRng(6), + ); + let delta = car.heading - previousHeading; + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + turned += Math.abs(delta); + previousHeading = car.heading; + if (car.path[0] === straight.b) { + closestToLane = Math.min( + closestToLane, + Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)), + ); + } + } + + return { turns: turned / (Math.PI * 2), lateral: closestToLane, car }; + }; + + it('rejoins the road instead of orbiting it', () => { + // Thirty metres wide of its own road: far enough that the aim point used to + // be unreachable, so the car circled indefinitely and drifted further out + // every lap rather than coming back. + const run = displaced(30); + expect(run.turns).toBeLessThan(1); + // Actually got back to the road, rather than running parallel to it. + expect(run.lateral).toBeLessThan(6); + }); + + it('holds its lane when it is already on it', () => { + const run = displaced(0); + expect(run.turns).toBeLessThan(0.2); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index 510a91b..190c061 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -512,10 +512,34 @@ function advance( return unit.path.length === 0; } - // Pure pursuit: aim at the point on the lane line a fixed distance ahead of - // wherever the car currently projects onto it. Short lookahead weaves, long - // lookahead never quite arrives. - const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD; + /* + * Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD` + * metres *from the car*. + * + * The distance being measured from the car rather than along the lane is the + * entire trick, and getting it wrong is what had traffic doing twenty laps of + * a junction to net eight metres, one car after another. + * + * Aim a fixed distance *along the lane* from where the car projects onto it + * and the geometry breaks down twice over. Close in, the aim point falls + * inside the tightest circle the car can drive — about six metres at + * `TURN_RATE` — so it cannot reach it, swings past and comes round again. + * Far out, the fix of growing the lookahead makes it worse: the aim point + * runs away down the lane until the direction to it is almost parallel with + * the lane itself, the steering correction vanishes, and the car drifts + * further off every step. Measured drifting from 28 to 42 metres wide of its + * own road while dutifully pointing along it. + * + * Holding the aim at a fixed radius from the car fixes both ends at once. On + * the line, it sits `LANE_LOOKAHEAD` ahead and the car tracks straight. Off + * the line by less than that, the point slides back along the lane and the + * car cuts in at a sane angle. Off by more than that, there is no such point + * at all, so it aims at the nearest bit of lane there is and drives at it. + */ + const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ; + const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ; + const reach = Math.sqrt(Math.max(0, LANE_LOOKAHEAD * LANE_LOOKAHEAD - lateral * lateral)); + const along = projected + reach; const aimX = laneX + dirX * along; const aimZ = laneZ + dirZ * along;