feat(city): add vehicle tail lights, beams and smooth dusk lighting
This commit is contained in:
+575
-140
@@ -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;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user