RiggingSession already held every rule and was tested headless; this is only the hands. Click an anchor to rig it, click again to cycle its hardware, shift-click to pull it off for a full refund, [ and ] for tension, S for the spare. RMB is left alone — that's the camera's orbit. Enter stays Lane A's: the phase machine calls commit(), which goes through main.js's rigSail() door as A asked. It renders its own anchor markers because the yard has none to raycast against — world.js builds posts and trunks, not pick targets — and marker styling is prep-phase UI, so it belongs to this lane. What you click is not what you see: the marker ring's tube is 5 cm, which at yard distance is a couple of pixels and genuinely unhittable. Picking goes against an invisible 0.45 m sphere and the ring is just the read. The panel shows live sail AREA, which SPRINT2 doesn't ask for but decision 2 implies: the 70-192 m² problem is invisible to a player who can't see what they're about to build. Picking the obvious quad (h1/h3/p1/p2) now says "191 m2" before you commit to it. dev_rigging.html follows the house pattern (C's weather_demo, D's dev_player): the picking UI can't be asserted headless — it is clicks, raycasts and materials — and can't run in index.html until A wires step 8. It boots the real world.js, real anchors and real cloth, so what's verified is what ships. Retire it once index.html hosts prep. Verified by hand in it: rig h2 by click, cycle carabiner -> shackle, budget $80 -> $65, weak link flagged, dashed quad preview, commit -> sail in scene (100 verts, 162 tris, casting a real shadow over the garden bed) -> storm with corner loads reading 1.0-1.2 kN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.8 KiB
HTML
161 lines
5.8 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>SHADES — Lane B rigging harness</title>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
|
|
font: 12px/1.5 ui-monospace, Menlo, monospace; }
|
|
canvas { display: block; }
|
|
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
|
|
white-space: pre; pointer-events: none; }
|
|
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
|
|
text-align: right; white-space: pre; pointer-events: none; }
|
|
</style>
|
|
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
|
|
<script type="importmap">
|
|
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<canvas id="c"></canvas>
|
|
<div id="dev"></div>
|
|
<div id="hint">Lane B harness — the prep phase only.
|
|
ENTER commits the rig and starts a storm. R resets to prep.</div>
|
|
|
|
<script type="module">
|
|
/**
|
|
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
|
|
*
|
|
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
|
|
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
|
|
* and it can't be exercised in index.html until Lane A wires it in step 8. This
|
|
* boots the real world.js, the real anchors and the real cloth so the thing
|
|
* being verified is the thing that ships. Retire it once index.html hosts prep.
|
|
*/
|
|
import * as THREE from 'three';
|
|
import { createWorld } from './js/world.js';
|
|
import { createCameraRig } from './js/camera.js';
|
|
import { createWind, loadStorm } from './js/weather.js';
|
|
import { SailRig, createSailView } from './js/sail.js';
|
|
import { createRiggingUI } from './js/rigging.js';
|
|
import { FIXED_DT } from './js/contracts.js';
|
|
|
|
const canvas = document.getElementById('c');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
const [calmDef, wildDef] = await Promise.all([
|
|
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
|
|
]);
|
|
// main.js multiplexes these behind a private router; the harness only ever needs
|
|
// one at a time, so it just swaps the reference when the storm starts.
|
|
const calmWind = createWind(calmDef);
|
|
const wildWind = createWind(wildDef);
|
|
let wind = calmWind;
|
|
const windProxy = {
|
|
sample: (p, t, out) => wind.sample(p, t, out),
|
|
gustTelegraph: (t) => wind.gustTelegraph(t),
|
|
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
|
|
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
|
|
};
|
|
|
|
const world = createWorld(scene, { wind: windProxy });
|
|
const cameraRig = createCameraRig(canvas);
|
|
cameraRig.setSolids(world.solids);
|
|
cameraRig.setGround(world.heightAt);
|
|
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
|
|
|
const rig = new SailRig({ anchors: world.anchors });
|
|
let sailView = null;
|
|
|
|
async function rigSail(anchorIds, hwChoices, tension) {
|
|
rig.attach(anchorIds, hwChoices, tension);
|
|
if (sailView) {
|
|
scene.remove(sailView);
|
|
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
|
}
|
|
sailView = await createSailView(rig);
|
|
scene.add(sailView);
|
|
}
|
|
|
|
let msg = '';
|
|
let msgT = 0;
|
|
const ui = await createRiggingUI({
|
|
scene, camera: cameraRig.object, domElement: canvas, world,
|
|
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
|
|
onMessage: (m) => { msg = m; msgT = 2; },
|
|
});
|
|
|
|
let phase = 'prep';
|
|
ui.setActive(true);
|
|
|
|
// harness-only handle, so a debugger (or an agent driving this page) can pick
|
|
// anchors without hunting for pixels. Never imported by the game.
|
|
window.__laneB = {
|
|
ui, rig, world, cameraRig, scene,
|
|
get phase() { return phase; },
|
|
/** rig by id, exactly as if you'd clicked it */
|
|
pick: (id) => ui.session.rig(id),
|
|
cycle: (id) => ui.session.cycleHardware(id),
|
|
};
|
|
|
|
addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
|
|
if (e.key.toLowerCase() === 'r') {
|
|
phase = 'prep';
|
|
wind = calmWind;
|
|
ui.setActive(true);
|
|
if (sailView) { scene.remove(sailView); sailView = null; }
|
|
}
|
|
});
|
|
|
|
// follow-cam target: the yard centre, so prep reads as a site walk-around
|
|
const focus = new THREE.Vector3(0, 1.2, 0);
|
|
const dev = document.getElementById('dev');
|
|
const clock = new THREE.Clock();
|
|
let t = 0, acc = 0;
|
|
|
|
renderer.setAnimationLoop(() => {
|
|
const raw = Math.min(0.25, clock.getDelta());
|
|
acc += raw;
|
|
while (acc >= FIXED_DT) {
|
|
t += FIXED_DT;
|
|
world.update(FIXED_DT, t);
|
|
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
|
|
acc -= FIXED_DT;
|
|
if (msgT > 0) msgT -= FIXED_DT;
|
|
}
|
|
ui.update(raw, t);
|
|
sailView?.update();
|
|
cameraRig.update(raw, focus);
|
|
|
|
const s = ui.summary;
|
|
const loads = rig.rigged
|
|
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
|
|
: '(not rigged)';
|
|
dev.textContent =
|
|
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
|
|
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
|
|
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
|
|
|
|
renderer.render(scene, cameraRig.object);
|
|
});
|
|
|
|
addEventListener('resize', () => {
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
|
cameraRig.object.updateProjectionMatrix();
|
|
});
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
|
cameraRig.object.updateProjectionMatrix();
|
|
</script>
|
|
</body>
|
|
</html>
|