skyfx: the change announces itself — a front wall in the sky (gate 3.4)
Every windchange event gets an in-world tell: a dark bank standing on the horizon in the quarter the new wind comes FROM, rising across the 12 s before the change and clearing after the swing, plus a low distant rumble layer in the audio. Soft-edged via vertex alpha (the hard band read as a floating rectangle on the bench). Deterministic off (dt, t): progress is a pure function of t, azimuth comes from dirAt at a fixed post-change instant. Night 2 teaches the tell at ~18 s; night 3 shows the same wall at t=6, which is 'looks like night 2 and isn't' made visible. dev_skyfx.html: the skyfx bench — one storm, bare ground, scrubbable clock, so the tell can be judged by eye without playing to night 3. Two new asserts in c.test.js (window, monotonic rise, direction, mesh orientation, changeless storm stays clear, bit-exact determinism). Selftest 322/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
375e7a29ce
commit
e5c0e3ffee
100
web/world/dev_skyfx.html
Normal file
100
web/world/dev_skyfx.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
dev_skyfx — Lane C's storm viewer. [SPRINT12]
|
||||
|
||||
The skyfx bench: one storm, a bare ground plane, and the whole sky stack
|
||||
(dome, rain, hail, lightning, change front, audio) with a scrubbable clock.
|
||||
Exists so "does the change ANNOUNCE itself" (gate 3.4) can be judged by eye
|
||||
in ten seconds instead of playing to night 3 — D, this is for you: load the
|
||||
early buster, watch the southern sky from t=0, and say whether you'd have
|
||||
known the change was coming without reading a number.
|
||||
|
||||
dev_skyfx.html ← storm_03b_earlybuster
|
||||
dev_skyfx.html?storm=storm_03_southerly&t=12 ← night 2's slower version
|
||||
|
||||
keys: space pause · ] +5 s · [ −5 s (forward-clean; rewinds may re-flash)
|
||||
←/→ turn · click once to unlock audio
|
||||
The VIEW runs on rAF (it's a viewer); the sim is stepped at FIXED_DT
|
||||
accumulated, same as the game, so what you see is what ships.
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>dev_skyfx</title>
|
||||
<style>
|
||||
body { margin:0; background:#000; overflow:hidden; }
|
||||
#hud { position:fixed; top:10px; left:12px; color:#cde; font:12px/1.5 ui-monospace,Menlo,monospace;
|
||||
text-shadow:0 1px 2px #000; white-space:pre; pointer-events:none; }
|
||||
</style>
|
||||
<div id="hud">loading…</div>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { FIXED_DT } from './js/contracts.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const stormName = q.get('storm') || 'storm_03b_earlybuster';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 400);
|
||||
camera.position.set(0, 1.7, -4);
|
||||
let yaw = Math.PI; // start looking toward +Z — the south
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||||
sun.position.set(30, 50, -20);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.2);
|
||||
scene.add(hemi);
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2),
|
||||
new THREE.MeshLambertMaterial({ color: 0x4a5d3a }),
|
||||
);
|
||||
scene.add(ground);
|
||||
// a fence-height slab to the north, so there's a yard-ish thing in frame
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(14, 3, 1), new THREE.MeshLambertMaterial({ color: 0x8a7f70 }));
|
||||
slab.position.set(0, 1.5, -11);
|
||||
scene.add(slab);
|
||||
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
|
||||
|
||||
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') paused = !paused;
|
||||
if (e.key === ']') t = Math.min(def.duration, t + 5);
|
||||
if (e.key === '[') t = Math.max(0, t - 5);
|
||||
if (e.key === 'ArrowLeft') yaw += 0.15;
|
||||
if (e.key === 'ArrowRight') yaw -= 0.15;
|
||||
});
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
const hud = document.getElementById('hud');
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
const wall = Math.min(0.1, (now - last) / 1000); last = now;
|
||||
if (!paused && t < def.duration) {
|
||||
acc += wall;
|
||||
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
|
||||
}
|
||||
camera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
const ch = (def.events || []).find((e) => e.type === 'windchange');
|
||||
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
|
||||
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
|
||||
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
|
||||
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user