debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that spikes a corner is the one that launches the neighbour's bin. Ground bounce via world.heightAt (contracts.js documents it as ours), sphere-vs-player knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it lights up when Lane B exposes nodes and stays silent until then. skyfx.js: instanced rain that wraps around the camera rather than respawning, storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers (wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst corner, flog when one blows). It modulates Lane A's lights and hands them back on dispose() rather than owning them. weather_demo.html: graybox bench to drive all three before M0 — mock sail, storm scrub, 4x, throw-a-crate. Aligned to contracts.js now that it has landed: relative three imports (there is no importmap), contracts' rng() instead of a local copy, and storm paths resolved off import.meta.url — server.py serves the repo root, so an absolute /world/... would have 404'd at integration. storm_02: fix the southerly change. contracts.js puts north at -Z and the wind vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW buster off the open side of the yard, into the house. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
10 KiB
HTML
256 lines
10 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>SHADES — Lane C — weather bench</title>
|
||
<style>
|
||
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; overflow:hidden; background:#000;
|
||
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
|
||
canvas { display:block; }
|
||
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||
border:1px solid #26263a; z-index:3; min-width:250px; }
|
||
#hud b { color:var(--gold); }
|
||
#hud .warn { color:#ff6; font-weight:bold; }
|
||
#hud .bad { color:#f66; font-weight:bold; }
|
||
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
|
||
border:1px solid #26263a; z-index:3; }
|
||
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
|
||
padding:4px 9px; cursor:pointer; }
|
||
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
|
||
#ctl input[type=range] { width:220px; vertical-align:middle; }
|
||
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
|
||
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
|
||
.bar i { display:block; height:100%; background:var(--neon); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="c"></canvas>
|
||
<div id="hud"></div>
|
||
<div id="note">
|
||
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
|
||
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
|
||
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
|
||
<br><br>drag = orbit · click = start audio
|
||
</div>
|
||
<div id="ctl"></div>
|
||
|
||
<script type="module">
|
||
import * as THREE from './vendor/three.module.js';
|
||
import { loadStorm, createWind } from './js/weather.js';
|
||
import { createSkyFx } from './js/skyfx.js';
|
||
import { createDebris } from './js/debris.js';
|
||
|
||
const canvas = document.getElementById('c');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
|
||
renderer.shadowMap.enabled = true;
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x9fc4e8);
|
||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
|
||
|
||
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
|
||
const ground = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(30, 20),
|
||
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
|
||
);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.receiveShadow = true;
|
||
scene.add(ground);
|
||
|
||
const TREES = [{ x: -9, z: 4 }, { x: 8, z: -5 }];
|
||
for (const tr of TREES) {
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
|
||
);
|
||
trunk.position.set(tr.x, 2, tr.z);
|
||
trunk.castShadow = true;
|
||
scene.add(trunk);
|
||
const canopy = new THREE.Mesh(
|
||
new THREE.SphereGeometry(3, 12, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
|
||
);
|
||
canopy.position.set(tr.x, 5, tr.z);
|
||
canopy.castShadow = true;
|
||
scene.add(canopy);
|
||
}
|
||
// house edge along north, for scale
|
||
const house = new THREE.Mesh(
|
||
new THREE.BoxGeometry(30, 3.2, 1),
|
||
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
|
||
);
|
||
house.position.set(0, 1.6, -10.5);
|
||
scene.add(house);
|
||
// 1.7 m reference person
|
||
const ref = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
|
||
);
|
||
ref.position.set(2, 0.85, 2);
|
||
ref.castShadow = true;
|
||
scene.add(ref);
|
||
const player = { pos: ref.position, carrying: null, busy: false };
|
||
|
||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
|
||
sun.position.set(-12, 18, 6);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.set(1024, 1024);
|
||
scene.add(sun);
|
||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
|
||
scene.add(hemi);
|
||
|
||
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
|
||
// debris shove it and drive the creak/flog audio off corner loads.
|
||
const N = 9;
|
||
const nodes = [];
|
||
for (let v = 0; v < N; v++) {
|
||
for (let u = 0; u < N; u++) {
|
||
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
|
||
}
|
||
}
|
||
const sailGeo = new THREE.BufferGeometry();
|
||
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
|
||
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
|
||
scene.add(sailPts);
|
||
const mockSail = {
|
||
nodes,
|
||
corners: [
|
||
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||
],
|
||
};
|
||
|
||
// --- weather ---
|
||
const params = new URLSearchParams(location.search);
|
||
const stormName = params.get('storm') || 'storm_02_wildnight';
|
||
const def = await loadStorm(stormName);
|
||
const wind = createWind(def);
|
||
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
|
||
|
||
const ticker = [];
|
||
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
|
||
const debris = createDebris({
|
||
wind, scene, player,
|
||
onEvent: (s) => ticker.unshift(s),
|
||
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
|
||
});
|
||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
|
||
|
||
// --- controls ---
|
||
let t = 0, playing = true, rate = 1;
|
||
const ctl = document.getElementById('ctl');
|
||
ctl.innerHTML = `
|
||
<button id="play">pause</button>
|
||
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
|
||
<button id="reset">reset</button>
|
||
<button id="break">break a corner</button>
|
||
<button id="crate">throw a crate</button>
|
||
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
|
||
`;
|
||
const $ = (id) => document.getElementById(id);
|
||
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
|
||
$('r1').onclick = () => { rate = 1; };
|
||
$('r4').onclick = () => { rate = 4; };
|
||
$('r0').onclick = () => { rate = 0.25; };
|
||
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
|
||
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
|
||
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
|
||
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
|
||
|
||
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
|
||
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
|
||
addEventListener('pointerup', () => { dragging = false; });
|
||
addEventListener('pointermove', (e) => {
|
||
if (!dragging) return;
|
||
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
|
||
lx = e.clientX; ly = e.clientY;
|
||
});
|
||
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
|
||
|
||
function resize() {
|
||
const w = innerWidth, h = innerHeight;
|
||
renderer.setSize(w, h, false);
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
addEventListener('resize', resize); resize();
|
||
|
||
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
|
||
const DT = 1 / 60;
|
||
let acc = 0, last = performance.now();
|
||
const hud = document.getElementById('hud');
|
||
const probe = new THREE.Vector3();
|
||
const w = new THREE.Vector3();
|
||
const posAttr = sailGeo.getAttribute('position');
|
||
|
||
function frame(now) {
|
||
const real = Math.min(0.1, (now - last) / 1000);
|
||
last = now;
|
||
if (playing) acc += real * rate;
|
||
|
||
while (acc >= DT) {
|
||
acc -= DT;
|
||
t += DT;
|
||
if (t > def.duration) t = 0;
|
||
|
||
// mock cloth: nodes just bob with local wind so debris has something to hit
|
||
for (const n of nodes) {
|
||
probe.set(n.x, n.y, n.z);
|
||
wind.sample(probe, t, w);
|
||
const sp = Math.hypot(w.x, w.z);
|
||
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
|
||
}
|
||
// mock loads so the creak layer has something to track
|
||
probe.set(0, 3.2, 0);
|
||
const sp = wind.speedAt(probe, t);
|
||
mockSail.corners.forEach((c, i) => {
|
||
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
|
||
});
|
||
|
||
debris.step(DT, t, { player, sail: mockSail });
|
||
sky.step(DT, t, { sail: mockSail });
|
||
}
|
||
|
||
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
|
||
posAttr.needsUpdate = true;
|
||
|
||
camera.position.set(
|
||
Math.sin(yaw) * Math.cos(pitch) * dist,
|
||
Math.sin(pitch) * dist + 1.5,
|
||
Math.cos(yaw) * Math.cos(pitch) * dist,
|
||
);
|
||
camera.lookAt(0, 2, 0);
|
||
|
||
$('scrub').value = t.toFixed(1);
|
||
probe.set(0, 1.7, 0);
|
||
wind.sample(probe, t, w);
|
||
const speed = Math.hypot(w.x, w.z);
|
||
const tg = wind.gustTelegraph(t);
|
||
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
|
||
hud.innerHTML = `
|
||
<div><b>${def.name}</b> — ${stormName}</div>
|
||
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
|
||
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
|
||
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
|
||
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
|
||
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
|
||
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? 'on' : '(click)'}</div>
|
||
<div>flash ${sky.flash.toFixed(2)}</div>
|
||
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div> </div>'}
|
||
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
|
||
`;
|
||
|
||
renderer.render(scene, camera);
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
|
||
</script>
|
||
</body>
|
||
</html>
|