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