HardYards/web/world/dev_player.html
m3ultra 6382e604b8 Lane D: the small person — player, interactions, selftests
player.sim.js  Deterministic core, zero imports: camera-relative movement, the
               state-machine table, wind slow/gust-shove/knockdown. step(dt,t)
               is the whole clock — no Date.now, no Math.random, no rAF — so a
               storm fast-forwards identically in selftest and in the game.
player.js      The view: rig load per the 90sDJsim DEVMANUAL rig rules
               (SkeletonUtils.clone, height-normalise off the MEASURED head
               bone, canonicalised bone namespace), clip retarget, and
               createPlayer() satisfying the Player contract.
interact.js    Hold-E with radial progress + wireYardActions (re-rig 2.5 s,
               turnbuckle trim 1.2 s, carry-one-item), duck-typed so it no-ops
               cleanly until Lane B lands sailRig.repair/trim.
d.test.js      20 asserts in Lane A's harness: 38 pass / 3 skip overall.

Ported from the 2D prototype's shape (game.js:250-252), retuned to m/s: the
slow curve, and shove gated to gusts only and scaling with speed² so gusts have
teeth. Knockdown needs 0.5 s of SUSTAINED overload — deliberately the same rule
as a sail corner letting go, so cloth and people speak one language.

Two decisions worth the review:
- The knockdown pitches the root in code rather than playing the Falling clip's
  root. Shared clips must drop Hips.quaternion (a different-orientation source
  lays the target flat), so a clip physically cannot lie the body down. Doing it
  in code also lets the fall go DOWNWIND of the gust that caused it, which a
  canned clip could never do, and keeps it deterministic.
- Gust magnitude is recovered from a slow EMA of local wind rather than widening
  Lane C's contract: wind.sample() gives the total and gustTelegraph() only fires
  BEFORE a gust, so nothing reports gust size during the hold. The EMA
  self-calibrates to whatever storm JSON Lane C authors.

Verified in a real scene, not only in asserts: head bone 1.715 m at fig scale
0.983, all six clips bound, walk/run at the tuned speeds, the body lies down and
gets back up, hold-E fires exactly once per press.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:16 +10:00

204 lines
8.9 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>SHADES — Lane D player harness</title>
<style>
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden; font: 12px/1.5 ui-monospace, Menlo, monospace; }
canvas { display: block; }
#hud { position: fixed; top: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000; white-space: pre; pointer-events: none; }
#panel { position: fixed; top: 8px; right: 8px; color: #fff; text-shadow: 0 1px 2px #000; text-align: right; }
#panel button { font: inherit; margin: 1px; }
#panel input { vertical-align: middle; }
#prompt { position: fixed; left: 50%; bottom: 64px; transform: translateX(-50%); color: #fff;
text-shadow: 0 1px 3px #000; font-size: 15px; text-align: center; pointer-events: none; }
#bar { width: 160px; height: 5px; background: #0006; margin: 5px auto 0; border-radius: 3px; overflow: hidden; }
#fill { height: 100%; width: 0; background: #ffd54a; }
</style>
<!--
REQUIRED, don't delete: every vendored addon (GLTFLoader, SkeletonUtils, …) imports from the bare
specifier 'three', so any page that pulls in player.js needs this map. index.html has no importmap
yet because nothing there uses an addon — flagged for Lane A in THREADS.md, since the placeholder
swap will need it too.
-->
<script type="importmap">
{ "imports": { "three": "/world/vendor/three.module.js", "three/addons/": "/world/vendor/addons/" } }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud"></div>
<div id="panel">
wind <input id="wind" type="range" min="0" max="40" step="0.5" value="4"> <span id="wv">4</span> m/s<br>
dir <input id="dir" type="range" min="0" max="6.28" step="0.01" value="0"><br>
<button id="gust">gust (+18, 1.6s)</button>
<button id="knock">knockdown</button>
<button id="stag">stagger</button>
</div>
<div id="prompt"></div>
<!--
Lane D dev harness. NOT the game — Lane A owns index.html, world.js, camera.js, hud.js.
Everything here that isn't player.js / interact.js is a throwaway mock standing in until M0 lands:
the ground, the wind, the camera and the prompt UI. PLAN3D §0 says lanes develop against contracts
+ mocks until Lane A's skeleton merges; this is that mock.
-->
<script type="module">
import * as THREE from 'three';
import { loadPlayer, KeyboardInput, STATES } from '/world/js/player.js';
import { Interact, wireYardActions } from '/world/js/interact.js';
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fa6b6);
scene.fog = new THREE.Fog(0x8fa6b6, 30, 90);
const cam = new THREE.PerspectiveCamera(55, 1, 0.1, 300);
const resize = () => {
renderer.setSize(innerWidth, innerHeight);
cam.aspect = innerWidth / innerHeight; cam.updateProjectionMatrix();
};
addEventListener('resize', resize); resize();
const sun = new THREE.DirectionalLight(0xfff3e0, 2.4);
sun.position.set(-8, 14, 6); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -18, right: 18, top: 18, bottom: -18, near: 1, far: 50 });
scene.add(sun, new THREE.HemisphereLight(0xbfd8e8, 0x4a5a3a, 1.1));
// --- mock yard: 30x20 m, flat. Lane A's world.js replaces this (and gives real terrain height). ---
const ground = new THREE.Mesh(new THREE.PlaneGeometry(30, 20),
new THREE.MeshLambertMaterial({ color: 0x6f8f4e }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(30, 30, 0x33502a, 0x5d7a45);
grid.position.y = 0.01; scene.add(grid);
// scale references: a 4 m sail post and a 1.7 m capsule. PLAN3D §5-D.1 wants the player to read
// "small person" beside a 4 m post — this is how we check that by eye.
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 4, 12),
new THREE.MeshLambertMaterial({ color: 0xcfd4d8 }));
post.position.set(-4, 2, -3); post.castShadow = true; scene.add(post);
const capsule = new THREE.Mesh(new THREE.CapsuleGeometry(0.28, 1.7 - 0.56, 6, 12),
new THREE.MeshLambertMaterial({ color: 0xd08a5a }));
capsule.position.set(-2.6, 0.85, -3); capsule.castShadow = true; scene.add(capsule);
// interact fixtures: a shed table (pick up a spare) and a broken sail corner (re-rig)
const table = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.06, 0.6),
new THREE.MeshLambertMaterial({ color: 0x8a6b45 }));
table.position.set(5, 0.8, 2); table.castShadow = true; scene.add(table);
const cornerPos = new THREE.Vector3(0, 2.6, -5);
const cornerDot = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10),
new THREE.MeshBasicMaterial({ color: 0xff5252 }));
cornerDot.position.copy(cornerPos); scene.add(cornerDot);
// --- mock wind, standing in for Lane C's weather.js wind.sample(pos,t) ---
const windEl = document.getElementById('wind'), dirEl = document.getElementById('dir');
const wv = document.getElementById('wv');
let gustUntil = -1, gustAdd = 0;
const wind = {
sample(_pos, t) {
const base = +windEl.value;
const extra = t < gustUntil ? gustAdd : 0;
const a = +dirEl.value;
const s = base + extra;
return new THREE.Vector3(Math.cos(a) * s, 0, Math.sin(a) * s);
},
};
windEl.oninput = () => { wv.textContent = windEl.value; };
// --- player ---
const { sim, view, step } = await loadPlayer(scene, {
start: { x: 0, y: 0, z: 3 },
height: 1.72,
groundAt: () => 0, // Lane A's world.js supplies the real terrain height
});
const input = new KeyboardInput();
// --- interactions (the real thing: interact.js + wireYardActions) ---
const interact = new Interact();
const corner = { anchorId: 'post_nw', broken: true, load: 0, pos: cornerPos };
wireYardActions(interact, {
sailRig: {
corners: [corner],
repair: () => { corner.broken = false; cornerDot.material.color.set(0x4caf50); },
trim: () => { cornerDot.scale.setScalar(cornerDot.scale.x * 1.08); },
},
world: { shedTable: { pos: table.position } },
});
document.getElementById('gust').onclick = () => { gustAdd = 18; gustUntil = clock.t + 1.6; };
document.getElementById('knock').onclick = () => sim.knockdown(clock.t, +windEl.value ? 1 : 0, 0);
document.getElementById('stag').onclick = () => sim.staggerHit(clock.t);
// --- mock third-person camera. Lane A's camera.js replaces this; RMB-drag orbits. ---
const orbit = { yaw: Math.PI, pitch: 0.28, dist: 6 };
let drag = false;
addEventListener('contextmenu', (e) => e.preventDefault());
addEventListener('mousedown', (e) => { if (e.button === 2) drag = true; });
addEventListener('mouseup', () => { drag = false; });
addEventListener('mousemove', (e) => {
if (!drag) return;
orbit.yaw -= e.movementX * 0.005;
orbit.pitch = Math.max(-0.2, Math.min(1.1, orbit.pitch + e.movementY * 0.004));
});
addEventListener('wheel', (e) => { orbit.dist = Math.max(2.5, Math.min(14, orbit.dist + e.deltaY * 0.01)); });
const hud = document.getElementById('hud');
const promptEl = document.getElementById('prompt');
const DT = 1 / 60;
const clock = { t: 0, acc: 0, last: performance.now() };
function frame(now) {
requestAnimationFrame(frame);
// rAF drives the VIEW; the sim is stepped at a fixed dt so it matches selftest exactly
let elapsed = Math.min(0.25, (now - clock.last) / 1000);
clock.last = now;
clock.acc += elapsed;
while (clock.acc >= DT) {
clock.acc -= DT;
clock.t += DT;
step(DT, clock.t, input.read(orbit.yaw), wind);
interact.step(DT, clock.t, sim, input.holding);
}
// camera: shoulder-follow, orbits on RMB
const h = 1.5;
cam.position.set(
sim.pos.x + Math.sin(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist,
sim.pos.y + h + Math.sin(orbit.pitch) * orbit.dist,
sim.pos.z + Math.cos(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist);
cam.lookAt(sim.pos.x, sim.pos.y + 1.1, sim.pos.z);
const near = interact.nearest(sim);
promptEl.innerHTML = near
? `[E] ${interact.labelOf(near, sim)}<div id="bar"><div id="fill" style="width:${(interact.progress * 100).toFixed(0)}%"></div></div>`
: '';
hud.textContent =
`state ${sim.state}${sim.busy ? ' (busy)' : ''}\n` +
`clip ${STATES[sim.state].clip}\n` +
`speed ${sim.speed.toFixed(2)} m/s\n` +
`pos ${sim.pos.x.toFixed(1)}, ${sim.pos.z.toFixed(1)}\n` +
`wind ${sim.windSpeed.toFixed(1)} m/s (base ${sim.windBase.toFixed(1)}, gust ${sim.gust.toFixed(1)})\n` +
`shove ${Math.hypot(sim.shove.x, sim.shove.z).toFixed(2)} m/s\n` +
`exposure ${sim.exposure.toFixed(2)} / ${sim.tune.knockSustain}\n` +
`pitch ${sim.pitch.toFixed(2)}\n` +
`carrying ${sim.carrying || '—'}\n` +
`bound ${view.clipNames.join(' ')}\n` +
`\nWASD move · shift run · E hold · RMB orbit`;
renderer.render(scene, cam);
}
requestAnimationFrame(frame);
// expose for console poking / screenshot checks
Object.assign(window, { sim, view, interact, scene, cam, orbit, clock, THREE });
</script>
</body>
</html>