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>
87 lines
3.1 KiB
JavaScript
87 lines
3.1 KiB
JavaScript
// flight/ship.js (Lane B) — ENDO-1, the probe you fly, and the reticle you aim with.
|
||
//
|
||
// Procedural primitives merged into ONE geometry: assets are optional by law (TECH §manifest),
|
||
// so this is the shipping look until Lane D's GLB lands, and the permanent fallback after.
|
||
// Convention: the hull is built nose-toward −Z so Object3D.lookAt() aims it with no fudge.
|
||
|
||
import * as THREE from 'three';
|
||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||
import { emissiveMat, EMISSIVE } from './emissive.js';
|
||
|
||
// D's GLB swaps in here when it exists; logic above never learns which one it got.
|
||
export function buildShip(assets = null) {
|
||
const group = new THREE.Group();
|
||
group.name = 'endo1';
|
||
|
||
const glb = assets?.get?.('models', 'endo1') ?? null;
|
||
const hull = glb ? glb.scene ?? glb : proceduralHull();
|
||
group.add(hull);
|
||
|
||
// engine bloom at the tail: additive, unlit, reads as thrust without a light
|
||
const glowGeo = new THREE.SphereGeometry(0.34, 12, 8);
|
||
const glowMat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.9 });
|
||
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||
glow.position.z = 0.95;
|
||
group.add(glow);
|
||
|
||
const disposables = [glowGeo, glowMat];
|
||
if (!glb) { disposables.push(hull.geometry, hull.material); }
|
||
|
||
return {
|
||
group,
|
||
glow,
|
||
setThrust(t) { glow.scale.setScalar(0.7 + t * 0.9); glowMat.uniforms.uOpacity.value = 0.5 + t * 0.5; },
|
||
setFlash(v) { if (hull.material?.uniforms?.uFlash) hull.material.uniforms.uFlash.value = v; },
|
||
dispose() { for (const d of disposables) d?.dispose?.(); },
|
||
};
|
||
}
|
||
|
||
function proceduralHull() {
|
||
const parts = [];
|
||
|
||
const body = new THREE.CapsuleGeometry(0.4, 0.9, 4, 12);
|
||
body.rotateX(Math.PI / 2); // capsule is +Y by default; we fly down −Z
|
||
parts.push(body);
|
||
|
||
const nose = new THREE.ConeGeometry(0.4, 0.85, 12);
|
||
nose.rotateX(-Math.PI / 2);
|
||
nose.translate(0, 0, -1.08);
|
||
parts.push(nose);
|
||
|
||
for (const side of [-1, 1]) {
|
||
const fin = new THREE.BoxGeometry(0.62, 0.07, 0.44);
|
||
fin.translate(side * 0.52, -0.04, 0.36);
|
||
fin.rotateZ(side * -0.22);
|
||
parts.push(fin);
|
||
}
|
||
|
||
const collar = new THREE.TorusGeometry(0.42, 0.06, 6, 14);
|
||
collar.translate(0, 0, -0.28);
|
||
parts.push(collar);
|
||
|
||
const geo = mergeGeometries(parts, false);
|
||
for (const p of parts) p.dispose();
|
||
return new THREE.Mesh(geo, emissiveMat(EMISSIVE.neutral));
|
||
}
|
||
|
||
// The reticle floats TUNING.aim.distance ahead in the disc plane. Depth-test off: it is an
|
||
// instrument reading, not an object in the gut — it must never be swallowed by a fold.
|
||
export function buildReticle() {
|
||
const geo = new THREE.RingGeometry(0.55, 0.72, 20);
|
||
const mat = emissiveMat(EMISSIVE.neutral, { additive: true, opacity: 0.85 });
|
||
mat.depthTest = false;
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.renderOrder = 10;
|
||
|
||
const dotGeo = new THREE.CircleGeometry(0.12, 10);
|
||
const dot = new THREE.Mesh(dotGeo, mat);
|
||
dot.renderOrder = 10;
|
||
mesh.add(dot);
|
||
|
||
return {
|
||
mesh,
|
||
setHot(v) { mat.uniforms.uFlash.value = v; }, // hot while the cannon is firing
|
||
dispose() { geo.dispose(); dotGeo.dispose(); mat.dispose(); },
|
||
};
|
||
}
|