guts/web/js/flight/player.js
type-two 87a4803bde [lane A+B+C] L3 Stomach: the acid sea, and pH as a resource you can see
The last skeleton. L3 has been 4 events since round 1, waiting (in its own status field) on
"the pH field" and a boss framework; both exist now, so the level is authored: 46 events,
2500 units, the sea, and the Guardian at the end of it.

THE PH MECHANIC, finally load-bearing. Lane A built an acid plane in round 2 and left two
hooks on it — depthAt() labelled "B:" and set() labelled "C's event pump drives this" — and
nothing had ever called either. Now:
  - player.js scales the biome's ambient coat drain by SUBMERSION. Stomach 2.2/s x5 = 11/s
    under the surface (measured: 8/s net of regen, and regen is off for 2.5s after any hit,
    so it is brutal mid-fight); x0.15 above it, which is effectively safe air.
  - a new `acid` level event names a target height and boot eases the sea toward it in step(),
    so a RISING TIDE is a beat C can author. L3 uses two: -14 -> -6 mid-sea, then -6 -> +2 in
    the antrum, where the surface goes ABOVE the centreline and the safe lane simply stops
    existing. Measured: the identical spot (y -5) goes from 0/s to 9.67/s across that tide.
This is the GDD's "mucus shallows are safe lanes" expressed as DEPTH rather than wall-hugging,
which is better on two counts: it reads instantly (you can SEE the surface) and it makes the
vertical axis matter in the one level that is a room. Every collectible worth having is under it.

Gated on the sea's s-span, not on depthAt alone — depthAt returns a bare -1 outside the span
and that is indistinguishable from "1 unit above the surface", so reading it naively would have
quietly scaled ambient drain on every level in the game. Caught before it shipped.

Level: cardia (still teal, so the fundus can REVEAL the sea) -> fundus (the teaching room:
huge, low tide, nothing kills you fast) -> body (the sea proper, tide rises while you are in
it) -> antrum (no safe air left, only speed) -> pyloric antrum (the Guardian). H. pylori is
taught alone in the dome and tested in a rising sea; pepsin swarms scale 3 -> 6; bezoar_chunk
is a new catalogue row for the stomach's traffic.

L3 has NO commensals and no biofilm gate, which is anatomically exact — acid sterilises, which
is why H. pylori is famous for surviving it. So BIOME STANDING goes quiet for nine minutes and
comes back in L4, which makes L4's colonies read as relief rather than furniture.

C's sim: par 540s vs par-pace 555s ("requires pushing above par-pace"), pressure
3.75/1.50/2.44/2.16, min clearance 7.15 vs the 2.5 law, all 21 checkpoint gaps under 30s.
par.score corrected 9000 -> 5500, because 9000 was above everything the level can actually pay.

Deferred, and said out loud rather than quietly dropped: churn cyclones. A vortex that flings
the player is a FORCE and no hazard in the tree applies one; it wants the same A/B work as the
Guardian's arena churn and should land with it.

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

350 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);
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;
// 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();
},
};
}