/** * 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 // Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a // low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it // became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the // prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm. shoveGustMin: 4, // 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. // RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the // real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the // shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2 // knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline). // d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again. stumbleGust: 9, // 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; } }