diff --git a/web/world/dev_skyfx.html b/web/world/dev_skyfx.html new file mode 100644 index 0000000..6e71b2b --- /dev/null +++ b/web/world/dev_skyfx.html @@ -0,0 +1,100 @@ + + + +dev_skyfx + +
loading…
+ + diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 10e74c9..2b0db1e 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -13,7 +13,7 @@ import * as THREE from '../vendor/three.module.js'; import { rng } from './contracts.js'; -import { valueNoise2 , hailBlockFor } from './weather.core.js'; +import { valueNoise2 , hailBlockFor, smoothstep } from './weather.core.js'; const lerp = (a, b, k) => a + (b - a) * k; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -348,6 +348,7 @@ function createAudio(seed = 1) { let rainGain, rainFilter; let gustGain, gustFilter; let hailGain, hailFilter, drumGain, drumFilter; + let frontGain, frontFilter; let noiseBuf = null; let creakNext = 0, flogNext = 0; let started = false; @@ -444,6 +445,14 @@ function createAudio(seed = 1) { drumGain.connect(master); loop(noiseBuf, drumGain, drumFilter); + // the change front's distant rumble: very low, very slow — a storm you + // can hear over the fence but not yet feel (SPRINT12 gate 3.4) + frontGain = ctx.createGain(); frontGain.gain.value = 0; + frontFilter = ctx.createBiquadFilter(); + frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85; + frontGain.connect(master); + loop(noiseBuf, frontGain, frontFilter); + started = true; }, @@ -496,6 +505,13 @@ function createAudio(seed = 1) { drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2); }, + /** @param {number} level 0..1 change-front progress — the approaching rumble. */ + setFront(level) { + if (!started) return; + // slow time-constant on purpose: a front swells, it doesn't tick + frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5); + }, + /** Telegraph cue: you hear it coming before you feel it. */ whoosh(power, eta) { if (!started) return; @@ -604,6 +620,66 @@ export function createSkyFx(o = {}) { dome.renderOrder = -1; if (scene) scene.add(dome); + // ------------------------------------------------------ the change front + // SPRINT12 gate 3.4 — "the change announces itself." Every windchange event + // gets an IN-WORLD tell: a dark front wall on the horizon, standing in the + // quarter the new wind will come FROM, rising from a smudge to a wall across + // the FRONT_LEAD seconds before the change and clearing overhead after it. + // A real southerly buster looks exactly like this — the shelf cloud arrives + // before the wind does — so the tell is weather, not UI. + // + // Why this telegraphs without spoiling: it's a continuous growth with no + // discrete pop, so a player watching the YARD learns "that wall means the + // swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) — and on + // night 3 the SAME wall is already standing at t≈6, which is the corner + // block's whole thesis ("looks like night 2 and isn't") made visible. The + // exact moment stays the storm's secret; the direction and the approach do + // not, because in the real sky they never are. + // + // Deterministic off (dt, t) like everything here: progress is a pure + // function of t, and the wall's azimuth comes from dirAt() sampled at a + // fixed post-change instant — def + seed in, same wall out, every run. + const FRONT_LEAD = 12; // s before the change the wall starts rising + const FRONT_FADE = 8; // s after the swing completes for it to clear + const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°) + const FRONT_MAX_OP = 0.85; + const frontEvents = (def.events || []) + .filter((e) => e.type === 'windchange' && Number.isFinite(e.t)) + .map((e) => ({ t0: e.t, over: e.over ?? 6, az: null })); + // Band from ~34° above the horizon down to it, centred (in local space) on + // azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at + // atan2(z,x) = π. rotation.y = π − A then carries the centre to azimuth A. + const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20); + { + // Soft edges via vertex alpha, or the band reads as a floating rectangle + // (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly + // that). Full at the horizon and the arc's centre; fading to nothing at + // the arc ends and across the top, so it sits ON the horizon like a bank + // of weather instead of hanging in the sky like a screen. + // Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the + // horizon edge. + const uv = frontGeo.attributes.uv; + const rgba = new Float32Array(uv.count * 4); + for (let i = 0; i < uv.count; i++) { + const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75); + const up = 1 - smoothstep(0.45, 1, uv.getY(i)); + rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1; + rgba[i * 4 + 3] = across * up; + } + frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4)); + } + const frontMesh = new THREE.Mesh( + frontGeo, + new THREE.MeshBasicMaterial({ + color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true, + opacity: 0, depthWrite: false, fog: false, + }), + ); + frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay + frontMesh.visible = false; + if (scene) scene.add(frontMesh); + let frontLevel = 0, frontRise = 0, frontAz = null; + // Remember what world.js handed us, so dispose() puts it back exactly. // Fog is captured BY VALUE, not by reference: step() mutates that very object // in place, so `scene.fog = original.fog` restores the object we just spent a @@ -636,10 +712,15 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, shadow, hailShadow, + rain, audio, dome, shadow, hailShadow, front: frontMesh, get flash() { return flash; }, /** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, + /** 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 + * comes FROM — or null while no front is up. For D's judging and asserts. */ + get changeFrontAz() { return frontAz; }, /** * 0..1 of a ground rect the sail is keeping dry, right now. @@ -809,6 +890,23 @@ export function createSkyFx(o = {}) { hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl); } + // --- the change front: progress is pure in t; the view applies it below. + // Computed above the render gate so a headless HUD (or a test) can read + // changeFront the same way it reads hailAmount. + frontLevel = 0; frontRise = 0; frontAz = null; + for (const ev of frontEvents) { + const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t); + const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t)); + if (lvl > frontLevel) { + // the settled post-change heading, sampled at a FIXED instant so the + // wall doesn't wander with the gusts; +π = the quarter it comes FROM + if (ev.az == null && typeof wind.dirAt === 'function') { + ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI; + } + frontLevel = lvl; frontRise = rise; frontAz = ev.az; + } + } + // 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. @@ -848,6 +946,19 @@ 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; + // --- the front wall (progress computed above the render gate) --- + // Grows upward as it rises (scale.y keys the RISE, so it keeps its full + // height while fading through the overhead pass), darkens as it comes. + // Deliberately a silhouette: no flash tint — lightning brightens the sky + // BEHIND it, which is what makes a shelf cloud read as a wall. + frontMesh.visible = frontLevel > 0.002; + if (frontMesh.visible) { + frontMesh.position.copy(camPos); + if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz; + frontMesh.scale.y = 0.3 + 0.7 * frontRise; + frontMesh.material.opacity = frontLevel * FRONT_MAX_OP; + } + // --- rain + hail drops (the grids they read were built above) --- rain.step(dt, camPos, w, intensity, shadow); hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow); @@ -858,6 +969,9 @@ export function createSkyFx(o = {}) { // --- audio --- audio.setLevels(speed, intensity); audio.setHail(hailAmt, onCloth, stone); + // the front carries its own distant rumble — you HEAR the change coming + // the way you see it, and both are the same pure function of t + audio.setFront(frontLevel); const tg = wind.gustTelegraph(t); if (tg && tg !== lastTelegraph) { // fires once per gust, right as the telegraph opens @@ -886,6 +1000,7 @@ export function createSkyFx(o = {}) { scene.remove(rain.mesh); scene.remove(hail.mesh); scene.remove(dome); + scene.remove(frontMesh); scene.background = original.background; if (ownsFog) { scene.fog = null; // we brought it; we take it @@ -902,6 +1017,8 @@ export function createSkyFx(o = {}) { hail.dispose(); dome.geometry.dispose(); dome.material.dispose(); + frontMesh.geometry.dispose(); + frontMesh.material.dispose(); domeTex.dispose(); audio.dispose(); }, diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index d9bd456..ee6a125 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -277,6 +277,92 @@ export default async function run(t) { assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`); }); + // --- SPRINT12 gate 3.4: the change announces itself --- + // The corner block's thesis is "looks like night 2 and isn't" — storm_03b's + // change lands at 18 s, not 30. The tell is a dark front wall standing in the + // quarter the new wind comes FROM, rising across the 12 s before the change + // and clearing after the swing. These asserts pin: the window (nothing before + // the lead opens, gone after the fade), the rise (monotonic, reaches ~full), + // the DIRECTION (the wall stands in the southern sky — +Z, the open fence + // side — and the mesh really is rotated there), and that a changeless storm + // never raises it. D judges whether it READS; this pins that it exists, + // where it stands, and when. + t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => { + const scene = new THREE.Scene(); + const wind = createWind(storms.storm_03b_earlybuster); + const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind }); + const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange'); + const t0 = ev.t, over = ev.over ?? 6; // 18, 6 + + let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false; + fixedLoop(40, FIXED_DT, (dt, time) => { + sky.step(dt, time, {}); + const f = sky.changeFront; + assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`); + if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`); + if (time > t0 - 12 && time <= t0) { + assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} → ${f.toFixed(3)} at t=${time.toFixed(2)}`); + prev = f; + if (f > peak) peak = f; + } + if (Math.abs(time - t0) < FIXED_DT) { + azAtChange = sky.changeFrontAz; + rotAtChange = sky.front.rotation.y; + opAtChange = sky.front.material.opacity; + visAtChange = sky.front.visible; + } + if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`); + }); + + assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`); + assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`); + + // direction: the wall stands where the new wind comes FROM. dirAt is the + // heading the wind blows TOWARD, so from = settled post-change dir + π. + const expected = wind.dirAt(t0 + over + 4) + Math.PI; + assert(Math.abs(azAtChange - expected) < 1e-9, + `front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`); + // and that quarter is the SOUTH: the buster blows toward -Z (the house), + // so it comes from +Z, the open fence side. sin(az) is the from-vector's z. + assert(Math.sin(azAtChange) > 0.3, + `the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`); + // the mesh is really rotated there (local band centre sits at azimuth π, + // so rotation.y = π − az carries it to az; see skyfx geometry note) + assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9, + `wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`); + sky.dispose(); + }); + + t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => { + // storm_03 (night 2): same wall, later — up but not full at t=20 (change 30) + const mk = (name) => createSkyFx({ + scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(), + wind: createWind(storms[name]), + }); + const a = mk('storm_03_southerly'), b = mk('storm_03_southerly'); + let mid = 0; + fixedLoop(21, FIXED_DT, (dt, time) => { + a.step(dt, time, {}); b.step(dt, time, {}); + // determinism: two instances at the same t agree to the bit — no hidden + // clock, no per-instance random draw in the tell + assert(a.changeFront === b.changeFront, + `two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`); + mid = a.changeFront; + }); + assert(mid > 0.05 && mid < 0.95, + `night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`); + a.dispose(); b.dispose(); + + // the gentle day has no windchange: no wall, ever + const calm = mk('storm_01_gentle'); + fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => { + calm.step(dt, time, {}); + assert(calm.changeFront === 0 && !calm.front.visible, + `a changeless storm raised the front at t=${time.toFixed(2)}`); + }); + calm.dispose(); + }); + // --- SPRINT5 decision 13: hail makes the garden score respond to the rig --- // The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig, // because honest rain walks under a sail. Steep hail doesn't — a sail over the