guts/web/js/flight/input.js
jing 0a038582a7 [lane B] Round 1: flight controller, cannon, enemy framework on the stub
Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked
forward motion, throttle as a spring-back lean, boost + i-frames, banking chase
cam on the parallel-transport frame, arcade wall response (shove + graze damage,
never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc,
mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported.

Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with
hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework
with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's
level:event. Coat/hull model and the full bus surface E builds against.

Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0);
boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s
with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80);
0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF
does not run in the automated pane. Evidence: docs/shots/laneB/.

Three findings escalated in LANE_B_NOTES:
- qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax
  gate has never checked anything. I shipped a real SyntaxError past a GREEN qa.
  Fix + verification handed to F.
- Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than
  the player (19.6 u/s), so level 2's signature mechanic loses to mashing
  throttle. Not tunable from this lane; escalated to A/F with options.
- Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's
  hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls
  inherit it.

js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it
retires once F pastes the snippet in LANE_B_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:56:32 +10:00

149 lines
6.1 KiB
JavaScript
Raw Permalink 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/input.js (Lane B) — raw devices -> one normalized intent object. No game logic
// here; player.js reads `intent` and never touches an event listener.
//
// The scheme is twin-stick, which is what makes the charter's "mouse-aim + WASD" and
// "gamepad twin-stick" the same game rather than two:
// left hand (WASD / left stick) -> move the ship inside the cross-section disc
// right hand (mouse / right stick) -> aim the reticle (the ship's nose follows it, lagged)
// Shift/Ctrl (or LT/RT) -> throttle, springs back to neutral when released
// Space / A / RB -> boost
// LMB / RT / X -> fire
// WASD is therefore NOT throttle. Throttle is a modifier because it lives in [0.6,1.4] and
// spends most of its life at 1.0 — it's a lean, not a stick.
const DEADZONE = 0.18;
const dz = (v) => (Math.abs(v) < DEADZONE ? 0 : (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE));
export function createInput(dom = document.body) {
const keys = new Set();
const intent = {
disc: { x: 0, y: 0 }, // 1..1 desired disc movement (x = lateral/bin, y = up/nor)
aimDelta: { x: 0, y: 0 }, // mouse pixels accumulated since the last read (consumed)
aimStick: { x: 0, y: 0 }, // 1..1 right stick
throttle: 0, // 1..1 (Ctrl .. Shift)
boost: false, // edge-triggered: true for exactly one read
fire: false, // held
fire2: false, // edge-triggered
locked: false, // pointer lock state (the "am I flying" signal)
};
// Held inputs are tracked per-device and OR'd each sample. Sharing one flag would latch:
// the trigger sets it true, and only mouse-up ever sets it false again.
let mouseFire = false, padFire = false;
let boostEdge = false, fire2Edge = false, padBoostWas = false, padFire2Was = false;
const onKeyDown = (e) => {
keys.add(e.code);
if (e.code === 'Space') { boostEdge = true; e.preventDefault(); }
if (e.code === 'KeyF') fire2Edge = true;
};
const onKeyUp = (e) => keys.delete(e.code);
const onBlur = () => keys.clear(); // alt-tab must not leave a key stuck down
const onMouseMove = (e) => {
if (!intent.locked) return;
intent.aimDelta.x += e.movementX;
intent.aimDelta.y += e.movementY;
};
const onMouseDown = (e) => {
if (!intent.locked) { dom.requestPointerLock?.(); return; }
if (e.button === 0) mouseFire = true;
if (e.button === 2) fire2Edge = true;
};
const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; };
const onLockChange = () => {
intent.locked = document.pointerLockElement === dom;
if (!intent.locked) { mouseFire = false; keys.clear(); }
};
const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu
addEventListener('keydown', onKeyDown);
addEventListener('keyup', onKeyUp);
addEventListener('blur', onBlur);
addEventListener('mousemove', onMouseMove);
dom.addEventListener('mousedown', onMouseDown);
addEventListener('mouseup', onMouseUp);
document.addEventListener('pointerlockchange', onLockChange);
dom.addEventListener('contextmenu', onContext);
function readPad() {
padFire = false;
const pads = navigator.getGamepads?.() || [];
for (const p of pads) {
if (!p?.connected) continue;
const [lx, ly, rx, ry] = p.axes;
// left stick wins over WASD only when actually deflected, so both can coexist
const dx = dz(lx ?? 0), dy = dz(ly ?? 0);
if (dx || dy) { intent.disc.x = dx; intent.disc.y = -dy; }
intent.aimStick.x = dz(rx ?? 0);
intent.aimStick.y = -dz(ry ?? 0);
const lt = p.buttons[6]?.value ?? 0, rt = p.buttons[7]?.value ?? 0;
if (lt > 0.05 || rt > 0.05) intent.throttle = rt - lt;
padFire = rt > 0.5;
const padBoost = !!(p.buttons[0]?.pressed || p.buttons[5]?.pressed);
if (padBoost && !padBoostWas) boostEdge = true;
padBoostWas = padBoost;
const padFire2 = !!p.buttons[2]?.pressed;
if (padFire2 && !padFire2Was) fire2Edge = true;
padFire2Was = padFire2;
return;
}
}
return {
intent,
// Called once per frame by player.js, before it reads `intent`.
sample() {
intent.disc.x = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
intent.disc.y = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0);
intent.aimStick.x = 0; intent.aimStick.y = 0;
intent.throttle = (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1 : 0) -
(keys.has('ControlLeft') || keys.has('ControlRight') ? 1 : 0);
readPad();
intent.fire = mouseFire || padFire;
// normalize diagonal so corner-dodging isn't 41% faster than a cardinal one
const m = Math.hypot(intent.disc.x, intent.disc.y);
if (m > 1) { intent.disc.x /= m; intent.disc.y /= m; }
intent.boost = boostEdge; boostEdge = false;
intent.fire2 = fire2Edge; fire2Edge = false;
return intent;
},
// player.js consumes the accumulated mouse delta exactly once per frame
takeAim() {
const d = { x: intent.aimDelta.x, y: intent.aimDelta.y };
intent.aimDelta.x = 0; intent.aimDelta.y = 0;
return d;
},
// Harness/test hook (flight/dev.html). Keyboard intents can be driven with real
// KeyboardEvents, but firing is gated behind pointer lock, which an automated session
// can't be granted — so the trigger needs a door. Dev-only; nothing ships against it.
_debug: {
setFire(v) { mouseFire = !!v; },
pulseFire2() { fire2Edge = true; },
pulseBoost() { boostEdge = true; },
key(code, down = true) { down ? keys.add(code) : keys.delete(code); },
},
dispose() {
removeEventListener('keydown', onKeyDown);
removeEventListener('keyup', onKeyUp);
removeEventListener('blur', onBlur);
removeEventListener('mousemove', onMouseMove);
dom.removeEventListener('mousedown', onMouseDown);
removeEventListener('mouseup', onMouseUp);
document.removeEventListener('pointerlockchange', onLockChange);
dom.removeEventListener('contextmenu', onContext);
if (document.pointerLockElement === dom) document.exitPointerLock?.();
},
};
}