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