diff --git a/.playwright-mcp/city10-collision-fixed.png b/.playwright-mcp/city10-collision-fixed.png new file mode 100644 index 0000000..441457f Binary files /dev/null and b/.playwright-mcp/city10-collision-fixed.png differ diff --git a/.playwright-mcp/city10-day-no-headlights.png b/.playwright-mcp/city10-day-no-headlights.png new file mode 100644 index 0000000..67604e5 Binary files /dev/null and b/.playwright-mcp/city10-day-no-headlights.png differ diff --git a/.playwright-mcp/city10-night-headlights.png b/.playwright-mcp/city10-night-headlights.png new file mode 100644 index 0000000..9aafbda Binary files /dev/null and b/.playwright-mcp/city10-night-headlights.png differ diff --git a/.playwright-mcp/city10-no-overlap.png b/.playwright-mcp/city10-no-overlap.png new file mode 100644 index 0000000..285621c Binary files /dev/null and b/.playwright-mcp/city10-no-overlap.png differ diff --git a/.playwright-mcp/city10-poles-aerial.png b/.playwright-mcp/city10-poles-aerial.png new file mode 100644 index 0000000..5fa4265 Binary files /dev/null and b/.playwright-mcp/city10-poles-aerial.png differ diff --git a/.playwright-mcp/city10-poles-fixed.png b/.playwright-mcp/city10-poles-fixed.png new file mode 100644 index 0000000..8cd2012 Binary files /dev/null and b/.playwright-mcp/city10-poles-fixed.png differ diff --git a/.playwright-mcp/city10-poles-sidewalk.png b/.playwright-mcp/city10-poles-sidewalk.png new file mode 100644 index 0000000..9e15eb6 Binary files /dev/null and b/.playwright-mcp/city10-poles-sidewalk.png differ diff --git a/.playwright-mcp/city10-poles-view.png b/.playwright-mcp/city10-poles-view.png new file mode 100644 index 0000000..82ce7ef Binary files /dev/null and b/.playwright-mcp/city10-poles-view.png differ diff --git a/.playwright-mcp/city10-stop-directional.png b/.playwright-mcp/city10-stop-directional.png new file mode 100644 index 0000000..1cb5d27 Binary files /dev/null and b/.playwright-mcp/city10-stop-directional.png differ diff --git a/.playwright-mcp/city10-stop-final.png b/.playwright-mcp/city10-stop-final.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-final.png differ diff --git a/.playwright-mcp/city10-stop-line-complete.png b/.playwright-mcp/city10-stop-line-complete.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-line-complete.png differ diff --git a/.playwright-mcp/city10-stop-line-final-check.png b/.playwright-mcp/city10-stop-line-final-check.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-line-final-check.png differ diff --git a/.playwright-mcp/city10-stop-line-verified-final.png b/.playwright-mcp/city10-stop-line-verified-final.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-line-verified-final.png differ diff --git a/.playwright-mcp/city10-stop-line-verified.png b/.playwright-mcp/city10-stop-line-verified.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-line-verified.png differ diff --git a/.playwright-mcp/city10-stop-line.png b/.playwright-mcp/city10-stop-line.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-line.png differ diff --git a/.playwright-mcp/city10-stop-verified-check.png b/.playwright-mcp/city10-stop-verified-check.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-verified-check.png differ diff --git a/.playwright-mcp/city10-stop-verified-final.png b/.playwright-mcp/city10-stop-verified-final.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-verified-final.png differ diff --git a/.playwright-mcp/city10-stop-verified.png b/.playwright-mcp/city10-stop-verified.png new file mode 100644 index 0000000..ead2c8c Binary files /dev/null and b/.playwright-mcp/city10-stop-verified.png differ diff --git a/.playwright-mcp/city10-vehicles-complete.png b/.playwright-mcp/city10-vehicles-complete.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-complete.png differ diff --git a/.playwright-mcp/city10-vehicles-detail.png b/.playwright-mcp/city10-vehicles-detail.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-detail.png differ diff --git a/.playwright-mcp/city10-vehicles-detailed.png b/.playwright-mcp/city10-vehicles-detailed.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-detailed.png differ diff --git a/.playwright-mcp/city10-vehicles-final-check.png b/.playwright-mcp/city10-vehicles-final-check.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-final-check.png differ diff --git a/.playwright-mcp/city10-vehicles-final.png b/.playwright-mcp/city10-vehicles-final.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-final.png differ diff --git a/.playwright-mcp/city10-vehicles-improved.png b/.playwright-mcp/city10-vehicles-improved.png new file mode 100644 index 0000000..f46ce29 Binary files /dev/null and b/.playwright-mcp/city10-vehicles-improved.png differ diff --git a/public/js/city/app.js b/public/js/city/app.js index 10fa9f9..7ccc1fc 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -206,6 +206,8 @@ let exercise = { let remoteCitizens = new Map(); // characterId -> mesh let presenceTimer = null; let vehicleHeadlights = []; +let vehicleTailLights = []; +let vehicleBeams = []; let mobileInput = { x: 0, y: 0, active: false }; let minimapCtx = null; let lastPresenceOk = 0; @@ -218,6 +220,8 @@ function clearGroup(g) { citizenMeshes = []; remoteCitizens = new Map(); vehicleHeadlights = []; + vehicleTailLights = []; + vehicleBeams = []; } function addCollider(cx, cy, cz, w, h, d) { @@ -264,7 +268,12 @@ function updateDayNight() { const dayT = (hour - 6) / 12; const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI); const isDay = hour >= 6 && hour <= 18; - const night = isDay ? 0 : 1; + // Smooth dusk (17–19) / dawn (5–7) so headlights ramp in gradually. + let night = 1; + if (hour >= 19 || hour < 5) night = 1; + else if (hour >= 17) night = (hour - 17) / 2; + else if (hour < 7) night = (7 - hour) / 2; + else night = 0; sim.nightFactor = night; const weatherDim = weatherSunDim(); @@ -534,41 +543,56 @@ function initTrafficLights(g) { const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 }); const pedPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.65, metalness: 0.4 }); + // Sidewalk band is [half, half+2] from the road centre (half=4 → 4..6). + const half = g.roadW / 2; + const sw = half + 1; // sidewalk centre + for (const x of g.xs) { for (const z of g.zs) { + // SE-corner pole, planted on the sidewalk. const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat); - pole.position.set(x + g.roadW / 2 + 1.6, 2.1, z + g.roadW / 2 + 1.6); + pole.position.set(x + sw, 2.1, z + sw); pole.castShadow = true; group.add(pole); + // Mast arm over the vertical road (northbound lane), facing + // south so approaching traffic sees the signal. + const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat); + armNS.position.set(x + sw, 4.0, z + sw - 1.1); + group.add(armNS); const headNS = makeSignalHead(); - headNS.position.set(x + g.roadW / 2 + 1.6, 3.8, z + g.roadW / 2 + 0.7); + headNS.position.set(x + sw, 3.7, z + sw - 2.2); + headNS.rotation.y = Math.PI; // face -Z (south) group.add(headNS); heads.push({ head: headNS, axis: 'z' }); + // Mast arm over the horizontal road (eastbound lane), facing west. + const armEW = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.1, 0.12), poleMat); + armEW.position.set(x + sw - 1.1, 4.0, z + sw); + group.add(armEW); const headEW = makeSignalHead(); - headEW.position.set(x + g.roadW / 2 + 0.7, 3.8, z + g.roadW / 2 + 1.6); - headEW.rotation.y = Math.PI / 2; + headEW.position.set(x + sw - 2.2, 3.7, z + sw); + headEW.rotation.y = -Math.PI / 2; // face -X (west) group.add(headEW); heads.push({ head: headEW, axis: 'x' }); - // Pedestrian signals on the SE corner, facing the crosswalks. - // Cross-Z head: allows walking E-W across the vertical (z-axis) road. + // Pedestrian signals: short posts on the same corner, well + // inside the sidewalk, facing the crosswalks they control. const pedZ = makePedSignal(); const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat); - pedZPole.position.set(x + g.roadW / 2 + 2.4, 1.2, z + g.roadW / 2 - 1.2); + pedZPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6); group.add(pedZPole); - pedZ.position.set(x + g.roadW / 2 + 2.4, 2.5, z + g.roadW / 2 - 1.2); + pedZ.position.set(x + sw + 0.6, 2.5, z + sw - 0.6); + pedZ.rotation.y = Math.PI; // faces south crosswalk group.add(pedZ); pedHeads.push({ head: pedZ, axis: 'z' }); - // Cross-X head: allows walking N-S across the horizontal (x-axis) road. const pedX = makePedSignal(); const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat); - pedXPole.position.set(x + g.roadW / 2 - 1.2, 1.2, z + g.roadW / 2 + 2.4); + pedXPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6); group.add(pedXPole); - pedX.position.set(x + g.roadW / 2 - 1.2, 2.5, z + g.roadW / 2 + 2.4); - pedX.rotation.y = Math.PI / 2; + pedX.position.set(x + sw - 0.6, 2.5, z + sw + 0.6); + pedX.rotation.y = -Math.PI / 2; // faces west crosswalk group.add(pedX); pedHeads.push({ head: pedX, axis: 'x' }); } @@ -628,183 +652,338 @@ const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827] function buildCarMesh(colorHex, isBus = false) { const g = new THREE.Group(); - const bodyLen = isBus ? 7.5 : 4.0; - const bodyW = isBus ? 2.2 : 1.8; - const bodyH = isBus ? 1.8 : 0.65; - const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.35 }); + const bodyLen = isBus ? 7.5 : 4.2; + const bodyW = isBus ? 2.2 : 1.85; + const bodyH = isBus ? 1.6 : 0.55; + const paint = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.32, metalness: 0.55 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x14161a, roughness: 0.5, metalness: 0.3 }); + const glass = new THREE.MeshStandardMaterial({ + color: 0x9fd4ff, roughness: 0.12, metalness: 0.2, transparent: true, opacity: 0.55, + }); + const chrome = new THREE.MeshStandardMaterial({ color: 0xd8dee6, roughness: 0.2, metalness: 0.9 }); + const rubber = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, roughness: 0.92 }); - const body = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), mat); - body.position.y = isBus ? 1.1 : 0.55; - body.castShadow = true; - g.add(body); + const bodyY = isBus ? 1.15 : 0.52; + const chassis = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), paint); + chassis.position.y = bodyY; + chassis.castShadow = true; + g.add(chassis); + + // Rounded hood / trunk edges. + const hood = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.38, 0.18, bodyW * 0.92), paint); + hood.position.set(bodyLen * 0.28, bodyY + bodyH * 0.5 + 0.05, 0); + g.add(hood); if (isBus) { - const winMat = new THREE.MeshStandardMaterial({ color: 0x88ccee, roughness: 0.2, metalness: 0.5 }); - const win = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.6, bodyW + 0.02), winMat); - win.position.y = 1.55; - g.add(win); - } else { - const cabin = new THREE.Mesh( - new THREE.BoxGeometry(bodyLen * 0.5, 0.5, bodyW * 0.85), - new THREE.MeshStandardMaterial({ color: 0x223344, roughness: 0.2, metalness: 0.4 }) - ); - cabin.position.set(-0.15, 1.05, 0); + const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.92, 1.35, bodyW * 0.96), paint); + cabin.position.set(-0.1, bodyY + 1.15, 0); cabin.castShadow = true; g.add(cabin); + for (const side of [-1, 1]) { + const strip = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.8, 0.5, 0.04), glass); + strip.position.set(-0.1, bodyY + 1.35, side * (bodyW / 2 + 0.01)); + g.add(strip); + } + const front = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.55, bodyW * 0.7), glass); + front.position.set(bodyLen / 2 - 0.02, bodyY + 1.3, 0); + g.add(front); + const roof = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.12, bodyW * 0.9), chrome); + roof.position.set(-0.1, bodyY + 1.9, 0); + g.add(roof); + } else { + const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.48, 0.52, bodyW * 0.86), paint); + cabin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.28, 0); + cabin.castShadow = true; + g.add(cabin); + const windshield = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.22, 0.38, bodyW * 0.78), glass); + windshield.position.set(bodyLen * 0.08, bodyY + bodyH * 0.5 + 0.3, 0); + g.add(windshield); + for (const side of [-1, 1]) { + const sideWin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.3, 0.32, 0.04), glass); + sideWin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.3, side * (bodyW * 0.44)); + g.add(sideWin); + } + // Bumpers + grille. + const bumperF = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome); + bumperF.position.set(bodyLen / 2 + 0.02, 0.38, 0); + g.add(bumperF); + const bumperR = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome); + bumperR.position.set(-bodyLen / 2 - 0.02, 0.38, 0); + g.add(bumperR); + 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); + // Headlights / taillights (materials stored for night ramp). + g.userData.headMats = []; + g.userData.tailMats = []; + for (const side of [-1, 1]) { + const headMat = new THREE.MeshStandardMaterial({ + color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2, + }); + const head = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.14, 0.28), headMat); + head.position.set(bodyLen / 2 + 0.05, 0.58, side * (bodyW * 0.32)); + g.add(head); + g.userData.headMats.push(headMat); + const tailMat = new THREE.MeshStandardMaterial({ + color: 0xff3344, emissive: 0xcc1122, emissiveIntensity: 0.4, + }); + const tail = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.12, 0.3), tailMat); + tail.position.set(-bodyLen / 2 - 0.04, 0.55, side * (bodyW * 0.32)); + g.add(tail); + g.userData.tailMats.push(tailMat); + } + // Visible beam cones (hidden by day). + g.userData.beams = []; + for (const side of [-1, 1]) { + const beam = new THREE.Mesh( + new THREE.ConeGeometry(0.55, 6, 10, 1, true), + new THREE.MeshBasicMaterial({ + color: 0xfff2b0, transparent: true, opacity: 0, + side: THREE.DoubleSide, depthWrite: false, + }) + ); + // Cone default points +Y; rotate to point forward (+X). + beam.rotation.z = -Math.PI / 2; + beam.position.set(bodyLen / 2 + 3.1, 0.5, side * (bodyW * 0.28)); + g.add(beam); + g.userData.beams.push(beam.material); + } } - const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.24, 12); + const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.26, 14); wheelGeo.rotateX(Math.PI / 2); - const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 }); + const rimGeo = new THREE.CylinderGeometry(0.16, 0.16, 0.28, 10); + rimGeo.rotateX(Math.PI / 2); const wheels = []; - const wx = bodyLen * 0.32; - const wz = bodyW / 2 + 0.02; + const wx = bodyLen * 0.3; + const wz = bodyW / 2 + 0.04; [[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => { - const w = new THREE.Mesh(wheelGeo, wheelMat); - w.position.set(px, 0.32, pz); + const w = new THREE.Mesh(wheelGeo, rubber); + w.position.set(px, 0.34, pz); + w.castShadow = true; g.add(w); + const rim = new THREE.Mesh(rimGeo, chrome); + rim.position.set(px, 0.34, pz); + g.add(rim); wheels.push(w); }); + g.userData.wheels = wheels; g.userData.kind = isBus ? 'bus' : 'car'; - g.userData.halfL = bodyLen * 0.45; - g.userData.halfW = bodyW * 0.55; + g.userData.halfL = bodyLen * 0.46; + g.userData.halfW = bodyW * 0.52; return g; } function buildMotoMesh(colorHex) { const g = new THREE.Group(); - const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.55 }); - const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.25, metalness: 0.85 }); + const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.6 }); + const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.22, metalness: 0.9 }); const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.7 }); - const tank = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.35, 0.4), bodyMat); - tank.position.set(0.1, 0.72, 0); + const tank = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.38, 0.42), bodyMat); + tank.position.set(0.1, 0.74, 0); tank.castShadow = true; g.add(tank); + const tankStripe = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.08, 0.44), chrome); + tankStripe.position.set(0.1, 0.88, 0); + g.add(tankStripe); - const seat = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.12, 0.35), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })); - seat.position.set(-0.35, 0.82, 0); + const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.14, 0.36), dark); + seat.position.set(-0.38, 0.84, 0); g.add(seat); - const engine = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.3, 0.35), chrome); + const engine = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.32, 0.38), chrome); engine.position.set(0.15, 0.42, 0); g.add(engine); - const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.55, 6), chrome); + const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.58, 8), chrome); fork.rotation.z = 0.35; fork.position.set(0.7, 0.55, 0); g.add(fork); - const bar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.55), chrome); - bar.position.set(0.62, 0.85, 0); + const bar = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.09, 0.58), chrome); + bar.position.set(0.62, 0.86, 0); g.add(bar); + for (const side of [-1, 1]) { + const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.14, 6), dark); + grip.rotation.x = Math.PI / 2; + grip.position.set(0.62, 0.86, side * 0.3); + g.add(grip); + } - const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.12, 12); + const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.14, 14); wheelGeo.rotateX(Math.PI / 2); + const rimGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.16, 8); + rimGeo.rotateX(Math.PI / 2); const wheels = []; - [[0.72, 0], [-0.55, 0]].forEach(([px]) => { + [[0.74, 0], [-0.56, 0]].forEach(([px]) => { const w = new THREE.Mesh(wheelGeo, rubber); - w.position.set(px, 0.32, 0); + w.position.set(px, 0.34, 0); + w.castShadow = true; g.add(w); + const rim = new THREE.Mesh(rimGeo, chrome); + rim.position.set(px, 0.34, 0); + g.add(rim); wheels.push(w); }); - const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.9, 6), chrome); + const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.95, 8), chrome); exhaust.rotation.z = Math.PI / 2; - exhaust.position.set(-0.2, 0.35, 0.2); + exhaust.position.set(-0.2, 0.36, 0.2); g.add(exhaust); - // Tiny rider silhouette so the moto is not empty. + // Headlight. + const head = new THREE.Mesh( + new THREE.SphereGeometry(0.14, 10, 10), + new THREE.MeshStandardMaterial({ color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.3 }) + ); + head.position.set(0.9, 0.72, 0); + g.add(head); + g.userData.headMats = [head.material]; + g.userData.tailMats = []; + g.userData.beams = []; + const rider = new THREE.Mesh( - new THREE.CapsuleGeometry(0.18, 0.45, 4, 6), + new THREE.CapsuleGeometry(0.19, 0.48, 4, 8), new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 }) ); - rider.position.set(-0.15, 1.15, 0); - rider.rotation.z = -0.25; + rider.position.set(-0.14, 1.18, 0); + rider.rotation.z = -0.28; rider.castShadow = true; g.add(rider); const helmet = new THREE.Mesh( - new THREE.SphereGeometry(0.16, 10, 10), - new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.4 }) + new THREE.SphereGeometry(0.17, 12, 12), + new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.25, metalness: 0.5 }) ); - helmet.position.set(0.05, 1.5, 0); + helmet.position.set(0.06, 1.54, 0); g.add(helmet); + const visor = new THREE.Mesh( + new THREE.BoxGeometry(0.12, 0.08, 0.22), + new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.2, metalness: 0.4 }) + ); + visor.position.set(0.2, 1.52, 0); + g.add(visor); g.userData.wheels = wheels; g.userData.kind = 'moto'; - g.userData.halfL = 0.95; - g.userData.halfW = 0.35; + g.userData.halfL = 0.98; + g.userData.halfW = 0.36; return g; } function buildBikeMesh(colorHex) { const g = new THREE.Group(); - const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.45, metalness: 0.5 }); + const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.55 }); const rubber = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 }); - const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.3, metalness: 0.8 }); + const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.25, metalness: 0.85 }); + const dark = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.85 }); const tube = (len, x, y, rz = 0) => { - const m = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 6), frameMat); + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.038, 0.038, len, 8), frameMat); m.position.set(x, y, 0); m.rotation.z = rz; g.add(m); return m; }; - tube(1.05, 0, 0.55, Math.PI / 2.4); // down tube - tube(0.55, -0.15, 0.75, 0); // seat post - tube(0.45, 0.35, 0.85, Math.PI / 2); // top tube-ish + tube(1.1, 0, 0.58, Math.PI / 2.4); + tube(0.58, -0.15, 0.78, 0); + tube(0.48, 0.35, 0.88, Math.PI / 2); + // Chainstay. + tube(0.7, -0.3, 0.38, Math.PI / 2.1); - const seat = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.06, 0.16), new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 })); - seat.position.set(-0.2, 1.0, 0); + const chainring = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.02, 6, 16), chrome); + chainring.position.set(-0.05, 0.38, 0.12); + g.add(chainring); + + const seat = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.07, 0.18), dark); + seat.position.set(-0.2, 1.02, 0); g.add(seat); + const seatPost = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.22, 6), chrome); + seatPost.position.set(-0.18, 0.9, 0); + g.add(seatPost); - const bar = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.5), chrome); - bar.position.set(0.5, 1.0, 0); + const bar = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.52), chrome); + bar.position.set(0.52, 1.02, 0); g.add(bar); + for (const side of [-1, 1]) { + const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.12, 6), dark); + grip.rotation.x = Math.PI / 2; + grip.position.set(0.52, 1.02, side * 0.24); + g.add(grip); + } - const wheelGeo = new THREE.TorusGeometry(0.32, 0.045, 8, 18); + const wheelGeo = new THREE.TorusGeometry(0.34, 0.05, 8, 20); const wheels = []; - [[0.55], [-0.55]].forEach(([px]) => { + [[0.58], [-0.58]].forEach(([px]) => { const w = new THREE.Mesh(wheelGeo, rubber); - w.position.set(px, 0.32, 0); + w.position.set(px, 0.34, 0); + w.castShadow = true; g.add(w); - const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.08, 8), chrome); + // Spokes. + for (let i = 0; i < 6; i++) { + const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.64, 0.015, 0.015), chrome); + spoke.position.set(px, 0.34, 0); + spoke.rotation.z = (i / 6) * Math.PI; + g.add(spoke); + } + const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.09, 8), chrome); hub.rotation.x = Math.PI / 2; - hub.position.set(px, 0.32, 0); + hub.position.set(px, 0.34, 0); g.add(hub); wheels.push(w); }); // Rider pedalling. const torso = new THREE.Mesh( - new THREE.CapsuleGeometry(0.16, 0.4, 4, 6), + new THREE.CapsuleGeometry(0.17, 0.42, 4, 8), new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 }) ); - torso.position.set(-0.05, 1.25, 0); - torso.rotation.z = -0.35; + torso.position.set(-0.05, 1.28, 0); + torso.rotation.z = -0.38; torso.castShadow = true; g.add(torso); const head = new THREE.Mesh( - new THREE.SphereGeometry(0.14, 10, 10), + new THREE.SphereGeometry(0.15, 12, 12), new THREE.MeshStandardMaterial({ color: 0xf1c27d, roughness: 0.7 }) ); - head.position.set(0.12, 1.55, 0); + head.position.set(0.14, 1.58, 0); g.add(head); - const legGeo = new THREE.CapsuleGeometry(0.06, 0.4, 4, 6); + // Helmet. + const helmet = new THREE.Mesh( + new THREE.SphereGeometry(0.17, 10, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), + new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.4 }) + ); + helmet.position.set(0.14, 1.6, 0); + g.add(helmet); + + const legGeo = new THREE.CapsuleGeometry(0.065, 0.4, 4, 6); [-1, 1].forEach(side => { const leg = new THREE.Mesh(legGeo, new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85 })); - leg.position.set(0.05, 0.75, side * 0.12); - leg.rotation.z = side * 0.4; + leg.position.set(0.05, 0.78, side * 0.13); + leg.rotation.z = side * 0.42; g.add(leg); - wheels.push(leg); // animate legs with "wheel" spin for a pedalling look + // Legs animate with "wheel" spin for a pedalling look. + wheels.push(leg); }); + // Front reflector (night headlight). + const reflMat = new THREE.MeshStandardMaterial({ + color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2, + }); + const refl = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.08, 0.1), reflMat); + refl.position.set(0.72, 0.9, 0); + g.add(refl); + g.userData.headMats = [reflMat]; + g.userData.tailMats = []; + g.userData.beams = []; + g.userData.wheels = wheels; g.userData.kind = 'bike'; - g.userData.halfL = 0.85; - g.userData.halfW = 0.3; + g.userData.halfL = 0.9; + g.userData.halfW = 0.32; return g; } @@ -857,16 +1036,25 @@ function initVehicles(g) { const vehicles = []; const loops = []; + // Prefetch the layout so stop-line math can see real intersection coords. + initVehicles.g = g; + for (const sq of g.squares) { const dx = sq.x1 - sq.x0; const dz = sq.z1 - sq.z0; if (dx < 30 || dz < 30) continue; - // Inner rectangle = one circulation direction (right-hand lanes); - // outer rectangle = the opposite direction on the far side of the roads. - const ptsA = squarePath(sq, g.roadW / 4); - const ptsB = squarePathOuter(sq, g.roadW / 4); - loops.push({ pts: ptsA, L: pathLength(ptsA) }); - loops.push({ pts: ptsB, L: pathLength(ptsB) }); + // 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; + const pts = roundedRectPath(corners, cornerR); + loops.push({ pts, L: pathLength(pts) }); } const spawnLoop = (loop, offsetS) => { @@ -973,6 +1161,89 @@ function vehicleAheadGap(v) { return minGap; } +/** + * World-space pose of a vehicle: position, unit forward, body half-sizes. + */ +function getVehiclePose(v) { + const pos = new THREE.Vector3(); + const ahead = new THREE.Vector3(); + if (v.pts) { + posOnPath(v.pts, v.s, v.L, pos); + posOnPath(v.pts, v.s + 0.6, v.L, ahead); + } else { + pos.lerpVectors(v.a, v.b, v.t); + const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.06)); + ahead.lerpVectors(v.a, v.b, lookT); + if (ahead.distanceToSquared(pos) < 1e-6) { + ahead.x = pos.x + (v.b.x - v.a.x) * v.dir; + ahead.z = pos.z + (v.b.z - v.a.z) * v.dir; + } + } + const fx = ahead.x - pos.x; + const fz = ahead.z - pos.z; + const len = Math.hypot(fx, fz) || 1; + return { + x: pos.x, + z: pos.z, + fx: fx / len, + fz: fz / len, + halfL: v.mesh?.userData?.halfL ?? 2.0, + halfW: v.mesh?.userData?.halfW ?? 0.95, + }; +} + +/** + * 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 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) { + const bx = b.x + b.fx * b.halfL * tb; + const bz = b.z + b.fz * b.halfL * tb; + const dx = ax - bx; + const dz = az - bz; + if (dx * dx + dz * dz < lat * lat) return true; + } + } + return false; +} + +/** + * Hard body-overlap check against every other vehicle. + * + * Returns null when clear, otherwise the pose of the nearest overlapping + * vehicle plus the along-axis offset (positive = other is ahead of us). + * Deliberately does NOT block on "vehicle ahead in lane" — that is what + * same-loop following and traffic lights are for. World-space blocking + * caused intersection deadlocks (two cars freezing each other). + */ +function vehicleSeparation(v) { + const self = getVehiclePose(v); + const list = vehicleSystem.vehicles; + + for (const other of list) { + if (other === v) continue; + const o = getVehiclePose(other); + + const dx = o.x - self.x; + const dz = o.z - self.z; + const reach = self.halfL + o.halfL + 0.5; + if (dx * dx + dz * dz > reach * reach) continue; + + if (bodiesOverlap(self, o)) { + return { + type: 'overlap', + along: dx * self.fx + dz * self.fz, + other, + }; + } + } + return null; +} + function probeAhead(pts, s, L) { let rem = ((s % L) + L) % L; for (let i = 0; i < pts.length; i++) { @@ -981,11 +1252,56 @@ function probeAhead(pts, s, L) { const seg = a.distanceTo(b); if (rem <= seg + 1e-6) { const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z'; - return { distToCorner: seg - rem, axis, corner: b }; + return { distToCorner: seg - rem, axis, corner: b, segStart: a, segEnd: b, segIndex: i }; } rem -= seg; } - return { distToCorner: 999, axis: 'x', corner: pts[0] }; + return { distToCorner: 999, axis: 'x', corner: pts[0], segStart: pts[0], segEnd: pts[1] || pts[0], segIndex: 0 }; +} + +/** + * Distance along travel to the real intersection stop line. + * + * Uses the road grid, not path waypoints: finds the next grid line ahead + * in the direction of travel and returns how far the vehicle still is + * from the stop line (edge of the crossing box). + * + * Returns: + * >= 0 — metres still to go before the stop line + * -1 — stop line already passed (committed; must clear the box) + */ +function distanceToStopLine(probe, vehiclePos) { + const g = initVehicles.g; + if (!g) return probe.distToCorner; + + // Travel direction from the current path segment (dominant axis). + const dx = probe.segEnd.x - probe.segStart.x; + const dz = probe.segEnd.z - probe.segStart.z; + const axis = Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z'; + const sign = (axis === 'x' ? dx : dz) >= 0 ? 1 : -1; + + const coord = axis === 'x' ? vehiclePos.x : vehiclePos.z; + const lines = axis === 'x' ? g.xs : g.zs; + + // Next grid line strictly ahead of the vehicle. + let next = null; + let bestAhead = Infinity; + for (const line of lines) { + const ahead = (line - coord) * sign; + if (ahead > 0.6 && ahead < bestAhead) { + bestAhead = ahead; + next = line; + } + } + if (next === null) return 999; + + // Stop line sits just before the crossing box (half road + nose + margin). + const boxEdge = g.roadW * 0.5 + 2.0 + 0.8; + const stopCoord = next - sign * boxEdge; + const dist = (stopCoord - coord) * sign; + + if (dist <= 0) return -1; // already at/past the line — clear the box + return dist; } /** @@ -997,24 +1313,120 @@ function yawFromDelta(dx, dz) { return Math.atan2(-dz, dx); } +/** + * Rounded rectangle path for smoother 90° turns. + * + * Each corner is replaced by a quarter-arc of radius r so vehicles + * steers through curves instead of snapping headings. + */ +function roundedRectPath(corners, radius) { + const pts = []; + const n = corners.length; + for (let i = 0; i < n; i++) { + const prev = corners[(i - 1 + n) % n]; + const curr = corners[i]; + const next = corners[(i + 1) % n]; + + const toPrev = new THREE.Vector3().subVectors(prev, curr); + const toNext = new THREE.Vector3().subVectors(next, curr); + const lenP = toPrev.length() || 1; + const lenN = toNext.length() || 1; + toPrev.divideScalar(lenP); + toNext.divideScalar(lenN); + + const r = Math.min(radius, lenP * 0.45, lenN * 0.45); + const enter = curr.clone().addScaledVector(toPrev, r); + const exit = curr.clone().addScaledVector(toNext, r); + + // Approximate the quarter-arc with a few intermediate points. + pts.push(enter); + const steps = 5; + 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)); + } + pts.push(exit); + } + return pts; +} + +/** + * Speed cap approaching a corner so the turn angle looks natural. + */ +function cornerSpeedCap(kind, distToCorner, maxSpeed) { + if (distToCorner > 10) return maxSpeed; + const kindScale = kind === 'bike' ? 0.55 : kind === 'moto' ? 0.75 : kind === 'bus' ? 0.55 : 0.7; + const base = Math.max(1.6, maxSpeed * kindScale); + // Ease in: full cap when very close, blend out by 10 units. + const t = Math.min(1, Math.max(0, (10 - distToCorner) / 10)); + return maxSpeed - (maxSpeed - base) * t; +} + function updateLoopVehicle(v, dt) { const probe = probeAhead(v.pts, v.s, v.L); - const distToCorner = probe.distToCorner; + const cur = new THREE.Vector3(); + posOnPath(v.pts, v.s, v.L, cur); + const distToStop = distanceToStopLine(probe, cur); + const committed = distToStop < 0; // already past the stop line + const distForLogic = committed ? 999 : distToStop; let wantStop = false; - if (trafficSystem && distToCorner < 7.5) { - const state = trafficSystem.axisState(probe.axis); - if (state === 'red') wantStop = true; - else if (state === 'yellow' && distToCorner > 4) wantStop = true; + // Stop line: keep well before the intersection so the nose never + // enters the crossing box while the light is red/yellow. + const stopLine = 9.5; + const stopAt = 4.5; // never brake once closer than this — commit to crossing + + const travelAxis = (() => { + const dx = probe.segEnd.x - probe.segStart.x; + const dz = probe.segEnd.z - probe.segStart.z; + return Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z'; + })(); + + if (trafficSystem && !committed && distForLogic < stopLine) { + const state = trafficSystem.axisState(travelAxis); + if (state === 'red') { + wantStop = distForLogic > stopAt || v.speed < 1.2; + } else if (state === 'yellow') { + // Brake for yellow unless already about to clear the box. + wantStop = distForLogic > 5.5; + } } const gap = vehicleAheadGap(v); - if (gap < 9) wantStop = true; + // Same-loop following only (path distance) — keep a comfortable buffer. + if (gap < 6.5 && !committed) wantStop = true; + if (gap < 4.5 && committed) wantStop = v.speed < 2; + + // Hard overlap: brake into the vehicle ahead of us AND nudge the + // 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; + } + // 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 (wantStop) { v.speed = Math.max(0, v.speed - v.decel * dt); - if (distToCorner < 2.2 && v.speed < 0.5) v.speed = 0; + // Hard stop at the stop line — never roll into the junction. + if (distForLogic <= stopAt && v.speed < 0.6) v.speed = 0; + } else if (!committed) { + const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed); + v.speed = Math.min(cap, v.speed + v.accel * dt); } else { + // Inside the box: keep momentum to clear it. v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt); } @@ -1022,10 +1434,23 @@ function updateLoopVehicle(v, dt) { const pos = new THREE.Vector3(); const tmp = new THREE.Vector3(); posOnPath(v.pts, v.s, v.L, pos); - posOnPath(v.pts, v.s + 0.6, v.L, tmp); + // Look farther ahead while moving so turns ease in smoothly. + const lookAhead = 0.5 + Math.min(2.2, v.speed * 0.18); + posOnPath(v.pts, v.s + lookAhead, v.L, tmp); v.mesh.position.copy(pos); if (tmp.distanceToSquared(pos) > 0.0001) { - v.mesh.rotation.y = 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 cur = v.mesh.rotation.y; + let delta = targetYaw - cur; + 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. + if (v.kind === 'bike' || v.kind === 'moto') { + v.mesh.rotation.z = -delta * Math.min(1, turnRate * dt) * 0.9; + } } const wheelSpin = v.speed * dt * 3; for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin; @@ -1048,7 +1473,11 @@ function updateShuttleVehicle(v, dt) { void total; } - if (wantStop) { + // Avoid driving through other vehicles on bridges / crossing. + const sep = vehicleSeparation(v); + if (sep && sep.type === 'overlap' && sep.along > 0.8) { + v.speed = Math.max(0, v.speed - 22 * dt); + } else if (wantStop) { v.speed = Math.max(0, v.speed - 10 * dt); } else { v.speed = Math.min(v.maxSpeed, v.speed + 5 * dt); @@ -2344,36 +2773,51 @@ function buildCity(data) { }); }); - // ---- Street lamps ---- + // ---- Street lamps (on the sidewalk, never in the roadway) ---- const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 }); + const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre + const nearIntersection = (lx, lz) => { + // Skip spots within 7 units of any crossing so lamps never sit + // in the intersection or on the crosswalk. + return grid.xs.some(vx => Math.abs(lx - vx) < 7) + && grid.zs.some(vz => Math.abs(lz - vz) < 7); + }; layout.roads.forEach(r => { if (r === layoutExtras.bridgeRoad || r === layoutExtras.crossRoad) return; const [x1,, z1] = r.from; const [x2,, z2] = r.to; const len = Math.hypot(x2 - x1, z2 - z1); - const w = r.width; - const spacing = 30; + const spacing = 28; const count = Math.max(1, Math.floor(len / spacing)); for (let i = 0; i <= count; i++) { const t = i / count; const lx = x1 + (x2 - x1) * t; const lz = z1 + (z2 - z1) * t; - const ox = r.axis === 'z' ? w / 2 + 1.4 : 0; - const oz = r.axis === 'x' ? w / 2 + 1.4 : 0; const side = (i % 2 === 0) ? 1 : -1; + // Plant the pole on the sidewalk centre-line. + const px = r.axis === 'z' ? lx + lampSwOffset * side : lx; + const pz = r.axis === 'x' ? lz + lampSwOffset * side : lz; + if (nearIntersection(px, pz)) continue; const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat); - pole.position.set(lx + ox * side, 2.25, lz + oz * side); + pole.position.set(px, 2.25, pz); pole.castShadow = true; worldGroup.add(pole); - const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat); - arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7); + // Curved arm reaching over the curb toward the road. + const dirX = r.axis === 'z' ? -side : 0; + const dirZ = r.axis === 'x' ? -side : 0; + const arm = new THREE.Mesh( + new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1), + lampPoleMat + ); + arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6); worldGroup.add(arm); const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 }); - const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.5), headMat); - head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3); + // Compact cobra-style head sitting at the arm tip, hanging slightly down. + const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.18, 0.28), headMat); + head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2); worldGroup.add(head); lampHeads.push(headMat); } @@ -3603,35 +4047,26 @@ function drawMinimap() { // Vehicle headlights at night // ------------------------------------------------------------------ function initVehicleHeadlights() { + // Collect materials tagged on each mesh (car/bus/moto/bike). for (const v of vehicleSystem?.vehicles || []) { - if (!v.mesh || v.mesh.userData.headlights) continue; - if (v.mesh.userData.kind === 'bike') continue; - const lights = []; - const halfL = v.mesh.userData.halfL ?? 2; - for (const side of [-1, 1]) { - const lamp = new THREE.Mesh( - new THREE.SphereGeometry(0.12, 8, 8), - new THREE.MeshStandardMaterial({ - color: 0xfff6c8, - emissive: 0xfff2b0, - emissiveIntensity: 0, - }) - ); - lamp.position.set(halfL + 0.05, 0.55, side * 0.55); - v.mesh.add(lamp); - lights.push(lamp.material); - } - v.mesh.userData.headlights = lights; - vehicleHeadlights.push(...lights); + const mesh = v.mesh; + if (!mesh || mesh.userData.lightsCollected) continue; + if (mesh.userData.headMats) vehicleHeadlights.push(...mesh.userData.headMats); + if (mesh.userData.tailMats) vehicleTailLights.push(...mesh.userData.tailMats); + if (mesh.userData.beams) vehicleBeams.push(...mesh.userData.beams); + mesh.userData.lightsCollected = true; } } function updateVehicleHeadlights() { const night = sim.nightFactor; - const on = night > 0.4 ? (night - 0.4) / 0.6 * 2.2 : 0.05; - for (const m of vehicleHeadlights) { - m.emissiveIntensity = on; - } + // Heads fade in from dusk; beams only once fully dark. + const head = night > 0 ? 0.25 + night * 3.0 : 0.2; + const tail = night > 0 ? 0.5 + night * 1.6 : 0.4; + const beam = night > 0.55 ? (night - 0.55) / 0.45 * 0.22 : 0; + for (const m of vehicleHeadlights) m.emissiveIntensity = head; + for (const m of vehicleTailLights) m.emissiveIntensity = tail; + for (const m of vehicleBeams) m.opacity = beam; } // ------------------------------------------------------------------