// flight/player.js (Lane B) — ENDO-1's controller, camera rig and durability. // // THE MODEL (GDD "flow-locked hybrid", tube mode). The player does not live in world space; // it lives in the world's spline space and is rendered into world space each frame: // // s arclength down the canal. Peristalsis owns it: ds/dt = flow·(throttle+surf+boost). // You can lean on it ±40%, burst past it, or surf it. You cannot stop or reverse. // x, y the cross-section disc offset — x along frame.bin (lateral), y along frame.nor // (vertical). This is where ALL your agency is, and it is fully analog. // // worldPos = frame.pos + frame.nor·y + frame.bin·x (frame = world.sample(s)) // theta = atan2(x, y) — matches world.project()'s atan2(rel·bin, rel·nor) // // Everything downstream falls out of this: collision is one radius compare against // world.wallRho, the camera is the same three numbers lagged, and enemies chase in the same // space for pennies. Arena/6DOF mode is round 2 (see NOTES); modeAt() is guarded below. import * as THREE from 'three'; import { TUNING as T } from './tuning.js'; import { createInput } from './input.js'; import { buildShip, buildReticle } from './ship.js'; export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null, camera, dom }) { const input = createInput(dom); const ship = buildShip(assets); const reticle = buildReticle(); scene.add(ship.group, reticle.mesh); const st = { s: 2, x: 0, y: 0, // spline-space position vx: 0, vy: 0, // disc velocity (u/s) ax: 0, ay: 0, // aim offset in the disc plane at s + aim.distance throttle: 1, boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0, surfBlend: 0, onCrest: false, coat: T.coat.max, hull: T.hull.max, sinceDamage: 99, wallCd: 0, roll: 0, pitch: 0, speed: 0, alive: true, }; // scratch — this runs 60×/s; nothing in the loop allocates const _pos = new THREE.Vector3(), _aim = new THREE.Vector3(), _look = new THREE.Vector3(); const _aimLag = new THREE.Vector3(), _camPos = new THREE.Vector3(), _tmp = new THREE.Vector3(); let aimLagInit = false; const discToWorld = (out, frame, x, y) => out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x); const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length); // --- durability ------------------------------------------------------------------- // Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does // NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E // reads continuous state off player:state instead. Auras tick discretely (combat/enemies). function damage(amount, kind = 'hit') { if (!st.alive || st.iframeT > 0 || amount <= 0) return false; st.sinceDamage = 0; const toCoat = Math.min(st.coat, amount); st.coat -= toCoat; const leak = amount - toCoat; if (leak > 0) st.hull = Math.max(0, st.hull - leak); bus.emit('player:damage', { amount, kind }); bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' }); if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); } return true; } function drain(amount) { // continuous, silent — coat only, never kills if (!st.alive || amount <= 0) return; st.coat = Math.max(0, st.coat - amount); } // Unconditional death — lethal hazards (C's reflux finale) call this. Deliberately ignores // iframes: boost-dodging THROUGH an acid wall isn't a read, it's a bug C's design forbids. function kill(kind = 'hazard') { if (!st.alive) return; st.coat = 0; st.hull = 0; st.alive = false; bus.emit('player:death', { s: st.s, kind }); bus.emit('audio:cue', { name: 'death' }); } // Disc-velocity impulse (u/s) — hazards push the ship around with this (squeeze/gate). function shove(dx, dy) { if (!st.alive) return; st.vx += dx; st.vy += dy; } // Pickup economy (balance.js §pickups): coat/hull points, boost = seconds shaved off the // cooldown (0.6 off a 2.4s cd — a recharge nudge, not a free boost). function refill({ coat = 0, hull = 0, boost = 0 } = {}) { if (!st.alive) return; st.coat = Math.min(T.coat.max, st.coat + coat); st.hull = Math.min(T.hull.max, st.hull + hull); st.boostCd = Math.max(0, st.boostCd - boost); } // --- the loop --------------------------------------------------------------------- function update(dt) { const intent = input.sample(); const mouse = input.takeAim(); const biome = world.biomeAt(st.s); if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; } // throttle: a lean, springing back to neutral if (intent.throttle) st.throttle += intent.throttle * T.flow.throttleRate * dt; else st.throttle += (1 - st.throttle) * Math.min(1, T.flow.throttleReturn * dt * 4); st.throttle = THREE.MathUtils.clamp(st.throttle, T.flow.throttleMin, T.flow.throttleMax); // boost st.wallCd = Math.max(0, st.wallCd - dt); st.boostCd = Math.max(0, st.boostCd - dt); st.boostT = Math.max(0, st.boostT - dt); st.iframeT = Math.max(0, st.iframeT - dt); if (intent.boost && st.boostCd <= 0) { st.boostT = T.boost.duration; st.boostCd = T.boost.cooldown; st.iframeT = T.boost.iframes; bus.emit('player:boost', { s: st.s }); bus.emit('audio:cue', { name: 'boost' }); } const boostTarget = st.boostT > 0 ? 1 : 0; st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt); // surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex // displacement — see NOTES, it's charter-specified but missing from the TECH contract. const pulse = world.flowPulse ? world.flowPulse(st.s) : 0; st.onCrest = pulse > T.surf.threshold; const surfTarget = st.onCrest ? 1 : 0; if (Math.abs(surfTarget - st.surfBlend) > 0.001) { const was = st.surfBlend > 0.5; st.surfBlend += (surfTarget - st.surfBlend) * Math.min(1, T.surf.ramp * dt); if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s }); } // --- forward motion: the flow carries you; you only ever scale it --- // Surf is a SPEED-LOCK onto the crest, not a bonus on top of throttle (round-2 ruling #1; // round 1 proved a flat bonus is self-cancelling). The wave now outruns you // (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the // wave's own speed* — which is also why you stay on it instead of shooting off the front. const flowSpeed = biome.flow * st.throttle; const crest = world.crestSpeed(st.s); // contract v1.1 law: CREST_FACTOR × flow(s) // Only ever accelerates. If you're already faster than the crest (boosting, or a fast // segment), the wave must not brake you — it just stops being relevant. const carried = crest > flowSpeed ? flowSpeed + (crest - flowSpeed) * st.surfBlend * T.surf.authority : flowSpeed; st.speed = carried + biome.flow * T.boost.gain * st.boostBlend; // boost punches out of a crest st.s = clampS(st.s + st.speed * dt); // disc movement st.vx += intent.disc.x * T.disc.accel * dt; st.vy += intent.disc.y * T.disc.accel * dt; const damp = Math.exp(-T.disc.damping * dt); st.vx *= damp; st.vy *= damp; const sp = Math.hypot(st.vx, st.vy); if (sp > T.disc.maxSpeed) { st.vx *= T.disc.maxSpeed / sp; st.vy *= T.disc.maxSpeed / sp; } st.x += st.vx * dt; st.y += st.vy * dt; const frame = world.sample(st.s); discToWorld(_pos, frame, st.x, st.y); collideWall(frame); // aim const aimIn = { x: mouse.x * T.aim.sensitivity + intent.aimStick.x * T.aim.padSpeed * dt, y: -mouse.y * T.aim.sensitivity + intent.aimStick.y * T.aim.padSpeed * dt, }; st.ax += aimIn.x; st.ay += aimIn.y; if (!aimIn.x && !aimIn.y) { // hands off: drift back to centre const k = Math.min(1, T.aim.recenter * dt); st.ax -= st.ax * k; st.ay -= st.ay * k; } const ar = Math.hypot(st.ax, st.ay); if (ar > T.aim.range) { st.ax *= T.aim.range / ar; st.ay *= T.aim.range / ar; } const aimFrame = world.sample(clampS(st.s + T.aim.distance)); discToWorld(_aim, aimFrame, st.ax, st.ay); reticle.mesh.position.copy(_aim); if (camera) reticle.mesh.lookAt(camera.position); reticle.setHot(intent.fire ? 1 : 0); // the ship's nose chases the reticle rather than snapping to it (Star Fox lag) if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; } _aimLag.lerp(_aim, Math.min(1, T.aim.lag * dt)); // coat: biome ambient drain, then delayed regen st.sinceDamage += dt; drain((biome.coatDrain ?? 0) * dt); if (st.sinceDamage > T.coat.regenDelay) st.coat = Math.min(T.coat.max, st.coat + T.coat.regen * dt); poseShip(dt, frame); updateCamera(dt, frame); emitState(biome); } // Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the // flow's and is untouched — scraping a fold costs coat and control, not your run. function collideWall(frame) { const hit = world.collide(_pos, T.ship.radius); if (!hit) return; const rho = Math.hypot(st.x, st.y); if (rho < 1e-5) return; const ux = st.x / rho, uy = st.y / rho; // outward radial, in disc coords st.x -= ux * hit.push.length(); // back inside the wall st.y -= uy * hit.push.length(); const vr = st.vx * ux + st.vy * uy; // >0 = travelling into the wall if (vr > 0) { st.vx -= (1 + T.wall.restitution) * vr * ux; st.vy -= (1 + T.wall.restitution) * vr * uy; } st.vx -= ux * T.wall.shoveOut; st.vy -= uy * T.wall.shoveOut; if (vr > T.wall.grazeFloor && st.wallCd <= 0) { damage(Math.min(T.wall.maxHit, (vr - T.wall.grazeFloor) * T.wall.damagePerSpeed), hit.kind); st.wallCd = T.wall.cooldown; bus.emit('audio:cue', { name: 'wall_scrape' }); } discToWorld(_pos, frame, st.x, st.y); } function poseShip(dt, frame) { const rollTarget = THREE.MathUtils.clamp(-st.vx * T.bank.gain, -T.bank.max, T.bank.max); st.roll += (rollTarget - st.roll) * Math.min(1, T.bank.rate * dt); st.pitch += (st.vy * T.bank.pitchGain - st.pitch) * Math.min(1, T.bank.rate * dt); ship.group.position.copy(_pos); ship.group.up.copy(frame.nor); ship.group.lookAt(_aimLag); // hull is authored nose-toward −Z, so this aims it ship.group.rotateZ(st.roll); // roll about the view axis = bank ship.group.rotateX(st.pitch); ship.setThrust(THREE.MathUtils.clamp((st.throttle - 0.6) / 0.8, 0, 1) * 0.6 + st.boostBlend * 0.4); } // Chase cam on the parallel-transport frame. up = frame.nor, ALWAYS — never world-up. // If this ever rolls or snaps, the frame is the suspect, not this function (charter: file // it to Lane A rather than patching here). const cam = { s: 0, x: 0, y: 0, fov: T.cam.fov, init: false }; function updateCamera(dt, frame) { if (!camera) return; if (!cam.init) { cam.s = st.s - T.cam.back; cam.x = st.x; cam.y = st.y; cam.init = true; } cam.s = clampS(st.s - T.cam.back); const k = Math.min(1, T.cam.discLag * dt); cam.x += (st.x * T.cam.discFollow - cam.x) * k; cam.y += (st.y * T.cam.discFollow - cam.y) * k; const cf = world.sample(cam.s); discToWorld(_camPos, cf, cam.x, cam.y + T.cam.up); camera.position.copy(_camPos); const lf = world.sample(clampS(st.s + T.cam.lookAhead)); discToWorld(_look, lf, st.x * 0.5, st.y * 0.5); _look.lerp(_tmp.copy(_aimLag), T.cam.aimInfluence); camera.up.copy(frame.nor); camera.lookAt(_look); const fovTarget = T.cam.fov + st.boostBlend * T.boost.fovKick; if (Math.abs(fovTarget - cam.fov) > 0.01) { cam.fov += (fovTarget - cam.fov) * Math.min(1, T.cam.fovRate * dt); camera.fov = cam.fov; camera.updateProjectionMatrix(); } } // Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One // small object per frame; treat it as read-only, don't retain it. function emitState(biome) { bus.emit('player:state', { coat: st.coat, coatMax: T.coat.max, hull: st.hull, hullMax: T.hull.max, speed: st.speed, flow: biome.flow, throttle: st.throttle, boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown, surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0, s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0, biome: biome.id, alive: st.alive, }); } bus.emit('player:spawn', { s: st.s }); return { update, state: st, // combat/ reads this for fire/fire2. Safe because input.sample() is called exactly once // per frame, here, and boot runs player.update() before combat.update() — so the // edge-triggered flags (fire2) are still live when the weapons read them this frame. get intent() { return input.intent; }, get _input() { return input; }, // harness only (flight/dev.js) — see input.js._debug get position() { return _pos; }, get aimPoint() { return _aim; }, get radius() { return T.ship.radius; }, get forward() { return world.sample(st.s).tan; }, damage, drain, kill, shove, refill, respawn(s = 0) { Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true }); cam.init = false; aimLagInit = false; bus.emit('player:spawn', { s }); }, dispose() { scene.remove(ship.group, reticle.mesh); ship.dispose(); reticle.dispose(); input.dispose(); }, }; }