Fix the car getting stuck on the Chenaran highway

Traffic cars picked a random direction even on one-way carriageways,
so some drove head-on in the player's lane and stopped dead in front of
them; every bump also threw the player's car backwards while the
traffic car stayed put, so the player could not get past.

- Map data records one-way roads (oneway tags, motorways, roundabouts)
- Traffic drives with the flow in one of two lanes on one-way roads and
  on the right-hand half of two-way roads, spawning only from nearby roads
- Bumps slow the player down instead of reversing them; a traffic car
  pressed against for more than 0.8 s pulls away out of sight
- Regression test for traffic direction and lanes; ?debug collision log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 09:29:58 +03:30
co-authored by Claude Opus 5.5
parent 0939dac9cd
commit 2f74788caa
5 changed files with 92 additions and 16 deletions
+12 -3
View File
@@ -65,7 +65,7 @@ const nameId = name => {
} }
return nameIndex.get(name); return nameIndex.get(name);
}; };
const roads = [], buildings = [], areas = [], labels = []; const roads = [], buildings = [], areas = [], labels = [], onewayRoads = [];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland']; const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park' const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
: t.landuse === 'grass' ? 'grass' : t.landuse === 'grass' ? 'grass'
@@ -104,13 +104,20 @@ for (const e of city) {
// Motorways are drawn and routed like trunk roads. // Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk'); const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) { if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// One-way roads are stored pointing in their direction of travel; dual carriageways
// (each side its own way) are one-way, as are motorways and roundabouts by default.
const oneway = ['yes', 'true', '1', '-1'].includes(t.oneway) || (t.oneway !== 'no' && (t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
// Whole ways come back from Overpass, so cut them where they leave the region. // Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon; const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = []; let run = [];
for (const point of [...e.geometry, null]) { for (const point of [...geometry, null]) {
if (point && inRegion(point)) run.push(point); if (point && inRegion(point)) run.push(point);
else { else {
if (run.length > 1) roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]); if (run.length > 1) {
if (oneway) onewayRoads.push(roads.length);
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
}
run = []; run = [];
} }
} }
@@ -162,6 +169,8 @@ const city_ = {
attribution: '© OpenStreetMap contributors', attribution: '© OpenStreetMap contributors',
bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z}, bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z},
roadTypes, areaKinds, names, roads, buildings, areas, roadTypes, areaKinds, names, roads, buildings, areas,
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
oneway: onewayRoads,
labels: [...new Map(labels.map(l => [l.name, l])).values()] labels: [...new Map(labels.map(l => [l.name, l])).values()]
}; };
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_)); writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
+1 -1
View File
File diff suppressed because one or more lines are too long
+28 -6
View File
@@ -966,7 +966,11 @@ function updateWalk(dt) {
player.gait += dt * player.speed * 1.9; player.gait += dt * player.speed * 1.9;
} }
// Collision log for ?debug runs; trimmed so it never grows without bound.
const debugHits = [];
function updateDrive(dt) { function updateDrive(dt) {
if (debugHits.length > 200) debugHits.splice(0, 100);
const throttle = THREE.MathUtils.clamp(axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, -1, 1); const throttle = THREE.MathUtils.clamp(axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, -1, 1);
const steer = THREE.MathUtils.clamp(axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, -1, 1); const steer = THREE.MathUtils.clamp(axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, -1, 1);
stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt); stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt);
@@ -975,19 +979,31 @@ function updateDrive(dt) {
const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along; const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along;
const p = resolveCircle(collision, cx, cz, 1.05); const p = resolveCircle(collision, cx, cz, 1.05);
if (p.hit) { if (p.hit) {
debugHits.push({kind: 'building', x: Math.round(cx), z: Math.round(cz), v: +car.speed.toFixed(1)});
car.x += p.x - cx; car.x += p.x - cx;
car.z += p.z - cz; car.z += p.z - cz;
if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square'); if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square');
car.speed *= .35; car.speed *= .35;
} }
} }
// Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
// against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
for (const npc of traffic.cars) { for (const npc of traffic.cars) {
const dx = car.x - npc.x, dz = car.z - npc.z, d = Math.hypot(dx, dz); const dx = car.x - npc.x, dz = car.z - npc.z, d = Math.hypot(dx, dz);
if (d < 3.6 && d > 0) { if (d >= 3.4 || d === 0) {
car.x = npc.x + dx / d * 3.6; if (npc.contact) npc.contact = 0;
car.z = npc.z + dz / d * 3.6; continue;
if (Math.abs(car.speed) > 4) tone(110, .25, .08, 'square'); }
car.speed *= -.2; debugHits.push({kind: 'traffic', x: Math.round(car.x), z: Math.round(car.z), v: +car.speed.toFixed(1), npcDir: npc.dir});
car.x = npc.x + dx / d * 3.4;
car.z = npc.z + dz / d * 3.4;
const towards = (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0;
if (towards && Math.abs(car.speed) > 4) tone(110, .25, .08, 'square');
if (towards) car.speed *= .4;
npc.contact = (npc.contact ?? 0) + dt;
if (npc.contact > .8) {
npc.contact = 0;
traffic.remove(npc);
} }
} }
const {minX, minZ, maxX, maxZ} = data.bounds; const {minX, minZ, maxX, maxZ} = data.bounds;
@@ -1235,7 +1251,13 @@ if (new URLSearchParams(location.search).has('debug')) window.game = {
return payment; return payment;
}, get elapsed() { }, get elapsed() {
return elapsed; return elapsed;
}, stations, guideState, real, keys }, get route() {
return route;
}, setWaypoint(id) {
waypoint = id;
route = null;
routeTimer = 0;
}, debugHits, nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, s => s[4] <= 3), stations, guideState, real, keys, traffic
}; };
reset(); reset();
resize(); resize();
+36 -6
View File
@@ -45,13 +45,19 @@ const COLORS = ['#e9e9e4', '#1d1f22', '#9aa3a8', '#d6c9a5', '#7e1f1d', '#2f4a6b'
// Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that // Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane. // falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
export function createTraffic(scene, data, count = 28) { export function createTraffic(scene, data, count = 28) {
const roads = data.roads.filter(r => r[0] <= 3).map(r => { const oneway = new Set(data.oneway ?? []);
const roads = data.roads.flatMap((r, index) => {
if (r[0] > 3) return [];
const pts = []; const pts = [];
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1])); for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
const lengths = [0]; const lengths = [0];
for (let i = 1; i < pts.length; i++) lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1])); for (let i = 1; i < pts.length; i++) lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
return {pts, lengths, total: lengths.at(-1), lanes: r[0] <= 1 ? 2 : 1}; const xs = pts.map(p => p.x), zs = pts.map(p => p.y);
return [{pts, lengths, total: lengths.at(-1), width: WIDTH[r[0]], oneway: oneway.has(index),
box: {x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs)}}];
}).filter(r => r.total > 80); }).filter(r => r.total > 80);
const sample = (road, d) => { const sample = (road, d) => {
let i = 1; let i = 1;
@@ -61,13 +67,25 @@ export function createTraffic(scene, data, count = 28) {
}; };
let seed = 7; let seed = 7;
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296; const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
let nearby = [], nearbyAt = null;
const roadsNear = (x, z) => {
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
nearbyAt = {x, z};
nearby = roads.filter(({box}) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600);
}
return nearby;
};
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists. // Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
function respawn(car, x, z) { function respawn(car, x, z) {
for (let attempt = 0; attempt < 40; attempt++) { const candidates = roadsNear(x, z);
const road = roads[Math.floor(random() * roads.length)], d = random() * road.total, p = sample(road, d); for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
const road = candidates[Math.floor(random() * candidates.length)], d = random() * road.total, p = sample(road, d);
const dist = Math.hypot(p.x - x, p.z - z); const dist = Math.hypot(p.x - x, p.z - z);
if (dist < 120 || dist > 600) continue; if (dist < 120 || dist > 600) continue;
Object.assign(car, {road, d, dir: random() < .5 ? 1 : -1, lane: 2.2 + (road.lanes > 1 && random() < .5 ? 3.2 : 0), speed: 9 + random() * 7}); // One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
const lane = road.oneway ? (random() < .5 ? -1 : 1) * road.width / 4 : road.width / 4;
Object.assign(car, {road, d, dir: road.oneway || random() < .5 ? 1 : -1, lane, speed: 9 + random() * 7});
car.model.group.visible = true; car.model.group.visible = true;
return true; return true;
} }
@@ -81,6 +99,13 @@ export function createTraffic(scene, data, count = 28) {
model.group.visible = false; model.group.visible = false;
cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9}); cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9});
} }
// Takes a car off the road; it respawns out of sight on a later frame.
function remove(car) {
car.road = null;
car.x = car.z = 1e9;
car.model.group.visible = false;
}
let cursor = 0; let cursor = 0;
function update(dt, obstacles, around) { function update(dt, obstacles, around) {
// Re-home a couple of distant cars per frame, which keeps the cost per frame small. // Re-home a couple of distant cars per frame, which keeps the cost per frame small.
@@ -99,6 +124,11 @@ export function createTraffic(scene, data, count = 28) {
const speed = ahead ? 0 : car.speed; const speed = ahead ? 0 : car.speed;
car.d += speed * car.dir * dt; car.d += speed * car.dir * dt;
if (car.d > car.road.total || car.d < 0) { if (car.d > car.road.total || car.d < 0) {
// At the end of a one-way road the car leaves; on a two-way road it turns back.
if (car.road.oneway) {
remove(car);
continue;
}
car.dir *= -1; car.dir *= -1;
car.d = Math.max(0, Math.min(car.road.total, car.d)); car.d = Math.max(0, Math.min(car.road.total, car.d));
} }
@@ -111,5 +141,5 @@ export function createTraffic(scene, data, count = 28) {
for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36; for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36;
} }
} }
return {cars, update}; return {cars, update, remove};
} }
+15
View File
@@ -5,6 +5,8 @@ import {toLocal, toLatLon, bearing, ORIGIN} from '../src/geo.js';
import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js'; import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js';
import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js'; import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js';
import {createCar, stepCar} from '../src/vehicle.js'; import {createCar, stepCar} from '../src/vehicle.js';
import {createTraffic} from '../src/traffic.js';
import {Scene} from 'three';
import {machines, machineById, FACTORY} from '../src/locations.js'; import {machines, machineById, FACTORY} from '../src/locations.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url))); const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
@@ -51,3 +53,16 @@ test('Car accelerates, steers right with positive input and brakes', () => {
for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30); for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30);
assert.ok(car.speed <= 0); assert.ok(car.speed <= 0);
}); });
test('Traffic drives with the flow on one-way carriageways and stays inside them', () => {
const oneway = new Set(data.oneway);
assert.ok(data.roads.some((r, i) => data.names[r[1]]?.includes('چناران') && oneway.has(i)), 'Chenaran highway is one-way per carriageway');
const traffic = createTraffic(new Scene(), data, 28), around = {x: -10008, z: -15049};
for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
const active = traffic.cars.filter(c => c.road);
assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
for (const car of active.filter(c => c.road.oneway)) {
assert.equal(car.dir, 1);
assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
}
});