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>
101 lines
4.3 KiB
HTML
101 lines
4.3 KiB
HTML
<!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>
|