diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 2798a4e..e9ecd7c 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -702,10 +702,22 @@ export function createSkyFx(o = {}) { * @param {number} dt * @param {number} t storm time * @param {object} [world] {sail} — duck-typed, for creak/flog + * + * Runs headless. The camera is a RENDERING concern and must never gate the + * shadow grids — see the note at the physics/view split below. */ step(dt, t, world = {}) { - if (!camera) return; - camera.getWorldPosition(camPos); + // A camera means "there is a view to place things in", not "the weather is + // real". This used to read `if (!camera) return;` at the top, which meant a + // harness with no camera silently skipped the shadow rebuild and then + // scored every rig as if the sail did not exist — hp 36 was the bare-bed + // constant, and it cost gate 0 two sprints of four lanes' time before A + // found it. The grids are physics; only the view and the audio are the + // camera's business. Fall back to the yard centre so the wind still has a + // place to be sampled. + const rendering = !!camera; + if (rendering) camera.getWorldPosition(camPos); + else camPos.set(0, 1.7, 0); wind.sample(camPos, t, w); const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); @@ -753,6 +765,37 @@ export function createSkyFx(o = {}) { flash *= Math.max(0, 1 - dt * 7); if (flash < 0.004) flash = 0; + // --- PHYSICS: the shadow grids. Camera or not, always. --- + // Everything that scores a rig — gardenExposure, gardenHailExposure, + // rainShadowOver, hailShadowOver — reads these grids. They are what the + // sail DOES, so they rebuild before any view work and above the render + // gate. Rebuilt a few times a second, not every frame: the cloth moves + // slowly next to the weather, and this is the only part that costs. + shadowTick -= dt; + if (shadowTick <= 0) { + shadowTick = 0.1; + rainVelocity(w, intensity, rainDir); + const rl = rainDir.length() || 1; + shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl); + } + hailAmt = hailIntensity(t); + const stone = hailStone(); + // The hail shadow follows the STEEP hail vector, not the wind. It barely + // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it. + hailTick -= dt; + if (hailTick <= 0) { + hailTick = 0.12; + hailWind.set(w.x, 0, w.z); + hailVelocity(hailWind, hailDir); + const hl = hailDir.length() || 1; + hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl); + } + + // Past here is the VIEW and the NOISE — drops to place, a dome to tint, a + // gale to hear. All of it needs somewhere to stand. A headless harness has + // the numbers it came for and can stop here. + if (!rendering) return; + // --- sky --- skyCol.copy(baseSky).lerp(target, storminess * darkness); if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash)); @@ -787,31 +830,8 @@ export function createSkyFx(o = {}) { domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; - // --- rain --- - // Rebuild the shadow a few times a second, not every frame: the cloth - // moves slowly next to the rain, and this is the only part that costs. - shadowTick -= dt; - if (shadowTick <= 0) { - shadowTick = 0.1; - rainVelocity(w, intensity, rainDir); - const len = rainDir.length() || 1; - shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len); - } + // --- rain + hail drops (the grids they read were built above) --- rain.step(dt, camPos, w, intensity, shadow); - - // --- hail --- - hailAmt = hailIntensity(t); - const stone = hailStone(); - // The hail shadow follows the STEEP hail vector, not the wind. It barely - // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it. - hailTick -= dt; - if (hailTick <= 0) { - hailTick = 0.12; - hailWind.set(w.x, 0, w.z); - hailVelocity(hailWind, hailDir); - const len = hailDir.length() || 1; - hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len); - } hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow); // how much of the hail the sail overhead is catching, for the drum sound — // sample right above the player/camera so it's "is it drumming over ME" diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 63ce0fb..40a0463 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -344,6 +344,46 @@ export default async function run(t) { sky.dispose(); }); + // The trap that cost gate 0 two sprints, asserted so it cannot come back. + // skyfx.step() used to open `if (!camera) return`, so a headless harness + // skipped the shadow rebuild and scored every rig as if the sail weren't + // there. A camera is a view, not a weather system: the grids are physics and + // must build without one. If someone re-adds an early return, this goes red. + t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => { + const bed = { x: 0, z: 0, w: 4, d: 3 }; + const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] }; + + const run = (withCamera) => { + const scene = new THREE.Scene(); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx(withCamera + ? { scene, camera: new THREE.PerspectiveCamera(), wind } + : { scene, wind }); // headless: no camera at all + fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel })); + const out = { + hailShadow: sky.hailShadowOver(bed), + rainShadow: sky.rainShadowOver(bed), + hailExp: sky.gardenHailExposure(bed, 56), + hail: sky.hailAmount, + }; + sky.dispose(); + return out; + }; + + const headless = run(false); + const viewed = run(true); + assert(headless.hailShadow > 0.9, + `no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`); + assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked'); + assert(headless.hailExp < 0.1, + `no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`); + // and a camera must not CHANGE the physics, only add the view + assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9, + `the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`); + assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9, + `the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');