feat(city): add vehicle tail lights, beams and smooth dusk lighting
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -206,6 +206,8 @@ let exercise = {
|
|||||||
let remoteCitizens = new Map(); // characterId -> mesh
|
let remoteCitizens = new Map(); // characterId -> mesh
|
||||||
let presenceTimer = null;
|
let presenceTimer = null;
|
||||||
let vehicleHeadlights = [];
|
let vehicleHeadlights = [];
|
||||||
|
let vehicleTailLights = [];
|
||||||
|
let vehicleBeams = [];
|
||||||
let mobileInput = { x: 0, y: 0, active: false };
|
let mobileInput = { x: 0, y: 0, active: false };
|
||||||
let minimapCtx = null;
|
let minimapCtx = null;
|
||||||
let lastPresenceOk = 0;
|
let lastPresenceOk = 0;
|
||||||
@@ -218,6 +220,8 @@ function clearGroup(g) {
|
|||||||
citizenMeshes = [];
|
citizenMeshes = [];
|
||||||
remoteCitizens = new Map();
|
remoteCitizens = new Map();
|
||||||
vehicleHeadlights = [];
|
vehicleHeadlights = [];
|
||||||
|
vehicleTailLights = [];
|
||||||
|
vehicleBeams = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCollider(cx, cy, cz, w, h, d) {
|
function addCollider(cx, cy, cz, w, h, d) {
|
||||||
@@ -264,7 +268,12 @@ function updateDayNight() {
|
|||||||
const dayT = (hour - 6) / 12;
|
const dayT = (hour - 6) / 12;
|
||||||
const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI);
|
const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI);
|
||||||
const isDay = hour >= 6 && hour <= 18;
|
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;
|
sim.nightFactor = night;
|
||||||
|
|
||||||
const weatherDim = weatherSunDim();
|
const weatherDim = weatherSunDim();
|
||||||
@@ -534,41 +543,56 @@ function initTrafficLights(g) {
|
|||||||
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
|
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 });
|
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 x of g.xs) {
|
||||||
for (const z of g.zs) {
|
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);
|
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;
|
pole.castShadow = true;
|
||||||
group.add(pole);
|
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();
|
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);
|
group.add(headNS);
|
||||||
heads.push({ head: headNS, axis: 'z' });
|
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();
|
const headEW = makeSignalHead();
|
||||||
headEW.position.set(x + g.roadW / 2 + 0.7, 3.8, z + g.roadW / 2 + 1.6);
|
headEW.position.set(x + sw - 2.2, 3.7, z + sw);
|
||||||
headEW.rotation.y = Math.PI / 2;
|
headEW.rotation.y = -Math.PI / 2; // face -X (west)
|
||||||
group.add(headEW);
|
group.add(headEW);
|
||||||
heads.push({ head: headEW, axis: 'x' });
|
heads.push({ head: headEW, axis: 'x' });
|
||||||
|
|
||||||
// Pedestrian signals on the SE corner, facing the crosswalks.
|
// Pedestrian signals: short posts on the same corner, well
|
||||||
// Cross-Z head: allows walking E-W across the vertical (z-axis) road.
|
// inside the sidewalk, facing the crosswalks they control.
|
||||||
const pedZ = makePedSignal();
|
const pedZ = makePedSignal();
|
||||||
const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
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);
|
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);
|
group.add(pedZ);
|
||||||
pedHeads.push({ head: pedZ, axis: 'z' });
|
pedHeads.push({ head: pedZ, axis: 'z' });
|
||||||
|
|
||||||
// Cross-X head: allows walking N-S across the horizontal (x-axis) road.
|
|
||||||
const pedX = makePedSignal();
|
const pedX = makePedSignal();
|
||||||
const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
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);
|
group.add(pedXPole);
|
||||||
pedX.position.set(x + g.roadW / 2 - 1.2, 2.5, z + g.roadW / 2 + 2.4);
|
pedX.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
||||||
pedX.rotation.y = Math.PI / 2;
|
pedX.rotation.y = -Math.PI / 2; // faces west crosswalk
|
||||||
group.add(pedX);
|
group.add(pedX);
|
||||||
pedHeads.push({ head: pedX, axis: 'x' });
|
pedHeads.push({ head: pedX, axis: 'x' });
|
||||||
}
|
}
|
||||||
@@ -628,183 +652,338 @@ const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827]
|
|||||||
|
|
||||||
function buildCarMesh(colorHex, isBus = false) {
|
function buildCarMesh(colorHex, isBus = false) {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const bodyLen = isBus ? 7.5 : 4.0;
|
const bodyLen = isBus ? 7.5 : 4.2;
|
||||||
const bodyW = isBus ? 2.2 : 1.8;
|
const bodyW = isBus ? 2.2 : 1.85;
|
||||||
const bodyH = isBus ? 1.8 : 0.65;
|
const bodyH = isBus ? 1.6 : 0.55;
|
||||||
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.35 });
|
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);
|
const bodyY = isBus ? 1.15 : 0.52;
|
||||||
body.position.y = isBus ? 1.1 : 0.55;
|
const chassis = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), paint);
|
||||||
body.castShadow = true;
|
chassis.position.y = bodyY;
|
||||||
g.add(body);
|
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) {
|
if (isBus) {
|
||||||
const winMat = new THREE.MeshStandardMaterial({ color: 0x88ccee, roughness: 0.2, metalness: 0.5 });
|
const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.92, 1.35, bodyW * 0.96), paint);
|
||||||
const win = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.6, bodyW + 0.02), winMat);
|
cabin.position.set(-0.1, bodyY + 1.15, 0);
|
||||||
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);
|
|
||||||
cabin.castShadow = true;
|
cabin.castShadow = true;
|
||||||
g.add(cabin);
|
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);
|
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 wheels = [];
|
||||||
const wx = bodyLen * 0.32;
|
const wx = bodyLen * 0.3;
|
||||||
const wz = bodyW / 2 + 0.02;
|
const wz = bodyW / 2 + 0.04;
|
||||||
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
||||||
const w = new THREE.Mesh(wheelGeo, wheelMat);
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
||||||
w.position.set(px, 0.32, pz);
|
w.position.set(px, 0.34, pz);
|
||||||
|
w.castShadow = true;
|
||||||
g.add(w);
|
g.add(w);
|
||||||
|
const rim = new THREE.Mesh(rimGeo, chrome);
|
||||||
|
rim.position.set(px, 0.34, pz);
|
||||||
|
g.add(rim);
|
||||||
wheels.push(w);
|
wheels.push(w);
|
||||||
});
|
});
|
||||||
|
|
||||||
g.userData.wheels = wheels;
|
g.userData.wheels = wheels;
|
||||||
g.userData.kind = isBus ? 'bus' : 'car';
|
g.userData.kind = isBus ? 'bus' : 'car';
|
||||||
g.userData.halfL = bodyLen * 0.45;
|
g.userData.halfL = bodyLen * 0.46;
|
||||||
g.userData.halfW = bodyW * 0.55;
|
g.userData.halfW = bodyW * 0.52;
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMotoMesh(colorHex) {
|
function buildMotoMesh(colorHex) {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.55 });
|
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.6 });
|
||||||
const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.25, metalness: 0.85 });
|
const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.22, metalness: 0.9 });
|
||||||
const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 });
|
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);
|
const tank = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.38, 0.42), bodyMat);
|
||||||
tank.position.set(0.1, 0.72, 0);
|
tank.position.set(0.1, 0.74, 0);
|
||||||
tank.castShadow = true;
|
tank.castShadow = true;
|
||||||
g.add(tank);
|
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 }));
|
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.14, 0.36), dark);
|
||||||
seat.position.set(-0.35, 0.82, 0);
|
seat.position.set(-0.38, 0.84, 0);
|
||||||
g.add(seat);
|
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);
|
engine.position.set(0.15, 0.42, 0);
|
||||||
g.add(engine);
|
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.rotation.z = 0.35;
|
||||||
fork.position.set(0.7, 0.55, 0);
|
fork.position.set(0.7, 0.55, 0);
|
||||||
g.add(fork);
|
g.add(fork);
|
||||||
|
|
||||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.55), chrome);
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.09, 0.58), chrome);
|
||||||
bar.position.set(0.62, 0.85, 0);
|
bar.position.set(0.62, 0.86, 0);
|
||||||
g.add(bar);
|
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);
|
wheelGeo.rotateX(Math.PI / 2);
|
||||||
|
const rimGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.16, 8);
|
||||||
|
rimGeo.rotateX(Math.PI / 2);
|
||||||
const wheels = [];
|
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);
|
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);
|
g.add(w);
|
||||||
|
const rim = new THREE.Mesh(rimGeo, chrome);
|
||||||
|
rim.position.set(px, 0.34, 0);
|
||||||
|
g.add(rim);
|
||||||
wheels.push(w);
|
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.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);
|
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(
|
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 })
|
new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 })
|
||||||
);
|
);
|
||||||
rider.position.set(-0.15, 1.15, 0);
|
rider.position.set(-0.14, 1.18, 0);
|
||||||
rider.rotation.z = -0.25;
|
rider.rotation.z = -0.28;
|
||||||
rider.castShadow = true;
|
rider.castShadow = true;
|
||||||
g.add(rider);
|
g.add(rider);
|
||||||
const helmet = new THREE.Mesh(
|
const helmet = new THREE.Mesh(
|
||||||
new THREE.SphereGeometry(0.16, 10, 10),
|
new THREE.SphereGeometry(0.17, 12, 12),
|
||||||
new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.4 })
|
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);
|
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.wheels = wheels;
|
||||||
g.userData.kind = 'moto';
|
g.userData.kind = 'moto';
|
||||||
g.userData.halfL = 0.95;
|
g.userData.halfL = 0.98;
|
||||||
g.userData.halfW = 0.35;
|
g.userData.halfW = 0.36;
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBikeMesh(colorHex) {
|
function buildBikeMesh(colorHex) {
|
||||||
const g = new THREE.Group();
|
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 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 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.position.set(x, y, 0);
|
||||||
m.rotation.z = rz;
|
m.rotation.z = rz;
|
||||||
g.add(m);
|
g.add(m);
|
||||||
return m;
|
return m;
|
||||||
};
|
};
|
||||||
|
|
||||||
tube(1.05, 0, 0.55, Math.PI / 2.4); // down tube
|
tube(1.1, 0, 0.58, Math.PI / 2.4);
|
||||||
tube(0.55, -0.15, 0.75, 0); // seat post
|
tube(0.58, -0.15, 0.78, 0);
|
||||||
tube(0.45, 0.35, 0.85, Math.PI / 2); // top tube-ish
|
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 }));
|
const chainring = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.02, 6, 16), chrome);
|
||||||
seat.position.set(-0.2, 1.0, 0);
|
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);
|
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);
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.52), chrome);
|
||||||
bar.position.set(0.5, 1.0, 0);
|
bar.position.set(0.52, 1.02, 0);
|
||||||
g.add(bar);
|
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 = [];
|
const wheels = [];
|
||||||
[[0.55], [-0.55]].forEach(([px]) => {
|
[[0.58], [-0.58]].forEach(([px]) => {
|
||||||
const w = new THREE.Mesh(wheelGeo, rubber);
|
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);
|
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.rotation.x = Math.PI / 2;
|
||||||
hub.position.set(px, 0.32, 0);
|
hub.position.set(px, 0.34, 0);
|
||||||
g.add(hub);
|
g.add(hub);
|
||||||
wheels.push(w);
|
wheels.push(w);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rider pedalling.
|
// Rider pedalling.
|
||||||
const torso = new THREE.Mesh(
|
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 })
|
new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 })
|
||||||
);
|
);
|
||||||
torso.position.set(-0.05, 1.25, 0);
|
torso.position.set(-0.05, 1.28, 0);
|
||||||
torso.rotation.z = -0.35;
|
torso.rotation.z = -0.38;
|
||||||
torso.castShadow = true;
|
torso.castShadow = true;
|
||||||
g.add(torso);
|
g.add(torso);
|
||||||
const head = new THREE.Mesh(
|
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 })
|
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);
|
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 => {
|
[-1, 1].forEach(side => {
|
||||||
const leg = new THREE.Mesh(legGeo, new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85 }));
|
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.position.set(0.05, 0.78, side * 0.13);
|
||||||
leg.rotation.z = side * 0.4;
|
leg.rotation.z = side * 0.42;
|
||||||
g.add(leg);
|
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.wheels = wheels;
|
||||||
g.userData.kind = 'bike';
|
g.userData.kind = 'bike';
|
||||||
g.userData.halfL = 0.85;
|
g.userData.halfL = 0.9;
|
||||||
g.userData.halfW = 0.3;
|
g.userData.halfW = 0.32;
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,16 +1036,25 @@ function initVehicles(g) {
|
|||||||
const vehicles = [];
|
const vehicles = [];
|
||||||
const loops = [];
|
const loops = [];
|
||||||
|
|
||||||
|
// Prefetch the layout so stop-line math can see real intersection coords.
|
||||||
|
initVehicles.g = g;
|
||||||
|
|
||||||
for (const sq of g.squares) {
|
for (const sq of g.squares) {
|
||||||
const dx = sq.x1 - sq.x0;
|
const dx = sq.x1 - sq.x0;
|
||||||
const dz = sq.z1 - sq.z0;
|
const dz = sq.z1 - sq.z0;
|
||||||
if (dx < 30 || dz < 30) continue;
|
if (dx < 30 || dz < 30) continue;
|
||||||
// Inner rectangle = one circulation direction (right-hand lanes);
|
// One circulation direction per block (alternating CW/CCW so
|
||||||
// outer rectangle = the opposite direction on the far side of the roads.
|
// neighbouring blocks share the two road lanes in opposite
|
||||||
const ptsA = squarePath(sq, g.roadW / 4);
|
// directions without ever sharing the same path line).
|
||||||
const ptsB = squarePathOuter(sq, g.roadW / 4);
|
// Outer paths are intentionally NOT used: they coincide with the
|
||||||
loops.push({ pts: ptsA, L: pathLength(ptsA) });
|
// inner path of the adjacent block and caused head-on overlaps.
|
||||||
loops.push({ pts: ptsB, L: pathLength(ptsB) });
|
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) => {
|
const spawnLoop = (loop, offsetS) => {
|
||||||
@@ -973,6 +1161,89 @@ function vehicleAheadGap(v) {
|
|||||||
return minGap;
|
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) {
|
function probeAhead(pts, s, L) {
|
||||||
let rem = ((s % L) + L) % L;
|
let rem = ((s % L) + L) % L;
|
||||||
for (let i = 0; i < pts.length; i++) {
|
for (let i = 0; i < pts.length; i++) {
|
||||||
@@ -981,11 +1252,56 @@ function probeAhead(pts, s, L) {
|
|||||||
const seg = a.distanceTo(b);
|
const seg = a.distanceTo(b);
|
||||||
if (rem <= seg + 1e-6) {
|
if (rem <= seg + 1e-6) {
|
||||||
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
|
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;
|
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);
|
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) {
|
function updateLoopVehicle(v, dt) {
|
||||||
const probe = probeAhead(v.pts, v.s, v.L);
|
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;
|
let wantStop = false;
|
||||||
|
|
||||||
if (trafficSystem && distToCorner < 7.5) {
|
// Stop line: keep well before the intersection so the nose never
|
||||||
const state = trafficSystem.axisState(probe.axis);
|
// enters the crossing box while the light is red/yellow.
|
||||||
if (state === 'red') wantStop = true;
|
const stopLine = 9.5;
|
||||||
else if (state === 'yellow' && distToCorner > 4) wantStop = true;
|
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);
|
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) {
|
if (wantStop) {
|
||||||
v.speed = Math.max(0, v.speed - v.decel * dt);
|
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 {
|
} else {
|
||||||
|
// Inside the box: keep momentum to clear it.
|
||||||
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
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 pos = new THREE.Vector3();
|
||||||
const tmp = new THREE.Vector3();
|
const tmp = new THREE.Vector3();
|
||||||
posOnPath(v.pts, v.s, v.L, pos);
|
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);
|
v.mesh.position.copy(pos);
|
||||||
if (tmp.distanceToSquared(pos) > 0.0001) {
|
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;
|
const wheelSpin = v.speed * dt * 3;
|
||||||
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;
|
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;
|
||||||
@@ -1048,7 +1473,11 @@ function updateShuttleVehicle(v, dt) {
|
|||||||
void total;
|
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);
|
v.speed = Math.max(0, v.speed - 10 * dt);
|
||||||
} else {
|
} else {
|
||||||
v.speed = Math.min(v.maxSpeed, v.speed + 5 * dt);
|
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 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 => {
|
layout.roads.forEach(r => {
|
||||||
if (r === layoutExtras.bridgeRoad || r === layoutExtras.crossRoad) return;
|
if (r === layoutExtras.bridgeRoad || r === layoutExtras.crossRoad) return;
|
||||||
const [x1,, z1] = r.from;
|
const [x1,, z1] = r.from;
|
||||||
const [x2,, z2] = r.to;
|
const [x2,, z2] = r.to;
|
||||||
const len = Math.hypot(x2 - x1, z2 - z1);
|
const len = Math.hypot(x2 - x1, z2 - z1);
|
||||||
const w = r.width;
|
const spacing = 28;
|
||||||
const spacing = 30;
|
|
||||||
const count = Math.max(1, Math.floor(len / spacing));
|
const count = Math.max(1, Math.floor(len / spacing));
|
||||||
for (let i = 0; i <= count; i++) {
|
for (let i = 0; i <= count; i++) {
|
||||||
const t = i / count;
|
const t = i / count;
|
||||||
const lx = x1 + (x2 - x1) * t;
|
const lx = x1 + (x2 - x1) * t;
|
||||||
const lz = z1 + (z2 - z1) * 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;
|
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);
|
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;
|
pole.castShadow = true;
|
||||||
worldGroup.add(pole);
|
worldGroup.add(pole);
|
||||||
|
|
||||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat);
|
// Curved arm reaching over the curb toward the road.
|
||||||
arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7);
|
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);
|
worldGroup.add(arm);
|
||||||
|
|
||||||
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
|
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);
|
// Compact cobra-style head sitting at the arm tip, hanging slightly down.
|
||||||
head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3);
|
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);
|
worldGroup.add(head);
|
||||||
lampHeads.push(headMat);
|
lampHeads.push(headMat);
|
||||||
}
|
}
|
||||||
@@ -3603,35 +4047,26 @@ function drawMinimap() {
|
|||||||
// Vehicle headlights at night
|
// Vehicle headlights at night
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
function initVehicleHeadlights() {
|
function initVehicleHeadlights() {
|
||||||
|
// Collect materials tagged on each mesh (car/bus/moto/bike).
|
||||||
for (const v of vehicleSystem?.vehicles || []) {
|
for (const v of vehicleSystem?.vehicles || []) {
|
||||||
if (!v.mesh || v.mesh.userData.headlights) continue;
|
const mesh = v.mesh;
|
||||||
if (v.mesh.userData.kind === 'bike') continue;
|
if (!mesh || mesh.userData.lightsCollected) continue;
|
||||||
const lights = [];
|
if (mesh.userData.headMats) vehicleHeadlights.push(...mesh.userData.headMats);
|
||||||
const halfL = v.mesh.userData.halfL ?? 2;
|
if (mesh.userData.tailMats) vehicleTailLights.push(...mesh.userData.tailMats);
|
||||||
for (const side of [-1, 1]) {
|
if (mesh.userData.beams) vehicleBeams.push(...mesh.userData.beams);
|
||||||
const lamp = new THREE.Mesh(
|
mesh.userData.lightsCollected = true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVehicleHeadlights() {
|
function updateVehicleHeadlights() {
|
||||||
const night = sim.nightFactor;
|
const night = sim.nightFactor;
|
||||||
const on = night > 0.4 ? (night - 0.4) / 0.6 * 2.2 : 0.05;
|
// Heads fade in from dusk; beams only once fully dark.
|
||||||
for (const m of vehicleHeadlights) {
|
const head = night > 0 ? 0.25 + night * 3.0 : 0.2;
|
||||||
m.emissiveIntensity = on;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||