feat(city): add vehicle tail lights, beams and smooth dusk lighting

This commit is contained in:
2026-10-24 16:34:07 -03:00
parent 693cd4cb00
commit a1357fe953
25 changed files with 575 additions and 140 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+575 -140
View File
@@ -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;
}
// ------------------------------------------------------------------