guts/web/js/flight/player.js
type-two b9d9d816ad [lane B+E] Stop the game lying: the death sting at living players, the stuck hiss, and a crew naming the wrong organ
Four audio/comms cues wired to the wrong condition. All four are written content that has been
in the repo for rounds, firing at the wrong moment or never firing at all.

THE DEATH STING PLAYED AT LIVING PLAYERS. finish() mapped every non-win outcome to `death` — and
`death` is not a stinger, it is a ~12 s noise table (synth.js). So surviving L1's Mast Cell
("degranulated"), outrunning the Tapeworm ("escaped") and clearing the Sovereign's Toll all told
an alive player they had died, over the top of the next twelve seconds of play. Outcomes are
classified now: won/spared/communion triumph, alive-but-it-went-badly gets a downbeat, actually
dead gets the sting, and housekeeping (reset/dispose) is silent because nothing happened to the
player at all. Verified: surviving the Mast Cell no longer emits `death`; dying still does.

THE PHASE TELL WAS A SUSTAINING HISS. boss.js used `overheat` as its phase-change cue, but that
layer sustains until the engine sends `overheat_clear` on a combat:state.overheated falling edge
— which a boss never produces. Every phase change leaked a ~12 s steam hiss and ducked the mix
under it for the rest of the fight. Three sites, now short cues.

THE CREW HAS NEVER MENTIONED HULL DAMAGE. comms.js gates its two hull lines on
`kind === 'hull_hit'`, but `kind` is the damage SOURCE ('dart'|'acid'|'gas'…), so that branch has
never once been true in the game's life and every hit read as a coat graze. damage() already
computes `leak > 0` for its own audio cue — the bus event now carries it as `hull`.

AND IT NAMED THE WRONG ORGAN. There was exactly ONE boss line — "that is the pylorus. it does not
open for you." — fired for all five bosses, so the tutorial's Mast Cell and the campaign's final
boss were both announced as a stomach valve. Per-boss lines now, off boss:start (which is the only
place the id is known, and for the Sovereign whether there is a fight at all). The Mast Cell's is
doing real work: "wait — wait. that is a mast cell. it is not hunting you." / "do NOT shoot it."
The crew is the game's only tutorialisation channel, and that is the lesson L1 exists to teach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 19:45:29 +10:00

354 lines
16 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);
// `hull` is the fact the audio cue below has always had and the bus event never carried:
// comms.js has two hull lines gated on `kind === 'hull_hit'`, but `kind` is the damage
// SOURCE ('dart'|'acid'|'gas'…), so that branch has never once been true and the crew has
// never mentioned hull damage in the game's life.
bus.emit('player:damage', { amount, kind, hull: leak > 0 });
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;
// pH: inside an acid sea's span, ambient drain is scaled by SUBMERSION (Lane A's own B-hook,
// world/acid.js depthAt > 0 = under the surface). Gated on the spec's s-span and not on
// depthAt alone, because depthAt returns a bare -1 outside the span and that is
// indistinguishable from "1 unit above the surface" — reading it as the latter would quietly
// scale the drain on every level that has no sea at all.
const sea = world.acid;
const inSea = !!sea && st.s >= sea.spec.from && st.s <= sea.spec.to;
st.submerged = inSea && sea.depthAt(_pos) > 0;
const phMult = !inSea ? 1 : (st.submerged ? T.coat.acidMult : T.coat.surfaceMult);
drain((biome.coatDrain ?? 0) * phMult * 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();
},
};
}