At gate 1 the ped walked straight through the house. Collision now stops it 0.30 m off the wall face (expected -9.70, measured -9.70), off trunks, posts and the fence, and slides along a wall hit at an angle. Injected as opts.collide the way groundAt already was, so player.sim.js stays zero-import and node-runnable. Two things the real yard taught, neither guessable from the plan: - `fence` is a GROUP of 37 child meshes whose combined box is the entire 30x20 m yard, so one box per solids entry is useless. Flattened to 43 leaf boxes. - the house ROOF spans y 2.99-3.21 and reaches 0.4 m FURTHER into the yard than the wall under it (eaves overhang). A flat footprint test would stop a 1.7 m person dead at an invisible eave, so every box is filtered by vertical overlap with the body and the roof drops out on its own. Boxes are built once (solids are static) and distance-pruned — no per-frame raycast, which is the thing Lane A measured as catastrophic on the terrain. The M3 pack is wired: carrying swaps locomotion to Carry/CarryIdle; an interaction names its own verb through a new `clip` field on the interact spec (Crank at a turnbuckle, PickUp at the shed table); StumbleBack fires on a gust that breaks your stride but can't floor you — below knockWind on purpose, so a storm reads as shoved → stumbling → floored rather than fine-fine-fine-flat. TakeCover (hold C) became a real mechanic rather than a pose: brace and knockWind x2.0, shove x0.25. A 38 m/s gale floors you standing and doesn't while braced; let go in the same gale and you're down in half a second. It raises the bar, it does not remove it — a big enough gust still wins, braced or not. So the storm's answer to "the gusts are too strong to cross the yard" is now wait one out and move in the lull, which is the repair-window language DESIGN.md already uses. wireYardActions now reads sailRig.corners[i] live by index instead of capturing the corner object — per Lane A's warning that attach() replaces the array, a captured corner is one the sim no longer steps and would gate forever on a `broken` flag that can never change again. 35 Lane D asserts, 0 fail (was 20). Carry/shelter/knockdown verified against the real 17-clip pack in the assembled game, not only in the harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
15 KiB
JavaScript
309 lines
15 KiB
JavaScript
/**
|
||
* 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
|
||
* carryClip — clip to use instead when the player has something in their hands
|
||
* 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 names an external releaser. `busy` and `shelter` are the released ones — interact.js and the
|
||
* shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
|
||
*/
|
||
export const STATES = {
|
||
idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true },
|
||
walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true },
|
||
run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true },
|
||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||
shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' },
|
||
stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' },
|
||
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' },
|
||
};
|
||
|
||
/**
|
||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||
* interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
|
||
* Kept here rather than in player.js so the selftest can assert it without a renderer.
|
||
*/
|
||
export function clipFor(sim) {
|
||
const st = STATES[sim.state];
|
||
if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
|
||
if (sim.carrying && st.carryClip) return st.carryClip;
|
||
return st.clip;
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
|
||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||
stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet)
|
||
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
|
||
|
||
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
|
||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||
shelterShoveMult: 0.25, // and it barely pushes you
|
||
};
|
||
|
||
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 {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
|
||
* Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
|
||
* player.js#makeSolidCollider builds the real one out of world.solids.
|
||
* @param {number} [opts.height] body height, metres — the collider's vertical span
|
||
* @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.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…)
|
||
this.stumbleCool = 0; // s until a gust may stumble you again
|
||
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.collide = opts.collide || null;
|
||
this.bodyHeight = opts.height || 1.72;
|
||
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;
|
||
this.stumbleCool = Math.max(0, this.stumbleCool - 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);
|
||
|
||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||
const wantShelter = !!input.shelter;
|
||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||
else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
|
||
const braced = this.state === 'shelter';
|
||
|
||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||
if (ws > knockAt) this.exposure += dt;
|
||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||
|
||
// --- a gust below the knockdown bar can still break your stride ---
|
||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||
this.stumbleCool = T.stumbleCooldown;
|
||
this.setState('stumble', t);
|
||
this.vel.x = this.vel.z = 0;
|
||
}
|
||
|
||
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 * (braced ? T.shelterShoveMult : 1);
|
||
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);
|
||
|
||
// Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
|
||
// so walking into a wall at an angle keeps its tangential component and slides along it for free
|
||
// — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
|
||
// hold you against a fence, and the pushout wins over it every frame anyway.
|
||
if (this.collide) {
|
||
const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
|
||
if (r) { this.pos.x = r.x; this.pos.z = r.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;
|
||
}
|
||
}
|