diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 3651155..9675264 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -23,6 +23,7 @@ const STORM_SKY = new THREE.Color(0x2a2f3a); const NIGHT_SKY = new THREE.Color(0x11141c); const WHITE = new THREE.Color(0xffffff); const FLASH_COL = new THREE.Color(0xdfe8ff); +const SUN_STORM = new THREE.Color(0xc4cedb); // the noon disc gone slate (gate 2.1) // ------------------------------------------------------------ rain shadow /** @@ -709,6 +710,8 @@ export function createSkyFx(o = {}) { fogFar: scene && scene.fog ? scene.fog.far : 0, sun: sun ? sun.intensity : 0, hemi: hemi ? hemi.intensity : 0, + sunColor: sun ? sun.color.clone() : null, + sunRadius: sun && sun.shadow ? sun.shadow.radius : 1, }; const baseSky = (scene && scene.background && scene.background.isColor) ? scene.background.clone() : CALM_SKY.clone(); @@ -720,6 +723,7 @@ export function createSkyFx(o = {}) { } let flash = 0; // decaying lightning brightness + let gradeNow = 0; // the storm grade, gate 2.1 — pure in t, see step() let flashQueue = []; // {at, power} — double-strike let lastTelegraph = null; const camPos = new THREE.Vector3(); @@ -730,6 +734,9 @@ export function createSkyFx(o = {}) { get flash() { return flash; }, /** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, + /** 0..1 — the storm grade driving sky/sun/shadow-softness (gate 2.1). + * storminess with a floor under the author's darkness dial; pure in t. */ + get stormGrade() { return gradeNow; }, /** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */ get changeFront() { return frontLevel; }, /** Azimuth (radians, XZ) the front stands in — the quarter the new wind @@ -845,6 +852,16 @@ export function createSkyFx(o = {}) { const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); const storminess = clamp01(Math.max(intensity, speed / 26)); + // SPRINT13 gate 2.1 — the STORM GRADE. The QA pass played the southerly at + // 65 km/h under a noon-blue sky, and the arithmetic says why: everything + // below multiplied the weather by the author's palette (`darkness`), so a + // darkness-0.5 storm at full blow only ever moved 0.35 toward slate — the + // author's dial could zero the weather's say. The grade gives the weather + // a floor: at full storminess even a darkness-0 sky goes half way to + // slate, while the wild night (0.94) keeps reading as the night it is. + // Same curve the rain already uses (storminess IS max(rain, wind)), pure + // in t, computed above the render gate so a test can read it headless. + gradeNow = storminess * lerp(0.5, 1, darkness); // --- events: lightning + the ticker --- for (const ev of wind.eventsBetween(t - dt, t)) { @@ -937,7 +954,7 @@ export function createSkyFx(o = {}) { if (!rendering) return; // --- sky --- - skyCol.copy(baseSky).lerp(target, storminess * darkness); + skyCol.copy(baseSky).lerp(target, gradeNow); if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash)); if (scene) { if (scene.fog) { @@ -946,8 +963,18 @@ export function createSkyFx(o = {}) { scene.fog.far = lerp(160, 55, storminess); } } - if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2; - if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2; + if (sun) { + sun.intensity = lerp(original.sun, original.sun * 0.12, gradeNow) + flash * 2.2; + // The sun loses its noon edge two ways as the grade builds: the warm + // disc goes slate (colour), and the shadows go soft (radius — the + // renderer is PCFSoft, so radius is real blur, not a no-op). Softness + // keys on STORMINESS, not the graded value: overcast is overcast even + // under a light-palette storm, and razor-sharp noon shadows at 65 km/h + // were the QA sighting this whole block answers. + if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85); + if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess); + } + if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + flash * 1.2; dome.position.copy(camPos); dome.material.opacity = storminess * 0.85; @@ -961,7 +988,7 @@ export function createSkyFx(o = {}) { // and the cloud texture is baked near-white. The crush is what makes night // look like night. It stops at 0.78 on purpose — the yard has no lights in // it yet, and a storm you can't see isn't a storm, it's a black screen. - const nightAmt = storminess * darkness; + const nightAmt = gradeNow; dome.material.color.copy(WHITE) .lerp(target, nightAmt * 0.92) .multiplyScalar(1 - 0.78 * nightAmt); @@ -1039,7 +1066,11 @@ export function createSkyFx(o = {}) { original.fog.far = original.fogFar; } } - if (sun) sun.intensity = original.sun; + if (sun) { + sun.intensity = original.sun; + if (original.sunColor) sun.color.copy(original.sunColor); + if (sun.shadow) sun.shadow.radius = original.sunRadius; + } if (hemi) hemi.intensity = original.hemi; rain.dispose(); hail.dispose(); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 9afb5b7..0a1c262 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -193,6 +193,40 @@ export default async function run(t) { sky.dispose(); }); + // SPRINT13 gate 2.1 — the storm grade. The QA sighting: 65 km/h + driving + // rain under a noon-blue sky with razor midday shadows. Cause: sky and sun + // both multiplied the weather by the author's palette (`darkness`, 0.5 on + // the southerly), so the dial could zero the weather's say — and nothing + // touched shadow softness at all. Pins: the grade floors the darkness dial, + // the sun dims/goes slate/softens, and dispose hands all of it back. + t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x9fc4e8); + const camera = new THREE.PerspectiveCamera(); + const sun = new THREE.DirectionalLight(0xfff2dc, 2.0); + const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius }; + // a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain — the QA scene + const gale = { + seed: 1, def: { sky: { darkness: 0.5 } }, + sample: (p, t2, o) => o.set(18, 0, 0), + speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30, + gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, + }; + const sky = createSkyFx({ scene, camera, wind: gale, sun }); + fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {})); + assert(sky.stormGrade > 0.7, + `grade ${sky.stormGrade} — the darkness dial still zeroes the weather (the old formula reads 0.50 here)`); + assert(sun.intensity < before.sun * 0.5, + `sun at ${sun.intensity.toFixed(2)} of ${before.sun} — still noon at 65 km/h`); + assert(sun.shadow.radius > 4, + `shadow radius ${sun.shadow.radius} — razor-sharp midday shadows in a gale`); + assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth'); + sky.dispose(); + assert(sun.intensity === before.sun && sun.color.getHex() === before.color + && sun.shadow.radius === before.radius, + 'dispose did not hand the sun back exactly (intensity / colour / shadow radius)'); + }); + // --- SPRINT2 §Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer