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>
204 lines
8.9 KiB
HTML
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>
|