// PROCITY Lane B — lighting.js // Sky dome (seeded sky-*.jpg on a BackSide sphere) + a 6-segment day/night cycle ported from // 90sDJsim's applyLighting: one hemisphere ambient + one directional sun (single shadow map that // follows the player) + a night flag that drives window/lamp emissives (via a callback into the // ChunkManager). Linear fog tuned to the streaming radius doubles as the pop-in mask. // // Global, not chunked. The sky dome and sun re-centre on the player each frame so the world // never runs out from under you. import * as THREE from 'three'; // [name, sunI, sunColor, hemiSky, hemiGround, hemiI, skyTint, fogColor, exposure, night, sunOffset] const SEG = [ ['DAWN', 0.75, 0xffd9b0, 0xbcd0e8, 0x8a7a60, 0.75, 0xdcdce8, 0xcfc6ba, 1.05, false, [80, 26, 14]], ['MORNING', 1.20, 0xffe8c8, 0xcfe0f2, 0x9a8a6c, 0.95, 0xffffff, 0xd6d6cc, 1.00, false, [50, 46, 22]], ['MIDDAY', 1.55, 0xffffff, 0xd8e8ff, 0xa89878, 1.10, 0xffffff, 0xe0e2da, 0.98, false, [12, 72, 12]], ['ARVO', 1.15, 0xffdca0, 0xc8d4e8, 0xa08858, 1.00, 0xf2e8d8, 0xe0cdb0, 1.02, false, [-42, 46, 20]], ['DUSK', 0.55, 0xff9860, 0x9a86b0, 0x6a4a3a, 0.72, 0xb89a8a, 0xa07860, 1.10, false, [-74, 16, 10]], ['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]], ]; const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00']; const HOUR_NUM = [6.5, 9, 12.5, 15.5, 18.5, 22]; // numeric form (matches Lane F's currentHour() parse) // A tiny sky→horizon→ground vertical gradient as an equirect env source (canvas, no asset). PMREM'd // into scene.environment so PBR content is lit by neutral IBL instead of rendering dark — Lane D's // rig-fleet peds bake their impostor atlas from scene.environment (sim.js), and future GLB props read it. function gradientEquirect(THREE) { const c = document.createElement('canvas'); c.width = 8; c.height = 64; const g = c.getContext('2d'); const grad = g.createLinearGradient(0, 0, 0, 64); grad.addColorStop(0.00, '#9fb6d6'); // zenith sky grad.addColorStop(0.50, '#cdccc0'); // horizon haze grad.addColorStop(1.00, '#5c5548'); // ground bounce g.fillStyle = grad; g.fillRect(0, 0, 8, 64); const tex = new THREE.CanvasTexture(c); tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true; return tex; } export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) { const group = new THREE.Group(); group.name = 'lighting'; scene.add(group); // sky dome — seeded photo skin, darkened at night via material colour // [R32 §31.6-3] `dawnSky` (option, shell passes null under ?classic=1): the default dome is a // golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN // segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override // (rain/overcast) OWNS the dome outright — the dawn swap never fights it. const skyName = plan.sky || 'golden-arvo'; const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false }); let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after let shownSky = skyName; skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat); group.add(dome); function applySkyFor(segIdx) { const want = externalSky || (segIdx === 0 && dawnSky ? dawnSky : skyName); if (want === shownSky) return; shownSky = want; skins.skyTex(want, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); } const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0); group.add(hemi); const sun = new THREE.DirectionalLight(0xffffff, 1.4); sun.castShadow = shadows; // dense cities skip the shadow pass (≈halves draw calls; doc allows "none") sun.shadow.mapSize.set(2048, 2048); sun.shadow.bias = -0.0004; const SH = 60; // half-extent of the shadow frustum around the player Object.assign(sun.shadow.camera, { left: -SH, right: SH, top: SH, bottom: -SH, near: 1, far: 260 }); sun.shadow.camera.updateProjectionMatrix(); group.add(sun, sun.target); // fog fades distant chunks out just before the streaming radius, hiding pop-in const fog = new THREE.Fog(0xd6d6cc, 40, (radius + 0.4) * 64); scene.fog = fog; const _skyTint = new THREE.Color(); const _fogTint = new THREE.Color(); let seg = 2, night = false; let clock = seg + 0.001, paused = false; const DAY_SECONDS = 240; // one full cycle let onNightChange = null; let renderer = null; let envRT = null; // PMREM env render target backing scene.environment function apply(i) { const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i]; applySkyFor(i); // [R32] dawn dome swap (no-op under classic / weather override / other segments) sun.intensity = sunI; sun.color.set(sunC); hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd); _skyTint.set(skyTint); skyMat.color.copy(_skyTint); _fogTint.set(fogC); fog.color.copy(_fogTint); scene.background = _fogTint.clone(); // horizon colour behind the (finite) dome if (renderer) renderer.toneMappingExposure = exposure; const was = night; night = isNight; seg = i; if (isNight !== was && onNightChange) onNightChange(isNight); // announce the segment so facade shop-hours state (buildings.js) re-evaluates once, not per frame. // hour is the segment's representative hour, matching Lane F's currentHour()/isOpen() convention. if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('procity:segment', { detail: { seg: i, hour: HOUR_NUM[i], night: isNight } })); } function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); } function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); } // Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky). // The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map. // [R32] a caller-set sky is an OVERRIDE: it wins over the base skin and the dawn swap from then on. function setSky(name) { externalSky = name; shownSky = name; skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); } const _sunOff = new THREE.Vector3(); function update(dt, playerPos) { if (!paused) { clock = (clock + (dt / DAY_SECONDS) * SEG.length) % SEG.length; const i = Math.floor(clock); if (i !== seg) apply(i); } // re-centre dome + sun on the player dome.position.copy(playerPos); const off = SEG[seg][10]; _sunOff.set(off[0], off[1], off[2]).multiplyScalar(0.8); sun.position.copy(playerPos).add(_sunOff); sun.target.position.copy(playerPos); sun.target.updateMatrixWorld(); } function getClock() { const frac = clock - Math.floor(clock); return { seg, label: SEG[seg][0], hour: HOUR[seg], night, frac }; } function attachRenderer(r) { renderer = r; r.toneMappingExposure = SEG[seg][8]; // set scene.environment now (needs the renderer for PMREM). Called before citizens construct, // so Lane D's ped impostor atlas bakes against a real environment. One neutral env for all // segments — the atlas bakes once, and the mostly-non-metal streetscape is barely affected. if (!envRT) { const pmrem = new THREE.PMREMGenerator(r); const eq = gradientEquirect(THREE); envRT = pmrem.fromEquirectangular(eq); scene.environment = envRT.texture; eq.dispose(); pmrem.dispose(); } } function dispose() { scene.fog = null; scene.environment = null; if (envRT) { envRT.dispose(); envRT = null; } dome.geometry.dispose(); skyMat.dispose(); scene.remove(group); } apply(seg); return { group, sun, hemi, update, setSegment, stepSegment, setSky, getClock, attachRenderer, dispose, setPaused: (p) => { paused = p; }, togglePaused: () => (paused = !paused), isNight: () => night, get segCount() { return SEG.length; }, set onNightChange(fn) { onNightChange = fn; }, get onNightChange() { return onNightChange; }, }; }