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:
@@ -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_));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+28
-6
@@ -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
@@ -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};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user