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
+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);
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;