HardYards/web/world/weather_demo.html
m3ultra 383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
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>
2026-07-16 21:38:21 +10:00

256 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>&nbsp;</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>