guts/web/js/flight/player.js
type-two 4ee6ae49a1 [lane B+E+F] Arena mode: in a room, you own the forward axis
The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.

The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.

- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
  12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
  wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
  respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.

Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.

Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:06 +10:00

341 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
vs: 0, // AXIAL velocity — arena mode only (tube mode: flow owns 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);
// Which mode is the anatomy in? 'arena' is an authored room (level.arenas) or a segment
// C marked `mode: "open"` — both opt-in, so a level that declares neither behaves exactly
// as it always has. This is the guard the header has promised since round 1.
const inArena = world.modeAt ? world.modeAt(st.s) === 'arena' : false;
// 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.
// Never in a room: a chamber has no travelling wave to ride, and letting surfBlend hang on
// would keep speed-locking you to a crest that isn't there.
const pulse = (!inArena && world.flowPulse) ? world.flowPulse(st.s) : 0;
st.onCrest = !inArena && 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.
if (inArena) {
// ARENA: you own the forward axis. `intent.throttle` is the raw lean (-1..1), so the same
// stick that trims your speed in a tube becomes bidirectional thrust in a room — hold back
// and you stop, hold further and you reverse. st.speed goes SIGNED here, which is honest:
// the HUD reading -4.2 u/s means you are backing out of the pylorus, and it is true.
st.vs += intent.throttle * T.arena.thrust * dt;
st.vs *= Math.exp(-T.arena.damping * dt);
const vmax = T.arena.maxSpeed * (1 + T.boost.gain * st.boostBlend);
st.vs = THREE.MathUtils.clamp(st.vs, -vmax, vmax);
st.speed = st.vs;
st.s = clampS(st.s + st.vs * dt);
} else {
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);
st.vs = st.speed; // so leaving a room into a tube doesn't inherit a stale axial velocity
}
// 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, inArena);
}
// 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, inArena = false) {
bus.emit('player:state', {
coat: st.coat, coatMax: T.coat.max,
hull: st.hull, hullMax: T.hull.max,
// `flow` reads 0 in a room, because in a room there is none — printing the biome's
// current next to a throttle that is really thrust would be a lie on the instrument.
speed: st.speed, flow: inArena ? 0 : biome.flow, throttle: st.throttle,
mode: inArena ? 'arena' : 'tube',
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, vs: 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();
},
};
}