diff --git a/.playwright-mcp/city10-no-zigzag.png b/.playwright-mcp/city10-no-zigzag.png new file mode 100644 index 0000000..a2a4ec2 Binary files /dev/null and b/.playwright-mcp/city10-no-zigzag.png differ diff --git a/.playwright-mcp/city10-traffic-rules.png b/.playwright-mcp/city10-traffic-rules.png new file mode 100644 index 0000000..94c189a Binary files /dev/null and b/.playwright-mcp/city10-traffic-rules.png differ diff --git a/public/js/city/app.js b/public/js/city/app.js index 7ccc1fc..b4dbdbd 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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;