fix(city): enforce right-hand traffic and refine collision sampling
Every block now circulates clockwise so adjacent blocks use opposite lanes of a shared road, eliminating contra-mão paths. Collision detection samples bodies more densely (0.35 step, 0.88 lateral tolerance) to catch near-misses, and corner radii were increased for smoother turns. Also adds cosmetic car details — front/rear license plates, side mirrors with mounting arms, and a roof trim panel — plus updated traffic-rule screenshots.
This commit is contained in:
+96
-40
@@ -713,6 +713,28 @@ function buildCarMesh(colorHex, isBus = false) {
|
||||
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, bodyW * 0.55), dark);
|
||||
grille.position.set(bodyLen / 2 + 0.08, 0.52, 0);
|
||||
g.add(grille);
|
||||
// License plates.
|
||||
const plateMat = new THREE.MeshStandardMaterial({ color: 0xf5f5f0, roughness: 0.8 });
|
||||
const plateF = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat);
|
||||
plateF.position.set(bodyLen / 2 + 0.1, 0.42, 0);
|
||||
g.add(plateF);
|
||||
const plateR = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat);
|
||||
plateR.position.set(-bodyLen / 2 - 0.1, 0.42, 0);
|
||||
g.add(plateR);
|
||||
// Side mirrors.
|
||||
const mirrorMat = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.4, metalness: 0.5 });
|
||||
for (const side of [-1, 1]) {
|
||||
const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.1), mirrorMat);
|
||||
mirror.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.48 + 0.08));
|
||||
g.add(mirror);
|
||||
const armM = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.04, 0.12), mirrorMat);
|
||||
armM.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.44));
|
||||
g.add(armM);
|
||||
}
|
||||
// Roof detail.
|
||||
const roofTrim = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.4, 0.06, bodyW * 0.7), chrome);
|
||||
roofTrim.position.set(-0.2, bodyY + bodyH * 0.5 + 0.56, 0);
|
||||
g.add(roofTrim);
|
||||
// Headlights / taillights (materials stored for night ramp).
|
||||
g.userData.headMats = [];
|
||||
g.userData.tailMats = [];
|
||||
@@ -1043,16 +1065,12 @@ function initVehicles(g) {
|
||||
const dx = sq.x1 - sq.x0;
|
||||
const dz = sq.z1 - sq.z0;
|
||||
if (dx < 30 || dz < 30) continue;
|
||||
// One circulation direction per block (alternating CW/CCW so
|
||||
// neighbouring blocks share the two road lanes in opposite
|
||||
// directions without ever sharing the same path line).
|
||||
// Outer paths are intentionally NOT used: they coincide with the
|
||||
// inner path of the adjacent block and caused head-on overlaps.
|
||||
const cornerR = Math.min(dx, dz) * 0.18;
|
||||
const base = squarePath(sq, g.roadW / 4);
|
||||
const idx = g.squares.indexOf(sq);
|
||||
const ccw = (Math.floor(idx / Math.max(1, g.xs.length - 1)) + (idx % Math.max(1, g.xs.length - 1))) % 2 === 1;
|
||||
const corners = ccw ? [...base].reverse() : base;
|
||||
// Right-hand traffic only: every block circulates clockwise (from
|
||||
// above). Adjacent blocks then occupy the two opposite lanes of a
|
||||
// shared road without ever driving contra-mão. Outer paths are
|
||||
// never used — they coincide with the neighbour's inner path.
|
||||
const cornerR = Math.min(dx, dz) * 0.22;
|
||||
const corners = squarePath(sq, g.roadW / 4);
|
||||
const pts = roundedRectPath(corners, cornerR);
|
||||
loops.push({ pts, L: pathLength(pts) });
|
||||
}
|
||||
@@ -1196,11 +1214,11 @@ function getVehiclePose(v) {
|
||||
* Do the two oriented boxes (sampled along each long axis) intersect?
|
||||
*/
|
||||
function bodiesOverlap(a, b) {
|
||||
const lat = (a.halfW + b.halfW) * 0.82;
|
||||
for (let ta = -1; ta <= 1.001; ta += 0.5) {
|
||||
const lat = (a.halfW + b.halfW) * 0.88;
|
||||
for (let ta = -1; ta <= 1.001; ta += 0.35) {
|
||||
const ax = a.x + a.fx * a.halfL * ta;
|
||||
const az = a.z + a.fz * a.halfL * ta;
|
||||
for (let tb = -1; tb <= 1.001; tb += 0.5) {
|
||||
for (let tb = -1; tb <= 1.001; tb += 0.35) {
|
||||
const bx = b.x + b.fx * b.halfL * tb;
|
||||
const bz = b.z + b.fz * b.halfL * tb;
|
||||
const dx = ax - bx;
|
||||
@@ -1338,15 +1356,31 @@ function roundedRectPath(corners, radius) {
|
||||
const enter = curr.clone().addScaledVector(toPrev, r);
|
||||
const exit = curr.clone().addScaledVector(toNext, r);
|
||||
|
||||
// Approximate the quarter-arc with a few intermediate points.
|
||||
// Circular arc tangent to both straights. Centre lies on the
|
||||
// angle bisector at distance r / sin(θ/2) from the corner.
|
||||
// half-angle θ/2 from cosθ = toPrev·toNext:
|
||||
// cos(θ/2) = √((1 + cosθ) / 2)
|
||||
pts.push(enter);
|
||||
const steps = 5;
|
||||
const bis = toPrev.clone().add(toNext).normalize();
|
||||
const cosTheta = Math.max(-1, Math.min(1, toPrev.dot(toNext)));
|
||||
const halfAngle = Math.acos(Math.sqrt(Math.max(0, (1 + cosTheta) / 2)));
|
||||
const sinH = Math.sin(halfAngle) || Math.SQRT1_2;
|
||||
const centre = curr.clone().addScaledVector(bis, r / sinH);
|
||||
const v0 = enter.clone().sub(centre);
|
||||
const v1 = exit.clone().sub(centre);
|
||||
const a0 = Math.atan2(v0.z, v0.x);
|
||||
let a1 = Math.atan2(v1.z, v1.x);
|
||||
// Sweep the short way (≤ 180°).
|
||||
while (a1 - a0 > Math.PI) a1 -= Math.PI * 2;
|
||||
while (a1 - a0 < -Math.PI) a1 += Math.PI * 2;
|
||||
const steps = 10;
|
||||
for (let k = 1; k < steps; k++) {
|
||||
const t = k / steps;
|
||||
// Quadratic bezier through corner.
|
||||
const a = enter.clone().lerp(curr, t);
|
||||
const b = curr.clone().lerp(exit, t);
|
||||
pts.push(a.lerp(b, t));
|
||||
const ang = a0 + (a1 - a0) * (k / steps);
|
||||
pts.push(new THREE.Vector3(
|
||||
centre.x + Math.cos(ang) * r,
|
||||
0,
|
||||
centre.z + Math.sin(ang) * r
|
||||
));
|
||||
}
|
||||
pts.push(exit);
|
||||
}
|
||||
@@ -1388,10 +1422,12 @@ function updateLoopVehicle(v, dt) {
|
||||
if (trafficSystem && !committed && distForLogic < stopLine) {
|
||||
const state = trafficSystem.axisState(travelAxis);
|
||||
if (state === 'red') {
|
||||
wantStop = distForLogic > stopAt || v.speed < 1.2;
|
||||
// Always stop before the line — never enter the box on red,
|
||||
// regardless of how close we already are.
|
||||
wantStop = true;
|
||||
} else if (state === 'yellow') {
|
||||
// Brake for yellow unless already about to clear the box.
|
||||
wantStop = distForLogic > 5.5;
|
||||
wantStop = distForLogic > 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,24 +1440,30 @@ function updateLoopVehicle(v, dt) {
|
||||
// pair apart so perpendicular clips dissolve in a frame or two.
|
||||
const sep = vehicleSeparation(v);
|
||||
if (sep && sep.type === 'overlap') {
|
||||
if (sep.along > 0.8) {
|
||||
v.speed = Math.max(0, v.speed - 22 * dt);
|
||||
if (v.speed < 0.4) v.speed = 0;
|
||||
// Brake into the vehicle ahead; if the overlap is severe also
|
||||
// nudge ourselves back along the path so bodies separate.
|
||||
if (sep.along > 0.5) {
|
||||
v.speed = Math.max(0, v.speed - 28 * dt);
|
||||
if (v.speed < 0.3) v.speed = 0;
|
||||
} else if (sep.along < -0.5) {
|
||||
// Their nose is behind ours — keep going to pull away.
|
||||
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
||||
} else {
|
||||
v.speed = Math.max(0, v.speed - 18 * dt);
|
||||
}
|
||||
// Severe clip (centers almost equal): push slightly along the
|
||||
// path so bodies separate instead of stacking.
|
||||
const o = getVehiclePose(sep.other);
|
||||
const dx = o.x - v.mesh.position.x;
|
||||
const dz = o.z - v.mesh.position.z;
|
||||
if (dx * dx + dz * dz < 1.2) {
|
||||
v.s = (v.s - 0.35 + v.L) % v.L;
|
||||
if (dx * dx + dz * dz < 2.2) {
|
||||
v.s = (v.s - 0.5 + v.L) % v.L;
|
||||
}
|
||||
}
|
||||
|
||||
if (wantStop) {
|
||||
v.speed = Math.max(0, v.speed - v.decel * dt);
|
||||
// Hard stop at the stop line — never roll into the junction.
|
||||
if (distForLogic <= stopAt && v.speed < 0.6) v.speed = 0;
|
||||
// Clamp exactly at the stop line — never roll into the junction.
|
||||
if (distForLogic <= 0.35 && v.speed < 0.8) v.speed = 0;
|
||||
if (distForLogic <= 0 && v.speed > 0) v.speed = 0;
|
||||
} else if (!committed) {
|
||||
const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed);
|
||||
v.speed = Math.min(cap, v.speed + v.accel * dt);
|
||||
@@ -1430,27 +1472,41 @@ function updateLoopVehicle(v, dt) {
|
||||
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
||||
}
|
||||
|
||||
// Extra safety: if somehow still inside the stop zone on red, freeze.
|
||||
if (trafficSystem && !committed && distForLogic >= 0
|
||||
&& trafficSystem.axisState(travelAxis) === 'red'
|
||||
&& distForLogic < 1.2) {
|
||||
v.speed = 0;
|
||||
}
|
||||
|
||||
v.s = (v.s + v.speed * dt) % v.L;
|
||||
const pos = new THREE.Vector3();
|
||||
const tmp = new THREE.Vector3();
|
||||
posOnPath(v.pts, v.s, v.L, pos);
|
||||
// Look farther ahead while moving so turns ease in smoothly.
|
||||
const lookAhead = 0.5 + Math.min(2.2, v.speed * 0.18);
|
||||
// Constant look-ahead so the heading never oscillates with speed.
|
||||
const lookAhead = 1.1;
|
||||
posOnPath(v.pts, v.s + lookAhead, v.L, tmp);
|
||||
v.mesh.position.copy(pos);
|
||||
if (tmp.distanceToSquared(pos) > 0.0001) {
|
||||
const targetYaw = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z);
|
||||
// Damp yaw so the body swings through the arc instead of snapping.
|
||||
const cur = v.mesh.rotation.y;
|
||||
let delta = targetYaw - cur;
|
||||
const curYaw = v.mesh.rotation.y;
|
||||
let delta = targetYaw - curYaw;
|
||||
while (delta > Math.PI) delta -= Math.PI * 2;
|
||||
while (delta < -Math.PI) delta += Math.PI * 2;
|
||||
const turnRate = v.kind === 'bike' ? 7 : v.kind === 'moto' ? 5.5 : 4.5;
|
||||
v.mesh.rotation.y = cur + delta * Math.min(1, turnRate * dt);
|
||||
// Slight body lean into the turn for bikes/motos.
|
||||
// Exponential damp toward the target; snap when close so the
|
||||
// body settles instead of hunting (which caused the zig-zag).
|
||||
const turnRate = v.kind === 'bike' ? 9 : v.kind === 'moto' ? 7 : 6;
|
||||
const t = Math.min(1, turnRate * dt);
|
||||
const yawStep = Math.abs(delta) < 0.01 ? delta : delta * t;
|
||||
v.mesh.rotation.y = curYaw + yawStep;
|
||||
if (v.kind === 'bike' || v.kind === 'moto') {
|
||||
v.mesh.rotation.z = -delta * Math.min(1, turnRate * dt) * 0.9;
|
||||
v.mesh.rotation.z = -yawStep * 0.85;
|
||||
}
|
||||
const wheels = v.mesh.userData.wheels || [];
|
||||
// Front-axle steering follows the yaw rate, damped so it settles.
|
||||
const steer = Math.max(-0.45, Math.min(0.45, yawStep * 4));
|
||||
if (wheels[0]) wheels[0].rotation.y += (steer - wheels[0].rotation.y) * Math.min(1, 10 * dt);
|
||||
if (wheels[1]) wheels[1].rotation.y += (steer - wheels[1].rotation.y) * Math.min(1, 10 * dt);
|
||||
}
|
||||
const wheelSpin = v.speed * dt * 3;
|
||||
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;
|
||||
|
||||
Reference in New Issue
Block a user