/** * 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' }, // --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the // knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than // Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. --- climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' }, // atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up // there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`. atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' }, }; /** * True while the player is on a ladder — movement is off, and the wind is meaner. * * Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`, * so a state-based test would say you'd stepped off the ladder the instant you started the repair * you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the * physical truth and survives every state the ladder passes through. */ export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb'; /** * 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 // Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is // where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only // defence is choosing your moment: climb in a lull, not through a gust. climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48. ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off }; 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.climbY = 0; // m above the ground; >0 means you're up a ladder this.climbTarget = 0; // where ladder.js asked you to be this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with 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); } /** How high this person's hands get right now. The gate on reaching a fascia bracket. */ get reachY() { return this.pos.y + this.climbY + this.tune.reach; } /** * Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so * a climb is deterministic and fast-forwards in selftest like everything else. * @param {number} y target height above ground; 0 climbs back down */ climbTo(y, t = 0) { this.climbTarget = Math.max(0, y); if (Math.abs(this.climbTarget - this.climbY) > 0.02) { this.vel.x = this.vel.z = 0; this.setState('climb', t); } return true; } 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 }; // Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the // same, so the ladder gets the get-up chain for free — you just arrive on the ground first. this.fellFrom = this.climbY; this.climbY = 0; this.climbTarget = 0; 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 }, fellFrom: this.fellFrom }); 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, and refused on // a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. --- const up = onLadder(this); 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; a ladder LOWERS it. Same exposure clock either way, so the storm // speaks one language whether you're on your feet or up a rung. --- const knockAt = T.knockWind * (braced ? T.shelterKnockMult : 1) * (up ? T.ladderKnockMult : 1); 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); // --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) --- if (this.state === 'climb') { const dy = this.climbTarget - this.climbY; const rung = T.climbRate * dt; if (Math.abs(dy) <= rung) { this.climbY = this.climbTarget; this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t); } else { this.climbY += Math.sign(dy) * rung; } } // Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js // releases a finished hold to 'idle' without knowing where you are; this puts you back in the // work stance instead of leaving you idling in mid-air. if (this.climbY > 0.02 && (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) { this.setState('atTop', t); } // --- 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]; const aloft = onLadder(this); // re-read: the climb block above may have just landed you // --- movement --- const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you let wantX = 0, wantZ = 0; if (!st.locked && !aloft) { 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). // `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without // this guard the speed check would immediately re-state you to idle and drop you off. --- if (!st.locked && !aloft) { 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; } }