Landed before the cut: surf as a speed-lock onto world.crestSpeed(s) with surf.authority (ruling #1) — riding beats throttle and holding a crest is throttle discipline; tuning.js header corrected to the real wallRho frame (C's flag); enemies.js reads rho as a FRACTION of safe radius (C's placement law); balance.js hazard + pickup constants; NEW hazards.js (reflux_surge / aortic_squeeze / ring_gate, self-scheduling telegraphs, reset(fromS) built for respawn) and NEW pickups.js (all five kinds, shape-coded). Cut off before: wiring — nothing imports hazards/pickups yet; the fiction-id -> archetype resolve (round-2 task #1) does NOT exist despite the enemies.js comment saying index.js does it; player.kill/shove/refill are called but were never added; checkpoint/respawn and gate -> level:complete not started; NOTES unwritten. F lands the glue next commit. Committed by F to protect the shared tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
13 KiB
JavaScript
297 lines
13 KiB
JavaScript
// 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);
|
||
|
||
// `world.crestSpeed(s)` is world contract v1.1, but Lane A's world had not exposed it yet
|
||
// when this was written (their round-2 task #1, in flight — the stub already complies). The
|
||
// fallback mirrors TECH §Crest-speed law rather than inventing a number, so surf behaves
|
||
// identically the moment A lands theirs, and this guard can then be deleted.
|
||
const CREST_FACTOR = 1.6;
|
||
const crestSpeedAt = (s, biome) =>
|
||
(world.crestSpeed ? world.crestSpeed(s) : biome.flow * CREST_FACTOR);
|
||
|
||
// --- 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);
|
||
}
|
||
|
||
// --- 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 = crestSpeedAt(st.s, biome);
|
||
// 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,
|
||
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();
|
||
},
|
||
};
|
||
}
|