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:
2026-10-24 16:48:33 -03:00
parent a1357fe953
commit a4b1cc8d78
3 changed files with 96 additions and 40 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+96 -40
View File
@@ -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); 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); grille.position.set(bodyLen / 2 + 0.08, 0.52, 0);
g.add(grille); 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). // Headlights / taillights (materials stored for night ramp).
g.userData.headMats = []; g.userData.headMats = [];
g.userData.tailMats = []; g.userData.tailMats = [];
@@ -1043,16 +1065,12 @@ function initVehicles(g) {
const dx = sq.x1 - sq.x0; const dx = sq.x1 - sq.x0;
const dz = sq.z1 - sq.z0; const dz = sq.z1 - sq.z0;
if (dx < 30 || dz < 30) continue; if (dx < 30 || dz < 30) continue;
// One circulation direction per block (alternating CW/CCW so // Right-hand traffic only: every block circulates clockwise (from
// neighbouring blocks share the two road lanes in opposite // above). Adjacent blocks then occupy the two opposite lanes of a
// directions without ever sharing the same path line). // shared road without ever driving contra-mão. Outer paths are
// Outer paths are intentionally NOT used: they coincide with the // never used — they coincide with the neighbour's inner path.
// inner path of the adjacent block and caused head-on overlaps. const cornerR = Math.min(dx, dz) * 0.22;
const cornerR = Math.min(dx, dz) * 0.18; const corners = squarePath(sq, g.roadW / 4);
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;
const pts = roundedRectPath(corners, cornerR); const pts = roundedRectPath(corners, cornerR);
loops.push({ pts, L: pathLength(pts) }); loops.push({ pts, L: pathLength(pts) });
} }
@@ -1196,11 +1214,11 @@ function getVehiclePose(v) {
* Do the two oriented boxes (sampled along each long axis) intersect? * Do the two oriented boxes (sampled along each long axis) intersect?
*/ */
function bodiesOverlap(a, b) { function bodiesOverlap(a, b) {
const lat = (a.halfW + b.halfW) * 0.82; const lat = (a.halfW + b.halfW) * 0.88;
for (let ta = -1; ta <= 1.001; ta += 0.5) { for (let ta = -1; ta <= 1.001; ta += 0.35) {
const ax = a.x + a.fx * a.halfL * ta; const ax = a.x + a.fx * a.halfL * ta;
const az = a.z + a.fz * 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 bx = b.x + b.fx * b.halfL * tb;
const bz = b.z + b.fz * b.halfL * tb; const bz = b.z + b.fz * b.halfL * tb;
const dx = ax - bx; const dx = ax - bx;
@@ -1338,15 +1356,31 @@ function roundedRectPath(corners, radius) {
const enter = curr.clone().addScaledVector(toPrev, r); const enter = curr.clone().addScaledVector(toPrev, r);
const exit = curr.clone().addScaledVector(toNext, 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); 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++) { for (let k = 1; k < steps; k++) {
const t = k / steps; const ang = a0 + (a1 - a0) * (k / steps);
// Quadratic bezier through corner. pts.push(new THREE.Vector3(
const a = enter.clone().lerp(curr, t); centre.x + Math.cos(ang) * r,
const b = curr.clone().lerp(exit, t); 0,
pts.push(a.lerp(b, t)); centre.z + Math.sin(ang) * r
));
} }
pts.push(exit); pts.push(exit);
} }
@@ -1388,10 +1422,12 @@ function updateLoopVehicle(v, dt) {
if (trafficSystem && !committed && distForLogic < stopLine) { if (trafficSystem && !committed && distForLogic < stopLine) {
const state = trafficSystem.axisState(travelAxis); const state = trafficSystem.axisState(travelAxis);
if (state === 'red') { 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') { } else if (state === 'yellow') {
// Brake for yellow unless already about to clear the box. // 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. // pair apart so perpendicular clips dissolve in a frame or two.
const sep = vehicleSeparation(v); const sep = vehicleSeparation(v);
if (sep && sep.type === 'overlap') { if (sep && sep.type === 'overlap') {
if (sep.along > 0.8) { // Brake into the vehicle ahead; if the overlap is severe also
v.speed = Math.max(0, v.speed - 22 * dt); // nudge ourselves back along the path so bodies separate.
if (v.speed < 0.4) v.speed = 0; 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 o = getVehiclePose(sep.other);
const dx = o.x - v.mesh.position.x; const dx = o.x - v.mesh.position.x;
const dz = o.z - v.mesh.position.z; const dz = o.z - v.mesh.position.z;
if (dx * dx + dz * dz < 1.2) { if (dx * dx + dz * dz < 2.2) {
v.s = (v.s - 0.35 + v.L) % v.L; v.s = (v.s - 0.5 + v.L) % v.L;
} }
} }
if (wantStop) { if (wantStop) {
v.speed = Math.max(0, v.speed - v.decel * dt); v.speed = Math.max(0, v.speed - v.decel * dt);
// Hard stop at the stop line — never roll into the junction. // Clamp exactly at the stop line — never roll into the junction.
if (distForLogic <= stopAt && v.speed < 0.6) v.speed = 0; if (distForLogic <= 0.35 && v.speed < 0.8) v.speed = 0;
if (distForLogic <= 0 && v.speed > 0) v.speed = 0;
} else if (!committed) { } else if (!committed) {
const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed); const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed);
v.speed = Math.min(cap, v.speed + v.accel * dt); 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); 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; v.s = (v.s + v.speed * dt) % v.L;
const pos = new THREE.Vector3(); const pos = new THREE.Vector3();
const tmp = new THREE.Vector3(); const tmp = new THREE.Vector3();
posOnPath(v.pts, v.s, v.L, pos); posOnPath(v.pts, v.s, v.L, pos);
// Look farther ahead while moving so turns ease in smoothly. // Constant look-ahead so the heading never oscillates with speed.
const lookAhead = 0.5 + Math.min(2.2, v.speed * 0.18); const lookAhead = 1.1;
posOnPath(v.pts, v.s + lookAhead, v.L, tmp); posOnPath(v.pts, v.s + lookAhead, v.L, tmp);
v.mesh.position.copy(pos); v.mesh.position.copy(pos);
if (tmp.distanceToSquared(pos) > 0.0001) { if (tmp.distanceToSquared(pos) > 0.0001) {
const targetYaw = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z); const targetYaw = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z);
// Damp yaw so the body swings through the arc instead of snapping. const curYaw = v.mesh.rotation.y;
const cur = v.mesh.rotation.y; let delta = targetYaw - curYaw;
let delta = targetYaw - cur;
while (delta > Math.PI) delta -= Math.PI * 2; while (delta > Math.PI) delta -= Math.PI * 2;
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; // Exponential damp toward the target; snap when close so the
v.mesh.rotation.y = cur + delta * Math.min(1, turnRate * dt); // body settles instead of hunting (which caused the zig-zag).
// Slight body lean into the turn for bikes/motos. 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') { 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; const wheelSpin = v.speed * dt * 3;
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin; for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;