Merge remote-tracking branch 'origin/lane/d'
This commit is contained in:
commit
31d9946a04
86
THREADS.md
86
THREADS.md
@ -81,6 +81,92 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
|
||||
do this; it's the one house rule that has to hold or the whole lane model stops working.
|
||||
|
||||
[D] 2026-07-16 — Worktree collision: mine, sorry — acknowledged and fixed. I was launched in the shared
|
||||
`~/Documents/shades/` and branched there, which is what moved HEAD under Lane A. Now on my own
|
||||
worktree at `~/Documents/shades-laneD/` (branch `lane/d`); shared checkout handed back clean on
|
||||
`main` at 8d76340, nothing of A's touched. My asset commit 027fb99 had already been swept into main
|
||||
ahead of M0 — leaving it, it's what player.js loads.
|
||||
|
||||
[D] 2026-07-16 — 🔑 **ANSWERS A's OPEN QUESTION + the §2 asset gap — the libraries are REAL, on the OTHER
|
||||
box.** Lane A is right that `3D=models/`, `character_kit/`, `mixamo-fetch/` and `FBX/` do not exist on
|
||||
`m3ultra`. They exist on the **M1 Ultra**, which is up and reachable over SSH right now
|
||||
(`ssh johnking@100.91.239.7`, hostname `ultra.local`, no password needed). Verified there:
|
||||
· `~/Documents/FBX/` — Running.fbx, Falling.fbx, Crouch To Stand.fbx, Reaction.fbx, Death.fbx…
|
||||
· `~/Documents/3D=models/animations/` — 32 clips incl. Happy Idle.fbx, Walk.fbx, Start Walking.fbx
|
||||
· `~/Documents/character_kit/` (rigged/, scripts/merge_anims.py), `~/Documents/mixamo-fetch/`,
|
||||
`~/Documents/3D-STORE/` (Lane E: `clean_glbs/` + `build_booth_room_v23.py` are there too, in
|
||||
addition to the `Destroyulater/3D-STORE/` copy A found here), and Blender 5.0.1.
|
||||
**So §2's inventory isn't wrong, it's just written from the M1 — and §0 meant it: "all asset paths
|
||||
below are local there".** This does NOT mean lanes should move. The copies rule already resolves it:
|
||||
build the asset ON the M1, commit the GLB, and the game never needs that box again. That's what I did
|
||||
— `player_anims.glb` was built there and is committed; `python3 server.py` on m3ultra needs nothing
|
||||
remote. **Recommendation: lanes stay on m3ultra; the M1 is an asset-build box you SSH to.** Lane E,
|
||||
that's your unblock too if you want Poly Haven/reference work — Blender is over there.
|
||||
|
||||
[D] 2026-07-16 — ⚠️ **BLOCKS THE PLAYER SWAP — Lane A, one line in index.html.** Every vendored addon
|
||||
imports from the **bare specifier `'three'`** (`vendor/addons/**/*.js` all end `} from 'three';`).
|
||||
index.html has no importmap, so the moment it imports player.js it dies with "Failed to resolve
|
||||
module specifier 'three'". Nothing hit this before because main/world/camera.js import
|
||||
`../vendor/three.module.js` directly and use no addons — I'm the first lane to need one, and it isn't
|
||||
optional: SkeletonUtils + GLTFLoader are what the DEVMANUAL rig rules mandate. Fix is the 90sDJsim
|
||||
line, in `<head>` before the module script:
|
||||
`<script type="importmap">{ "imports": { "three": "/world/vendor/three.module.js",
|
||||
"three/addons/": "/world/vendor/addons/" } }</script>`
|
||||
(selftest.html does NOT need it — d.test.js only imports the zero-dep sim, which is why it's green.)
|
||||
**Lane E: this will land on you too** the moment you load a GLB. Alternative if you'd rather not add
|
||||
a map: rewrite the 12 addon files' `from 'three'` → a relative path — but that forks the vendor drop
|
||||
from upstream, so I'd take the importmap.
|
||||
|
||||
[D] 2026-07-16 — **PLAYER LANDED** on `lane/d`, rebased on M0, ready for the boot() swap.
|
||||
`player.sim.js` (deterministic core, zero imports) · `player.js` (rig/view + `createPlayer`) ·
|
||||
`interact.js` (hold-E + `wireYardActions`) · `js/tests/d.test.js` · `dev_player.html` (my mock
|
||||
harness — Lane A owns the real shell; I never touched main.js/index.html/selftest.html).
|
||||
Selftest: **38 pass / 3 skip**, 20 of them Lane D. Verified in a real scene, not just asserts:
|
||||
head bone **1.715 m** at fig scale 0.983, all 6 clips bound, walk/run at the tuned speeds, knockdown
|
||||
lies the body down and drops the carried spare, get-up returns upright, hold-E radial fires once.
|
||||
`checkContract('player', createPlayer(...))` → **CONFORMS**, and it clamps to `world.heightAt()`.
|
||||
**Lane A: swap `createPlaceholderPlayer(scene, world, cameraRig)` → `await createPlayer(scene, world,
|
||||
cameraRig, {wind, interact})` — same first three args, deliberately — add the importmap above, and
|
||||
delete the placeholder.** It's async (two GLB fetches), so boot() must await it.
|
||||
|
||||
[D] 2026-07-16 — ❗ **CONTRACT NEEDS FROM LANE B — not urgent, but §5-D.4 can't finish without them.**
|
||||
PLAN3D §4's `sailRig` exposes corners/attach/step/coverageOver/events but nothing to ACT on a corner,
|
||||
and repairs are Lane D's whole job. `interact.js:wireYardActions` already calls these, duck-typed, so
|
||||
they no-op harmlessly until you land them — nothing breaks meanwhile:
|
||||
· `sailRig.repair(i)` — re-rig corner i (I gate it on the player carrying a spare, 2.5 s hold, and
|
||||
I consume the spare). Needed for the M2 "one mid-storm repair must be survivable" line in §7.
|
||||
· `sailRig.trim(i, delta)` — per-corner turnbuckle, ±tension at ONE corner (1.2 s hold). This is
|
||||
§5-D.4's "new vs prototype, makes corners individual".
|
||||
· `corner.pos` (or `sailRig.cornerPos(i)`) → world Vector3 — I need somewhere to put the prompt.
|
||||
Live-read each frame, so a flogging corner's prompt tracks it.
|
||||
Shout if the shapes fight your sim and I'll adapt — you own sail.js, I'll move.
|
||||
|
||||
[D] 2026-07-16 — 📌 **PLAN3D §5-D.1 is not buildable as written — the peds cannot go through Blender.**
|
||||
§5-D.1 says merge clips onto a ped via the character_kit pipeline. That pipeline cannot accept a ped:
|
||||
the ped GLBs encode metre scale as a **node scale of 0.01 on `mixamorig*:Hips`** with every child bone
|
||||
in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF
|
||||
importer silently drops that 0.01 — straight after import the rig already reads Hips at 0.99 (metres)
|
||||
while HeadTop_End reads 76.88 (centimetres) and LeftToe_End sits **96 m under the floor**. Exploded
|
||||
before a single clip is merged. (It's also why `dancer.glb` is 30x small — its base, Hum_M_1.fbx, is
|
||||
an FBX with no such trick, head bone 0.0563 m. And `merge_anims.py`'s own comment warns about exactly
|
||||
this class of bug from the other end.) **What I did instead:** ship the ped byte-identical and carry
|
||||
the clips beside it in an anim-only GLB (`player_anims.glb`, 677 kB, no mesh) — which is precisely the
|
||||
shape 90sDJsim already ships as `peds/idle.glb` + `peds/walk.glb`, so it's the house pattern, not a
|
||||
workaround. Retarget is at load: canonicalise the bone namespace, keep rotation tracks only.
|
||||
Rebuild: `tools/character/build_player_anims.py` (header has the full why + the ssh one-liner).
|
||||
Two gotchas worth knowing if you touch rigs:
|
||||
· three.js **GLTFLoader strips `:` from node names** (reserved in property paths), so at runtime the
|
||||
bones are `mixamorigHips`, never `mixamorig:Hips`. `_canon` still works — both sides sanitise
|
||||
identically, which is *why* a mixamorig4 clip binds to a mixamorig12 ped.
|
||||
· Blender 5.0 **removed `Action.fcurves`** (slotted actions — they're under
|
||||
`layers[].strips[].channelbags[]`), so `character_kit/scripts/merge_anims.py` no longer runs there
|
||||
as written. My script handles both layouts.
|
||||
|
||||
[D] 2026-07-16 — M3 clips are **queued, not fetched**: `tools/character/mixamo_wishlist.txt` (Climbing
|
||||
Ladder, Turning Key, Digging + repair/storm extras). `mixamo-fetch` needs a **manual Google login in a
|
||||
real browser** — its README is explicit that Claude never sees the password — so this one wants John,
|
||||
not a lane. Everything M0–M2 needs is already on disk and in `player_anims.glb`.
|
||||
|
||||
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
|
||||
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
|
||||
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
|
||||
|
||||
34
tools/character/mixamo_wishlist.txt
Normal file
34
tools/character/mixamo_wishlist.txt
Normal file
@ -0,0 +1,34 @@
|
||||
# SHADES animation wishlist — Mixamo search terms, one per line. (Lane D, for M3)
|
||||
#
|
||||
# Same pipeline as 90sDJsim's dj_wishlist.txt and the op-shop clerk's wishlist.txt:
|
||||
# cd ~/Documents/mixamo-fetch
|
||||
# node fetch.cjs login # a real browser opens; John logs in by hand, once
|
||||
# cp <this file> ./shades_wishlist.txt
|
||||
# node fetch.cjs anims # loops the wishlist, downloads each "Without Skin"
|
||||
# then add the new FBX to CLIPS in tools/character/build_player_anims.py and rebuild the pack.
|
||||
#
|
||||
# NOTE: fetch.cjs needs a MANUAL Google login in a real browser window (its README is explicit that
|
||||
# Claude never sees the password), so this list is queued for John to run — not something a lane
|
||||
# fetches on its own. Everything M0-M2 needs is already on disk and already in player_anims.glb:
|
||||
# Idle / Walk / Run / Falling / CrouchToStand / Reaction.
|
||||
#
|
||||
# '#' and blank lines ignored. Names are fuzzy search terms (top hit wins); a miss is skipped.
|
||||
# In-place clips only — the game translates the player itself, and player.js keeps rotation tracks
|
||||
# only, so anything with big root travel is wasted.
|
||||
|
||||
## --- M3: the repair verbs (PLAN3D §5-D.1) ---
|
||||
Climbing Ladder
|
||||
Turning Key
|
||||
Digging
|
||||
|
||||
## --- M3: worth having while the browser is open (DESIGN.md "limited hands" toolset) ---
|
||||
Hammering
|
||||
Sweeping Floor
|
||||
Picking Up Object
|
||||
Carrying Box
|
||||
Standing Up Ready
|
||||
|
||||
## --- storm reactions (better than reusing Reaction for everything) ---
|
||||
Covering Head
|
||||
Bracing
|
||||
Stumble Backwards
|
||||
203
web/world/dev_player.html
Normal file
203
web/world/dev_player.html
Normal file
@ -0,0 +1,203 @@
|
||||
<!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>
|
||||
181
web/world/js/interact.js
Normal file
181
web/world/js/interact.js
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* interact.js — hold-E actions with radial progress. (Lane D)
|
||||
*
|
||||
* contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
|
||||
*
|
||||
* Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
|
||||
* `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
|
||||
*
|
||||
* The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
|
||||
* writes it, so a dropped release cannot strand the player — and if the world takes the player away
|
||||
* mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
|
||||
* stomping on the state the world just set.
|
||||
*/
|
||||
|
||||
export class Interact {
|
||||
constructor() {
|
||||
this.targets = new Map();
|
||||
this.active = null; // the target currently being held
|
||||
this.progress = 0; // 0..1 — the radial
|
||||
this.latched = false; // a completed action re-arms only after E is released (see step)
|
||||
this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} spec
|
||||
* @param {string} spec.id
|
||||
* @param {object} spec.pos {x,y,z} — read live each step, so it may move (a sail corner does)
|
||||
* @param {number} [spec.radius] metres
|
||||
* @param {number} [spec.holdSecs]
|
||||
* @param {string|function} [spec.label] string, or (player)->string for live text
|
||||
* @param {function} [spec.canUse] (player) -> bool
|
||||
* @param {function} [spec.onDone] (player, t) -> void
|
||||
* @returns {function} unregister
|
||||
*/
|
||||
register(spec) {
|
||||
if (!spec || !spec.id) throw new Error('interact.register: id required');
|
||||
const target = {
|
||||
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
|
||||
};
|
||||
this.targets.set(target.id, target);
|
||||
return () => this.unregister(target.id);
|
||||
}
|
||||
|
||||
unregister(id) {
|
||||
if (this.active && this.active.id === id) this.active = null, this.progress = 0;
|
||||
return this.targets.delete(id);
|
||||
}
|
||||
|
||||
labelOf(target, player) {
|
||||
return typeof target.label === 'function' ? target.label(player) : target.label;
|
||||
}
|
||||
|
||||
_usable(target, player) {
|
||||
return !target.canUse || !!target.canUse(player);
|
||||
}
|
||||
|
||||
/** Nearest registered target in range whose canUse() passes. */
|
||||
nearest(player) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const target of this.targets.values()) {
|
||||
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
|
||||
if (!p) continue;
|
||||
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
|
||||
if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
cancel(t, player) {
|
||||
if (!this.active) return;
|
||||
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
|
||||
if (player.state === 'busy') player.setState('idle', t);
|
||||
this.events.push({ type: 'cancel', id: this.active.id, t });
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dt @param {number} t
|
||||
* @param {PlayerSim} player
|
||||
* @param {boolean} holding is E held this frame
|
||||
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
|
||||
*/
|
||||
step(dt, t, player, holding) {
|
||||
// One press, one action: a completed hold latches until E is released. Without this, a held key
|
||||
// re-arms the instant the action finishes and the same action fires every holdSecs forever
|
||||
// (leaning on the shed table would deal you a spare a second, indefinitely).
|
||||
if (!holding) this.latched = false;
|
||||
|
||||
const near = this.nearest(player);
|
||||
|
||||
if (this.active) {
|
||||
const stolen = player.state !== 'busy'; // something else claimed the player
|
||||
if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
|
||||
this.cancel(t, player);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.active && holding && !this.latched && near && !player.busy) {
|
||||
this.active = near;
|
||||
this.progress = 0;
|
||||
player.setState('busy', t);
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
this.progress += dt / Math.max(1e-6, this.active.holdSecs);
|
||||
if (this.progress >= 1) {
|
||||
const done = this.active;
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
this.latched = true;
|
||||
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
|
||||
if (done.onDone) done.onDone(player, t);
|
||||
this.events.push({ type: 'done', id: done.id, t });
|
||||
}
|
||||
}
|
||||
|
||||
const shown = this.active || near;
|
||||
return {
|
||||
target: shown,
|
||||
progress: this.progress,
|
||||
label: shown ? this.labelOf(shown, player) : '',
|
||||
holding: !!this.active,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
|
||||
* never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
|
||||
*
|
||||
* @param {Interact} interact
|
||||
* @param {object} deps {sailRig, world, spares}
|
||||
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
|
||||
* sailRig.repair(i) -> void [PROPOSED — see THREADS.md]
|
||||
* sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md]
|
||||
* world.shedTable -> {pos} (Lane A/E)
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
const wired = [];
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((corner, i) => {
|
||||
// re-rig a broken corner — costs the spare you're carrying
|
||||
wired.push(interact.register({
|
||||
id: `rerig_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
canUse: (p) => corner.broken && p.carrying === 'spare',
|
||||
onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
wired.push(interact.register({
|
||||
id: `trim_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
canUse: () => !corner.broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (world && world.shedTable) {
|
||||
wired.push(interact.register({
|
||||
id: 'spare_table',
|
||||
pos: world.shedTable.pos,
|
||||
radius: 1.5,
|
||||
holdSecs: 0.6,
|
||||
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
|
||||
canUse: (p) => !p.carrying, // hands-full rule
|
||||
onDone: (p, t) => p.pickUp('spare', t),
|
||||
}));
|
||||
}
|
||||
|
||||
return () => wired.forEach((un) => un());
|
||||
}
|
||||
267
web/world/js/player.js
Normal file
267
web/world/js/player.js
Normal file
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* player.js — the small person: rig, clips, camera-relative control. (Lane D)
|
||||
*
|
||||
* The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
|
||||
* DEVMANUAL "Rigged animated characters" rules, which are law here:
|
||||
* · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes
|
||||
* · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the
|
||||
* scale factor is always MEASURED, never a blind setScalar of a guessed constant
|
||||
* · canonicalise the bone namespace so any clip binds to any character
|
||||
*
|
||||
* Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
|
||||
* player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
|
||||
* are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE } from './player.sim.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE };
|
||||
|
||||
export const CHAR_URL = '/world/models/player_01.glb';
|
||||
export const ANIM_URL = '/world/models/player_anims.glb';
|
||||
|
||||
/**
|
||||
* Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
|
||||
* character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
|
||||
* BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error.
|
||||
* (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
|
||||
*/
|
||||
const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');
|
||||
|
||||
const canonRig = (r) => {
|
||||
if (!r) return r;
|
||||
if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
|
||||
if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
|
||||
return r;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
|
||||
* source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
|
||||
* the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
|
||||
* scale retargets cleanly onto the metre-scale ped.
|
||||
*
|
||||
* The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the
|
||||
* Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
|
||||
* DOWNWIND of the gust that caused it. The clip only supplies the flail.
|
||||
*/
|
||||
const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
|
||||
c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
|
||||
|
||||
const _loadGLTF = (loader, url) => new Promise((res, rej) =>
|
||||
loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
|
||||
() => rej(new Error(`player: failed to load ${url}`))));
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
export class PlayerView {
|
||||
/**
|
||||
* @param {object} rig {scene, anims} — the character
|
||||
* @param {Array} clips retargeted AnimationClips
|
||||
* @param {number} height metres to the top of the head
|
||||
*/
|
||||
constructor(rig, clips, height = 1.72) {
|
||||
// root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0.
|
||||
this.root = new THREE.Group();
|
||||
this.root.name = 'player';
|
||||
|
||||
const fig = skeletonClone(rig.scene);
|
||||
this.fig = fig;
|
||||
fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
|
||||
this.root.add(fig);
|
||||
this.root.updateWorldMatrix(true, true);
|
||||
|
||||
// measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
|
||||
// HeadTop_End; max() takes the crown, which is what "height" means.
|
||||
const wp = new THREE.Vector3();
|
||||
let headY = 0;
|
||||
fig.traverse((o) => {
|
||||
if (!o.isBone) return;
|
||||
o.getWorldPosition(wp);
|
||||
if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
|
||||
});
|
||||
if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
|
||||
this.height = height;
|
||||
|
||||
// plant the feet: lift the rig so its lowest bone sits on the root's origin
|
||||
let minY = Infinity;
|
||||
fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
|
||||
if (minY < Infinity) fig.position.y = -minY;
|
||||
|
||||
this.mixer = new THREE.AnimationMixer(fig);
|
||||
this.actions = {};
|
||||
// some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
|
||||
// else three.js spams "No target node found" for every missing bone
|
||||
const nodes = new Set();
|
||||
fig.traverse((o) => { if (o.name) nodes.add(o.name); });
|
||||
for (const clip of clips) {
|
||||
const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
|
||||
if (!bindable.length) continue;
|
||||
const use = bindable.length === clip.tracks.length
|
||||
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
|
||||
this.actions[clip.name] = this.mixer.clipAction(use);
|
||||
}
|
||||
this.current = null;
|
||||
this._axis = new THREE.Vector3();
|
||||
this._qYaw = new THREE.Quaternion();
|
||||
this._qPitch = new THREE.Quaternion();
|
||||
}
|
||||
|
||||
/** @returns {string[]} clip names that bound to at least one bone */
|
||||
get clipNames() { return Object.keys(this.actions); }
|
||||
|
||||
play(name, loop = true, fade = 0.18) {
|
||||
const next = this.actions[name];
|
||||
if (!next || next === this.current) return;
|
||||
next.reset();
|
||||
next.setEffectiveWeight(1);
|
||||
next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
|
||||
next.clampWhenFinished = !loop;
|
||||
if (this.current) next.crossFadeFrom(this.current, fade, false);
|
||||
next.play();
|
||||
this.current = next;
|
||||
}
|
||||
|
||||
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
|
||||
sync(sim, dt) {
|
||||
const st = STATES[sim.state];
|
||||
this.play(st.clip, st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
|
||||
|
||||
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
|
||||
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
|
||||
this._qYaw.setFromAxisAngle(UP, sim.facing);
|
||||
if (sim.pitch > 1e-4) {
|
||||
this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
|
||||
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
|
||||
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
|
||||
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
|
||||
} else {
|
||||
this.root.quaternion.copy(this._qYaw);
|
||||
}
|
||||
|
||||
this.mixer.update(dt);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.mixer.stopAllAction();
|
||||
this.root.removeFromParent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the player and attach it to a scene.
|
||||
* @param {THREE.Scene|THREE.Object3D} scene
|
||||
* @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl}
|
||||
* @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
|
||||
*/
|
||||
export async function loadPlayer(scene, opts = {}) {
|
||||
const loader = new GLTFLoader();
|
||||
const [rig, animPack] = await Promise.all([
|
||||
_loadGLTF(loader, opts.charUrl || CHAR_URL),
|
||||
_loadGLTF(loader, opts.animUrl || ANIM_URL),
|
||||
]);
|
||||
canonRig(rig);
|
||||
canonRig(animPack);
|
||||
|
||||
const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
|
||||
const view = new PlayerView(rig, clips, opts.height || 1.72);
|
||||
scene.add(view.root);
|
||||
|
||||
const sim = new PlayerSim(opts);
|
||||
|
||||
const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
|
||||
a.indexOf(c) === i && !view.actions[c]);
|
||||
if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);
|
||||
|
||||
return {
|
||||
sim,
|
||||
view,
|
||||
/** Convenience: step the sim then push it to the rig. */
|
||||
step(dt, t, input, wind) {
|
||||
sim.step(dt, t, input, wind);
|
||||
view.sync(sim, dt);
|
||||
return sim.state;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Player` contract factory (contracts.js) — this is the one main.js calls.
|
||||
* Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
|
||||
* only changes which function it calls. Everything the player needs per frame it pulls itself, so
|
||||
* `update(dt, t)` matches the contract's signature exactly.
|
||||
*
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World — `heightAt(x,z)` clamps the player to the ground
|
||||
* @param {object} cameraRig contracts Camera — `yaw` is what WASD is relative to
|
||||
* @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl}
|
||||
* @returns {Promise<object>} satisfies checkContract('player', …)
|
||||
*/
|
||||
export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const p = await loadPlayer(scene, {
|
||||
...opts,
|
||||
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
|
||||
start: opts.start || { x: 0, y: 0, z: 6 },
|
||||
});
|
||||
const keyboard = new KeyboardInput();
|
||||
const { sim, view } = p;
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
set carrying(v) { sim.carrying = v; },
|
||||
get busy() { return sim.busy; },
|
||||
|
||||
/** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */
|
||||
update(dt, t) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
|
||||
/** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */
|
||||
get object() { return view.root; },
|
||||
sim,
|
||||
view,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard → the sim's input shape. Camera yaw comes from Lane A's camera each frame.
|
||||
* Kept out of PlayerSim so the sim stays headless.
|
||||
*/
|
||||
export class KeyboardInput {
|
||||
constructor(target = window) {
|
||||
this.keys = new Set();
|
||||
this._down = (e) => {
|
||||
this.keys.add(e.code);
|
||||
if (/^(Arrow|Space)/.test(e.code)) e.preventDefault();
|
||||
};
|
||||
this._up = (e) => this.keys.delete(e.code);
|
||||
target.addEventListener('keydown', this._down);
|
||||
target.addEventListener('keyup', this._up);
|
||||
this._target = target;
|
||||
}
|
||||
|
||||
get holding() { return this.keys.has('KeyE'); }
|
||||
|
||||
/** @param {number} camYaw radians */
|
||||
read(camYaw = 0) {
|
||||
const k = this.keys;
|
||||
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
|
||||
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
|
||||
return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._target.removeEventListener('keydown', this._down);
|
||||
this._target.removeEventListener('keyup', this._up);
|
||||
}
|
||||
}
|
||||
246
web/world/js/player.sim.js
Normal file
246
web/world/js/player.sim.js
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
* player.sim.js — the small person's deterministic core. (Lane D)
|
||||
*
|
||||
* Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a
|
||||
* fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or
|
||||
* Math.random(). `step(dt, t, …)` is the entire clock: same inputs → same trace, every run.
|
||||
*
|
||||
* player.js owns the view (rig, clips, camera). This file owns the truth: where the person is,
|
||||
* what state they're in, and what the wind is doing to them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
|
||||
* clip — clip name in player_anims.glb
|
||||
* locked — movement input is ignored, and `player.busy` is true
|
||||
* secs — timed states auto-advance to `next` after this long
|
||||
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
|
||||
* or is released by an external actor. `busy` is the only externally-released state — interact.js
|
||||
* both enters and leaves it, so a dropped release can't strand the player.
|
||||
*/
|
||||
export const STATES = {
|
||||
idle: { clip: 'Idle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', locked: false, loop: true },
|
||||
run: { clip: 'Run', locked: false, loop: true },
|
||||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||||
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
|
||||
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
|
||||
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
|
||||
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
|
||||
*/
|
||||
export const TUNE = {
|
||||
walkSpeed: 1.5, // m/s — a person crossing a 30 m yard, unhurried
|
||||
runSpeed: 4.4, // m/s — shift
|
||||
accel: 16, // m/s² toward the wanted velocity
|
||||
turnRate: 11, // rad/s — facing chases the movement direction
|
||||
|
||||
// prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak),
|
||||
// which is already m/s-shaped, so the curve ports across directly.
|
||||
slowMax: 0.35,
|
||||
slowRef: 160,
|
||||
|
||||
// prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
|
||||
// only applied while wind.gust > 8, never from the base wind.
|
||||
shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust
|
||||
shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all
|
||||
shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp
|
||||
|
||||
// Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
|
||||
// the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's
|
||||
// contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above
|
||||
// it is gust. Self-calibrating to whatever storm JSON Lane C authors.
|
||||
baseTrack: 0.25, // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base
|
||||
|
||||
// Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) —
|
||||
// same verb for cloth and for people, so the player reads one language.
|
||||
knockWind: 30, // m/s sustained local wind → you go down
|
||||
knockSustain: 0.5, // s above knockWind before it happens
|
||||
knockBleed: 2, // exposure drains this many × faster than it fills
|
||||
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */
|
||||
function turnToward(a, b, maxStep) {
|
||||
let d = (b - a) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return a + clamp(d, -maxStep, maxStep);
|
||||
}
|
||||
|
||||
export class PlayerSim {
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.start] {x,y,z} spawn, metres
|
||||
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
|
||||
* @param {object} [opts.tune] overrides for TUNE
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
const s = opts.start || { x: 0, y: 0, z: 0 };
|
||||
this.pos = { x: s.x, y: s.y || 0, z: s.z };
|
||||
this.vel = { x: 0, z: 0 }; // intended (input-driven) velocity
|
||||
this.shove = { x: 0, z: 0 }; // wind-driven velocity, decays through foot friction
|
||||
this.facing = opts.facing || 0; // yaw; models face +Z at 0 (90sDJsim convention)
|
||||
|
||||
this.state = 'idle';
|
||||
this.stateT = 0;
|
||||
this.carrying = null; // contract: player.carrying — one item, hands-full rule
|
||||
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
|
||||
|
||||
this.exposure = 0; // s spent above knockWind
|
||||
this.windBase = 0; // EMA baseline (see TUNE.baseTrack)
|
||||
this.windSpeed = 0; // last sampled local speed, m/s — HUD/audio read this
|
||||
this.gust = 0; // windSpeed - windBase, clamped ≥0
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.tune = { ...TUNE, ...(opts.tune || {}) };
|
||||
}
|
||||
|
||||
/** contract: player.busy */
|
||||
get busy() { return !!STATES[this.state].locked; }
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
setState(s, t = 0) {
|
||||
if (this.state === s) return false;
|
||||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||||
this.state = s;
|
||||
this.stateT = 0;
|
||||
this.events.push({ type: 'state', state: s, t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */
|
||||
drop(t = 0) {
|
||||
if (!this.carrying) return null;
|
||||
const item = this.carrying;
|
||||
this.carrying = null;
|
||||
this.events.push({ type: 'drop', item, t });
|
||||
return item;
|
||||
}
|
||||
|
||||
/** @returns {boolean} true if the pickup was accepted (hands-full rule). */
|
||||
pickUp(item, t = 0) {
|
||||
if (this.carrying || this.busy) return false;
|
||||
this.carrying = item;
|
||||
this.events.push({ type: 'pickup', item, t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Light hit — a glancing debris clip. Ignored if already down. */
|
||||
staggerHit(t = 0) {
|
||||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||||
return this.setState('stagger', t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it
|
||||
* itself on sustained extreme wind.
|
||||
* @param {number} [dirX] @param {number} [dirZ] which way to fall — defaults to downwind/facing.
|
||||
*/
|
||||
knockdown(t = 0, dirX, dirZ) {
|
||||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||||
let x = dirX, z = dirZ;
|
||||
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
|
||||
const m = Math.hypot(x, z) || 1;
|
||||
this.knockDir = { x: x / m, z: z / m };
|
||||
this.setState('knocked', t);
|
||||
this.exposure = 0;
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.drop(t);
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dt fixed step, seconds
|
||||
* @param {number} t storm time, seconds
|
||||
* @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians
|
||||
* @param {object} wind contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector
|
||||
*/
|
||||
step(dt, t, input = {}, wind = null) {
|
||||
const T = this.tune;
|
||||
this.stateT += dt;
|
||||
|
||||
// --- local wind, and how much of it is gust ---
|
||||
let wx = 0, wz = 0;
|
||||
if (wind) {
|
||||
const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind;
|
||||
if (v) { wx = v.x || 0; wz = v.z || 0; }
|
||||
}
|
||||
const ws = Math.hypot(wx, wz);
|
||||
this.windSpeed = ws;
|
||||
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
|
||||
if (ws > T.knockWind) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
const st = STATES[this.state];
|
||||
|
||||
// --- movement ---
|
||||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||||
let wantX = 0, wantZ = 0;
|
||||
if (!st.locked) {
|
||||
const ix = input.x || 0, iz = input.z || 0;
|
||||
const mag = Math.hypot(ix, iz);
|
||||
if (mag > 1e-3) {
|
||||
// camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos).
|
||||
const cy = input.camYaw || 0;
|
||||
const sin = Math.sin(cy), cos = Math.cos(cy);
|
||||
const nx = ix / mag, nz = iz / mag;
|
||||
const dx = nx * cos - nz * sin;
|
||||
const dz = -nx * sin - nz * cos;
|
||||
const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow;
|
||||
wantX = dx * target; wantZ = dz * target;
|
||||
this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt);
|
||||
}
|
||||
}
|
||||
// approach the wanted velocity at a fixed accel (both directions — stopping is the same law)
|
||||
const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z;
|
||||
const dvm = Math.hypot(dvx, dvz);
|
||||
const step = T.accel * dt;
|
||||
if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; }
|
||||
else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; }
|
||||
|
||||
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
|
||||
const grounded = this.state === 'knocked' || this.state === 'getup';
|
||||
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
|
||||
const a = T.shoveK * ws * ws;
|
||||
this.shove.x += (wx / ws) * a * dt;
|
||||
this.shove.z += (wz / ws) * a * dt;
|
||||
}
|
||||
const bleed = Math.exp(-T.shoveDamp * dt);
|
||||
this.shove.x *= bleed; this.shove.z *= bleed;
|
||||
|
||||
this.pos.x += (this.vel.x + this.shove.x) * dt;
|
||||
this.pos.z += (this.vel.z + this.shove.z) * dt;
|
||||
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
|
||||
|
||||
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
|
||||
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
|
||||
const wantPitch = this.state === 'knocked' ? 1
|
||||
: this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1))
|
||||
: 0;
|
||||
const pstep = dt / T.pitchSecs;
|
||||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||||
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
|
||||
if (!st.locked) {
|
||||
const sp = this.speed;
|
||||
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
|
||||
} else if (st.secs && this.stateT >= st.secs && st.next) {
|
||||
this.setState(st.next, t);
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,295 @@
|
||||
/**
|
||||
* Lane D selftests — player state machine and interactions.
|
||||
* Lane D selftests — player state machine, weather effects, interactions. (PLAN3D §5-D.5)
|
||||
*
|
||||
* Lane D owns this file. Lane A pre-created it so adding your suite never means
|
||||
* editing selftest.html.
|
||||
* Everything asserted here is renderer-free: player.sim.js and interact.js import nothing, so this
|
||||
* suite is pure logic driven at fixed dt, never rAF. (rAF being throttled in a hidden tab is not
|
||||
* theoretical — this lane's dev harness froze mid-knockdown at stateT=0.333 until it was driven by
|
||||
* a fixed loop, which is exactly the trap Lane A called out.)
|
||||
*
|
||||
* The asserts PLAN3D §5-D asks for, once player.js lands:
|
||||
* 1. anim state machine table test — every state reachable, none stuck
|
||||
* (idle/walk/run + one-shot interact + knockdown → get-up).
|
||||
* 2. interact radius respects the busy and carrying flags.
|
||||
*
|
||||
* Note for integration: main.js currently drives a placeholder capsule that
|
||||
* satisfies the Player contract ({pos, carrying, busy, update}). When player.js
|
||||
* lands, Lane A swaps the factory call in boot() and deletes the placeholder —
|
||||
* you shouldn't need to touch main.js yourself. Ping THREADS.md when you're
|
||||
* ready and Lane A will do the swap.
|
||||
*
|
||||
* Useful imports:
|
||||
* import { FIXED_DT, createStubWind } from '../contracts.js';
|
||||
* import { assert, assertEq, fixedLoop } from '../testkit.js';
|
||||
* The rig half of player.js — bone binding, head-bone scale, clip retarget — can't be asserted
|
||||
* headlessly; it needs a GL context and two GLB fetches. That is verified by hand in
|
||||
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
|
||||
* all six clips bound, Hips tracks correctly absent.
|
||||
*/
|
||||
import { PlayerSim, STATES, TUNE } from '../player.sim.js';
|
||||
import { Interact, wireYardActions } from '../interact.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
/** Steady wind blowing +X at `speed` m/s. */
|
||||
const windX = (speed) => ({ x: speed, y: 0, z: 0 });
|
||||
|
||||
/** Drive the sim for `secs` at fixed dt. */
|
||||
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
t.skip('player.js not landed yet — Lane D');
|
||||
// ---------------------------------------------------------------- state machine table
|
||||
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
|
||||
const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']);
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('state table: no stuck states — every locked state drains to a free one', () => {
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
if (!st.locked) continue;
|
||||
assert((st.next && st.secs > 0) || !!st.releasedBy,
|
||||
`locked state ${name} has neither a timed exit nor an external releaser`);
|
||||
if (st.next) assert(!!STATES[st.next], `state ${name}.next=${st.next} is not a state`);
|
||||
}
|
||||
for (const name of Object.keys(STATES)) {
|
||||
let cur = name, hops = 0;
|
||||
const seen = new Set();
|
||||
while (STATES[cur].locked && STATES[cur].next && hops < 16 && !seen.has(cur)) {
|
||||
seen.add(cur); cur = STATES[cur].next; hops++;
|
||||
}
|
||||
assert(!STATES[cur].locked || !!STATES[cur].releasedBy,
|
||||
`state ${name} chains into a dead end at ${cur}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- locomotion
|
||||
t.test('locomotion: idle -> walk -> run -> idle at the tuned speeds', () => {
|
||||
const sim = new PlayerSim();
|
||||
assertEq(sim.state, 'idle', 'spawns idle');
|
||||
assert(!sim.busy, 'idle is not busy');
|
||||
|
||||
drive(sim, 1.0, { x: 0, z: 1, camYaw: 0 });
|
||||
assertEq(sim.state, 'walk', 'W walks');
|
||||
assertClose(sim.speed, TUNE.walkSpeed, 0.05, 'walk speed');
|
||||
|
||||
drive(sim, 1.0, { x: 0, z: 1, run: true, camYaw: 0 });
|
||||
assertEq(sim.state, 'run', 'shift runs');
|
||||
assertClose(sim.speed, TUNE.runSpeed, 0.05, 'run speed');
|
||||
|
||||
drive(sim, 1.0, {});
|
||||
assertEq(sim.state, 'idle', 'releasing input returns to idle');
|
||||
assertLess(sim.speed, 0.15, 'and stops');
|
||||
});
|
||||
|
||||
t.test('locomotion: movement is relative to camera.yaw', () => {
|
||||
const a = new PlayerSim(); drive(a, 1.0, { x: 0, z: 1, camYaw: 0 });
|
||||
assertLess(a.pos.z, -0.5, 'at yaw 0, W drives -Z (three.js camera-forward)');
|
||||
assertClose(a.pos.x, 0, 1e-6, 'and not sideways');
|
||||
|
||||
const b = new PlayerSim(); drive(b, 1.0, { x: 0, z: 1, camYaw: Math.PI / 2 });
|
||||
assertLess(b.pos.x, -0.5, 'at yaw 90 deg, the same key drives -X');
|
||||
assertClose(b.pos.z, 0, 1e-6, 'and not forward');
|
||||
|
||||
assertClose(Math.abs(a.facing), Math.PI, 0.05, 'facing chases the movement direction');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- weather on the player
|
||||
t.test('weather: wind slows you, capped at TUNE.slowMax', () => {
|
||||
const calm = new PlayerSim(); drive(calm, 2, { x: 0, z: 1, camYaw: 0 }, windX(0));
|
||||
const blow = new PlayerSim(); drive(blow, 2, { x: 0, z: 1, camYaw: 0 }, windX(20));
|
||||
assertLess(blow.speed, calm.speed, 'wind slows you');
|
||||
|
||||
// Isolate the slow curve from the knockdown — in a real 1e4 m/s you are flat on your back
|
||||
// inside half a second (which is correct, and is what this assert used to accidentally measure).
|
||||
const gale = new PlayerSim({ tune: { knockWind: Infinity } });
|
||||
drive(gale, 2, { x: 0, z: 1, camYaw: 0 }, windX(1e4));
|
||||
assertClose(gale.speed, TUNE.walkSpeed * (1 - TUNE.slowMax), 0.05,
|
||||
'slow saturates at slowMax and never goes past it');
|
||||
});
|
||||
|
||||
t.test('weather: steady wind reads as baseline, not as a gust', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 60, {}, windX(20));
|
||||
assertLess(s.gust, 1.0, 'the EMA learns a constant 20 m/s as base');
|
||||
assertLess(Math.hypot(s.shove.x, s.shove.z), 0.05, 'so it does not shove you forever');
|
||||
});
|
||||
|
||||
t.test('weather: a gust over the baseline shoves you downwind, and scales with speed squared', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(6)); // learn a calm baseline
|
||||
const x0 = s.pos.x;
|
||||
drive(s, 1.6, {}, windX(22), 30); // 16 m/s over baseline -> past shoveGustMin
|
||||
assert(s.pos.x - x0 > 0.05, `gust should shove downwind, dx=${(s.pos.x - x0).toFixed(3)}`);
|
||||
|
||||
const push = (ws) => {
|
||||
const p = new PlayerSim();
|
||||
drive(p, 30, {}, windX(2));
|
||||
const p0 = p.pos.x;
|
||||
drive(p, 1.0, {}, windX(ws), 30);
|
||||
return p.pos.x - p0;
|
||||
};
|
||||
const p15 = push(15), p30 = push(30);
|
||||
assert(p30 > p15 * 2.5,
|
||||
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- knockdown
|
||||
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
|
||||
const brief = new PlayerSim();
|
||||
drive(brief, 0.3, {}, windX(TUNE.knockWind + 5));
|
||||
assert(brief.state !== 'knocked', 'a brief spike must not put you down');
|
||||
|
||||
const held = new PlayerSim();
|
||||
held.carrying = 'spare';
|
||||
drive(held, TUNE.knockSustain + 0.2, {}, windX(TUNE.knockWind + 5));
|
||||
assertEq(held.state, 'knocked', 'sustained overload does');
|
||||
assertEq(held.carrying, null, 'and drops what you were carrying');
|
||||
assert(held.knockDir.x > 0.99, 'and you fall downwind');
|
||||
});
|
||||
|
||||
t.test('knockdown: exposure bleeds off — flickering gusts never creep into one', () => {
|
||||
const s = new PlayerSim();
|
||||
fixedLoop(10, DT, (dt, t) => {
|
||||
const on = Math.round(t / DT) % 40 < 10; // 0.17 s on, 0.5 s off
|
||||
s.step(dt, t, {}, windX(on ? TUNE.knockWind + 5 : 5));
|
||||
});
|
||||
assert(s.state !== 'knocked',
|
||||
`flickering gusts must not accumulate (exposure=${s.exposure.toFixed(3)})`);
|
||||
});
|
||||
|
||||
t.test('knockdown: knocked -> getup -> idle, unaided, and upright again', () => {
|
||||
const s = new PlayerSim();
|
||||
s.knockdown(0);
|
||||
assert(s.busy, 'knocked is locked');
|
||||
drive(s, 0.5, { x: 0, z: 1, camYaw: 0 });
|
||||
assertClose(s.pos.z, 0, 1e-9, 'knocked ignores movement input');
|
||||
assert(s.pitch > 0.99, 'body goes flat');
|
||||
drive(s, 1.1, {});
|
||||
assertEq(s.state, 'getup', 'knocked drains to getup on its own');
|
||||
drive(s, 1.4, {});
|
||||
assertEq(s.state, 'idle', 'getup drains to idle on its own');
|
||||
assertLess(s.pitch, 0.01, 'and the body is upright again');
|
||||
assert(!s.busy, 'player is free');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- determinism (PLAN3D §4)
|
||||
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
|
||||
const trace = () => {
|
||||
const s = new PlayerSim();
|
||||
const out = [];
|
||||
fixedLoop(50, DT, (dt, tt) => {
|
||||
const w = { x: 6 + 24 * Math.max(0, Math.sin(tt * 0.7)), y: 0, z: 3 * Math.cos(tt * 0.31) };
|
||||
s.step(dt, tt, { x: Math.sin(tt), z: Math.cos(tt * 0.5), run: tt % 4 < 2, camYaw: tt * 0.2 }, w);
|
||||
out.push(`${s.state}|${s.pos.x.toFixed(9)}|${s.pos.z.toFixed(9)}|${s.pitch.toFixed(9)}`);
|
||||
});
|
||||
return out.join(';');
|
||||
};
|
||||
assertEq(trace(), trace(), 'same inputs must give the same trace');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- interact
|
||||
const mk = () => {
|
||||
const sim = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
let done = 0;
|
||||
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 1.5, holdSecs: 1, label: 'do it',
|
||||
onDone: () => { done++; } });
|
||||
return { sim, it, done: () => done };
|
||||
};
|
||||
|
||||
t.test('interact: radius gates the action', () => {
|
||||
const { sim, it, done } = mk();
|
||||
sim.pos.x = 5;
|
||||
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 0, 'out of radius never fires');
|
||||
assert(!sim.busy, 'and never makes you busy');
|
||||
});
|
||||
|
||||
t.test('interact: hold to completion fires once and hands the player back', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 1, 'fires once');
|
||||
assert(!sim.busy && sim.state === 'idle', 'player released');
|
||||
assert(it.events.some((e) => e.type === 'done' && e.id === 'x'), 'emits a done event');
|
||||
});
|
||||
|
||||
t.test('interact: player is busy mid-hold, with a partial radial', () => {
|
||||
const { sim, it } = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assert(sim.busy && sim.state === 'busy', 'busy while holding');
|
||||
assert(it.progress > 0.4 && it.progress < 0.6, `radial partway, got ${it.progress.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('interact: one press, one action — a held key must not re-arm itself', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(6.7, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 1, 'holding E for 6.7 s over a 1 s action still fires once');
|
||||
it.step(DT, 7, sim, false);
|
||||
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, 7 + tt, sim, true));
|
||||
assertEq(done(), 2, 'releasing re-arms it');
|
||||
});
|
||||
|
||||
t.test('interact: releasing or walking away cancels the hold', () => {
|
||||
const a = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => a.it.step(dt, tt, a.sim, true));
|
||||
a.it.step(DT, 0.5, a.sim, false);
|
||||
assertEq(a.it.progress, 0, 'release cancels');
|
||||
assert(!a.sim.busy && a.sim.state === 'idle', 'and frees the player');
|
||||
|
||||
const b = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => b.it.step(dt, tt, b.sim, true));
|
||||
b.sim.pos.x = 9;
|
||||
b.it.step(DT, 0.5, b.sim, true);
|
||||
assertEq(b.it.progress, 0, 'leaving the radius cancels');
|
||||
assertEq(b.done(), 0, 'without firing');
|
||||
});
|
||||
|
||||
t.test('interact: a knockdown mid-hold aborts without stomping the knocked state', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
sim.knockdown(0.5);
|
||||
it.step(DT, 0.51, sim, true);
|
||||
assertEq(it.progress, 0, 'hold aborted');
|
||||
assertEq(done(), 0, 'action did not fire');
|
||||
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
|
||||
});
|
||||
|
||||
t.test('interact: canUse() gates on the carrying flag', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
let fired = 0;
|
||||
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => !p.carrying, onDone: () => { fired++; } });
|
||||
sim.carrying = 'spare';
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 0, 'hands full: gated');
|
||||
assert(!sim.busy, 'and never went busy');
|
||||
sim.carrying = null;
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 1, 'fires once the gate opens');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- hands-full + wiring
|
||||
t.test('hands-full rule: one item at a time', () => {
|
||||
const sim = new PlayerSim();
|
||||
assertEq(sim.pickUp('spare'), true, 'first item accepted');
|
||||
assertEq(sim.pickUp('wrench'), false, 'second refused');
|
||||
assertEq(sim.carrying, 'spare', 'still holding the first');
|
||||
assertEq(sim.drop(), 'spare', 'drop returns it');
|
||||
assertEq(sim.carrying, null, 'hands empty');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: duck-types against a half-landed world', () => {
|
||||
const empty = new Interact();
|
||||
wireYardActions(empty, {});
|
||||
assertEq(empty.targets.size, 0, 'no sailRig yet (Lane B) -> no actions, no crash');
|
||||
|
||||
const it = new Interact();
|
||||
const corners = [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 }, load: 0 }];
|
||||
let repaired = -1;
|
||||
wireYardActions(it, {
|
||||
sailRig: { corners, repair: (i) => { repaired = i; }, trim: () => {} },
|
||||
world: { shedTable: { pos: { x: 10, y: 0, z: 0 } } },
|
||||
});
|
||||
assertEq(it.targets.size, 3, 're-rig + trim + spare table');
|
||||
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assertEq(repaired, -1, 're-rig refuses without a spare');
|
||||
p.carrying = 'spare';
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, 10 + tt, p, true));
|
||||
assertEq(repaired, 0, 're-rig fires with a spare');
|
||||
assertEq(p.carrying, null, 'and consumes it');
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user