Add prep-phase picking UI over RiggingSession (unblocks A step 8)
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>
This commit is contained in:
parent
7e8ffa307c
commit
cb557a1d6f
160
web/world/dev_rigging.html
Normal file
160
web/world/dev_rigging.html
Normal file
@ -0,0 +1,160 @@
|
||||
<!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>
|
||||
@ -147,11 +147,262 @@ export class RiggingSession {
|
||||
/**
|
||||
* Prep-phase picking UI.
|
||||
*
|
||||
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
|
||||
* anchor markers to raycast against, none of which exist yet. RiggingSession
|
||||
* above holds all the rules and is fully tested, so this stays a thin
|
||||
* click-to-session adapter once M0 lands. See THREADS.md.
|
||||
* Everything above is the rules; this is only the hands. 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 rather than to the terrain.
|
||||
*
|
||||
* Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
|
||||
* shift-LMB 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 belongs to Lane
|
||||
* A's phase machine, which calls commit() on the way out of prep.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object} o.scene THREE.Scene to hang markers in
|
||||
* @param {object} o.camera cameraRig.object — what we raycast from
|
||||
* @param {Element} o.domElement renderer.domElement — where clicks land
|
||||
* @param {object} o.world needs world.anchors
|
||||
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
|
||||
* @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
|
||||
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
|
||||
*/
|
||||
export async function createRiggingUI() {
|
||||
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
|
||||
export async function createRiggingUI({
|
||||
scene, camera, domElement, world,
|
||||
onCommit, onMessage = () => {}, panel = true,
|
||||
} = {}) {
|
||||
const THREE = await import('../vendor/three.module.js');
|
||||
const session = new RiggingSession({ anchors: world.anchors });
|
||||
|
||||
// --- markers -----------------------------------------------------------
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
scene.add(group);
|
||||
|
||||
const DIM = 0x33424c;
|
||||
const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
|
||||
const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
|
||||
// What you click is NOT what you see: the ring's tube is 5 cm, which at yard
|
||||
// distance is a couple of pixels and unhittable. Pick against an invisible
|
||||
// sphere big enough to mean "that anchor" and let the ring just be the read.
|
||||
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
|
||||
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
|
||||
|
||||
const markers = world.anchors.map((a) => {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
|
||||
const ring = new THREE.Mesh(ringGeo, mat);
|
||||
const dot = new THREE.Mesh(dotGeo, mat);
|
||||
const hit = new THREE.Mesh(pickGeo, pickMat);
|
||||
hit.userData.anchorId = a.id;
|
||||
const holder = new THREE.Group();
|
||||
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
|
||||
group.add(holder);
|
||||
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
|
||||
});
|
||||
const pickTargets = markers.map((m) => m.hit);
|
||||
|
||||
// --- quad preview ------------------------------------------------------
|
||||
// A closed loop through the ring-ordered picks: this is the shape you are
|
||||
// about to build, drawn before you commit to it.
|
||||
const previewGeo = new THREE.BufferGeometry();
|
||||
previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
|
||||
const preview = new THREE.Line(
|
||||
previewGeo,
|
||||
new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
|
||||
);
|
||||
preview.frustumCulled = false;
|
||||
group.add(preview);
|
||||
|
||||
// --- panel -------------------------------------------------------------
|
||||
const el = panel ? document.createElement('div') : null;
|
||||
if (el) {
|
||||
el.id = 'rigging-panel';
|
||||
el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
|
||||
background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
|
||||
font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
|
||||
white-space:pre;pointer-events:none`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
let active = false;
|
||||
let hovered = null;
|
||||
|
||||
const ndc = new THREE.Vector2();
|
||||
const ray = new THREE.Raycaster();
|
||||
const scratch = new THREE.Vector3();
|
||||
|
||||
function pickAt(ev) {
|
||||
const r = domElement.getBoundingClientRect();
|
||||
ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
|
||||
ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
|
||||
ray.setFromCamera(ndc, camera);
|
||||
return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
|
||||
}
|
||||
|
||||
function say(result) {
|
||||
if (result && result.ok === false) onMessage(result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
function onPointerDown(ev) {
|
||||
if (!active || ev.button !== 0) return; // RMB is the camera's
|
||||
const id = pickAt(ev);
|
||||
if (!id) return;
|
||||
ev.preventDefault();
|
||||
if (!session.isRigged(id)) say(session.rig(id));
|
||||
else if (ev.shiftKey) say(session.unrig(id));
|
||||
else say(session.cycleHardware(id));
|
||||
refresh();
|
||||
}
|
||||
|
||||
function onPointerMove(ev) {
|
||||
if (!active) return;
|
||||
hovered = pickAt(ev);
|
||||
domElement.style.cursor = hovered ? 'pointer' : '';
|
||||
}
|
||||
|
||||
function onKeyDown(ev) {
|
||||
if (!active) return;
|
||||
if (ev.key === '[') session.setTension(session.tension - 0.05);
|
||||
else if (ev.key === ']') session.setTension(session.tension + 0.05);
|
||||
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
refresh();
|
||||
}
|
||||
|
||||
domElement.addEventListener('pointerdown', onPointerDown);
|
||||
domElement.addEventListener('pointermove', onPointerMove);
|
||||
addEventListener('keydown', onKeyDown);
|
||||
|
||||
/** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
|
||||
function quadArea() {
|
||||
if (session.picks.length !== MAX_CORNERS) return 0;
|
||||
const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
|
||||
const tri = (a, b, c) =>
|
||||
new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
|
||||
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!el) return;
|
||||
const s = session.summary;
|
||||
const rows = world.anchors.map((a) => {
|
||||
const pick = session.pickOf(a.id);
|
||||
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)} —`;
|
||||
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
|
||||
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
|
||||
});
|
||||
const area = quadArea();
|
||||
el.textContent = [
|
||||
`PREP — rig four corners $${s.budget} left`,
|
||||
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
|
||||
'',
|
||||
...rows,
|
||||
'',
|
||||
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
|
||||
'click anchor: rig / cycle hw shift-click: remove',
|
||||
'[ ] tension S spare RMB orbit',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const ui = {
|
||||
session,
|
||||
get summary() { return { ...session.summary, area: quadArea() }; },
|
||||
get canStart() { return session.canStart; },
|
||||
get active() { return active; },
|
||||
|
||||
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
|
||||
setActive(on) {
|
||||
active = !!on;
|
||||
group.visible = active;
|
||||
if (el) el.style.display = active ? 'block' : 'none';
|
||||
if (!active) domElement.style.cursor = '';
|
||||
if (active) refresh();
|
||||
return ui;
|
||||
},
|
||||
|
||||
/** Markers ride the anchors, so a tree corner wanders before you even rig it. */
|
||||
update(dt, t) {
|
||||
if (!active) return;
|
||||
for (const m of markers) {
|
||||
const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
|
||||
m.holder.position.set(p.x, p.y, p.z);
|
||||
m.holder.quaternion.copy(camera.quaternion); // rings face the player
|
||||
const pick = session.pickOf(m.anchor.id);
|
||||
m.mat.color.setHex(pick ? pick.hw.color : DIM);
|
||||
const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
|
||||
m.ring.scale.setScalar(s);
|
||||
m.label.visible = !!pick || hovered === m.anchor.id;
|
||||
}
|
||||
|
||||
const pos = previewGeo.attributes.position;
|
||||
if (session.picks.length >= 2) {
|
||||
preview.visible = true;
|
||||
const n = session.picks.length;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const k = session.picks[i % n];
|
||||
const a = world.anchors.find((x) => x.id === k.anchorId);
|
||||
const p = a.sway ? a.sway(t) : a.pos;
|
||||
scratch.set(p.x, p.y, p.z);
|
||||
pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||||
}
|
||||
// degenerate tail so a partial pick doesn't draw a stale segment
|
||||
for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||||
pos.needsUpdate = true;
|
||||
previewGeo.setDrawRange(0, session.picks.length + 1);
|
||||
preview.computeLineDistances();
|
||||
} else {
|
||||
preview.visible = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
|
||||
commit() {
|
||||
if (!session.canStart) {
|
||||
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
|
||||
return false;
|
||||
}
|
||||
onCommit(
|
||||
session.picks.map((p) => p.anchorId),
|
||||
session.picks.map((p) => p.hw),
|
||||
session.tension,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
domElement.removeEventListener('pointerdown', onPointerDown);
|
||||
domElement.removeEventListener('pointermove', onPointerMove);
|
||||
removeEventListener('keydown', onKeyDown);
|
||||
scene.remove(group);
|
||||
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
|
||||
preview.material.dispose();
|
||||
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
|
||||
el?.remove();
|
||||
},
|
||||
};
|
||||
|
||||
refresh();
|
||||
return ui;
|
||||
}
|
||||
|
||||
/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
|
||||
function makeLabel(THREE, text) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 128; c.height = 64;
|
||||
const g = c.getContext('2d');
|
||||
g.font = 'bold 40px ui-monospace, Menlo, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.lineWidth = 6;
|
||||
g.strokeStyle = '#0d1418';
|
||||
g.strokeText(text, 64, 32);
|
||||
g.fillStyle = '#dde5ea';
|
||||
g.fillText(text, 64, 32);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
|
||||
}));
|
||||
sprite.position.set(0, 0.55, 0);
|
||||
sprite.scale.set(0.8, 0.4, 1);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user