diff --git a/ROADMAP.md b/ROADMAP.md index a5b13d0..e954222 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,6 +24,10 @@ was folded directly into `storyline` rather than merged through the PR itself. technical audit + six follow-up rounds (see "Technical audit & hardening" below) — it is not one of the numbered phases. +**`space`** branches off `storyline` for the Act 1 space-layer pass described +in Phase 11.8 below (ship collision, asteroid fracture/debris, space mining, +space-POI narrative content) — not yet merged back or manually playtested. + --- ## Completed phases @@ -119,6 +123,59 @@ to the resource/crafting system below despite sharing the "Phase 11" label. | 11.6 — Backpack fidelity + offline progression | **Implemented, pending playtest** | PR #8 Round 6 (Minecraft-style grid inventory; extractor/condenser passive production with capped offline catch-up) delivers this sub-phase's actual goals, but was committed as part of the audit PR, not tagged `11.6`. The PR body itself flags that manual in-game playtesting of placing/producing/offline-catch-up has not been done — this is currently being manually playtested. | | 11.7 — Balance, persistence hardening, perf pass | **Partially covered, unlabeled — not a dedicated pass** | PR #8 Round 1 covers real ground here (persistence bug fixes: cargo capacity, resource duplication, ground-drop loss; perf: telemetry decoupling, capped raycasts, fewer setState calls) but there has been no dedicated economy-balance or full regression pass across all 16 bodies. | +### Phase 11.8 — Act 1 space layer (flight collision, asteroid fracture, space mining, space narrative) — branch `space`, unmerged +Built to make the Solar System (Act 1) feel like an archaeological, structured +place to fly through rather than empty traversal space, in strict priority +order: flight feel first, then exploration density, then narrative, then +performance — extending `shipPhysics.ts`/`ShipController.tsx`/`AsteroidBelt.tsx`/ +`quality.ts` rather than adding a parallel physics engine or ECS. + +- **Ship collision**: the ship previously flew straight through planets and + asteroids with zero physical response. It now slides off both on contact — + an analytic sphere push-out + tangential-velocity-retain correction, never + an added force, so the "Phase 11: pure thrust, no gravity" flight model is + untouched. Asteroid collision is broadphased through a new cylindrical + spatial grid (radial × angular bins tuned to the belt's thin-torus shape) + instead of scanning up to 9000 instances. +- **Flight-feel juice**: fixed the thruster glow (previously a random flicker + unrelated to throttle) to track actual throttle; added a pooled + engine-exhaust particle trail and a throttle-reactive engine hum. +- **Deterministic, structured belt**: the belt's instance placement was + unseeded `Math.random()` (reshuffled on every quality change). Replaced with + a pure function of `(tier, variant, index, seed)` using the existing + `cellHash`/`seedFromName` primitives, so a given index always resolves to + the same rock — required for asteroid collision/fracture state to stay in + sync with the render matrices, and for narrative content to anchor to a + specific asteroid. Placement is also sector-weighted (24 angular sectors, + deterministic gap/sparse/normal/dense density) so the belt reads as + corridors and clusters rather than uniform noise. +- **Asteroid fracture + debris**: asteroids now hold real health instead of + being binary — a hit that doesn't finish one off just wears it down (no + debris); a lethal hit spawns 1–5 debris fragments, or 4–8 on a big-overkill + hit, with deterministically hashed (not `Math.random()`) spawn directions/ + sizes. Debris never recursively fractures. A pooled, quality-budgeted debris + population drifts freely via the ship's existing `integrate()` helper and + sticks-and-expires on any contact rather than continuing to bounce. +- **Space mining**: a raycast beam (mirroring the voxel mining crosshair + convention) feeds sustained fire into the same fracture-damage pipeline + collision damage uses. A fraction of any fracture's debris comes back as + collectible ore with a resource type from the existing inventory system; + ore chunks home toward the ship within range and collect into the same + backpack voxel mining uses. +- **Space narrative/POI layer**: a small, fixed set of space POIs (wreckage, + a signal anomaly reusing the existing cross-body "signal" mystery, a + resource cluster, a distant landmark), discovered by proximity while + piloting and recorded through the *existing* `recordDiscovery` journal with + `planet: 'space'` — no new store field, no new narrative system. Wreckage + can anchor to a specific, stable belt asteroid and flags it indestructible. +- Not done / explicitly deferred: crater cosmetics on sub-lethal hits, mining + muzzle-flash VFX, and space-POI density scaling were all cut as + cosmetic-only per the "cut simulation complexity first" performance rule — + no quality-tier fields were added for capabilities that don't exist yet. + Manual in-game/visual playtesting has not been done (this environment + cannot render WebGPU live); flight feel, collision weight, frame time in a + dense belt, and fracture-burst cost all need a live-device check. + ### Phase 12 — Procedural galaxy Travel beyond the solar system into procedurally generated star systems, each with its own unique planets to discover and explore. Not started. diff --git a/src/App.tsx b/src/App.tsx index 5b6bbfe..db7c2dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useStore } from './store'; -import { loadInventory, loadItems, loadSeen, loadMode, loadUpgrades } from './voxel/persistence'; +import { loadInventory, loadItems, loadSeen, loadMode, loadUpgrades, loadActiveTool, loadFlashlightOn } from './voxel/persistence'; import { DEFAULT_UPGRADES, type ShipUpgrades } from './ship/upgrades'; import { SolarSystem } from './scene/SolarSystem'; import { HUD } from './ui/HUD'; @@ -13,6 +13,7 @@ import { VoxelAudio } from './audio/VoxelAudio'; import { SettingsPanel } from './ui/SettingsPanel'; import { Labels } from './ui/Labels'; import { ShipHUD } from './ui/ShipHUD'; +import { SpaceCrosshair } from './ui/SpaceCrosshair'; import { DescentOverlay } from './ui/DescentOverlay'; import { SurfaceHUD } from './ui/SurfaceHUD'; import { VoxelHUD } from './ui/VoxelHUD'; @@ -43,6 +44,12 @@ export default function App() { loadUpgrades().then((saved) => { if (saved) useStore.getState().setShipUpgrades({ ...DEFAULT_UPGRADES, ...saved } as ShipUpgrades); }); + loadActiveTool().then((tool) => { + if (tool) useStore.getState().setActiveTool(tool); + }); + loadFlashlightOn().then((on) => { + if (on) useStore.getState().setFlashlightOn(on); + }); }, []); return ( @@ -53,6 +60,7 @@ export default function App() { + diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 9760468..58f466f 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -15,6 +15,8 @@ class AudioManager { private master?: GainNode; private droneGain?: GainNode; private droneFilter?: BiquadFilterNode; + private engineGain?: GainNode; + private engineFilter?: BiquadFilterNode; private started = false; // Surface ambience graph (built lazily on first landing, reused after). @@ -118,6 +120,23 @@ class AudioManager { this.droneGain = droneGain; this.droneFilter = droneFilter; + // --- Ship engine hum: gain/pitch track throttle while piloting. --- + const engineFilter = ctx.createBiquadFilter(); + engineFilter.type = 'lowpass'; + engineFilter.frequency.value = 300; + const engineGain = ctx.createGain(); + engineGain.gain.value = 0; + engineFilter.connect(engineGain).connect(master); + const engineOsc = ctx.createOscillator(); + engineOsc.type = 'sawtooth'; + engineOsc.frequency.value = 60; + const engineOscGain = ctx.createGain(); + engineOscGain.gain.value = 0.6; + engineOsc.connect(engineOscGain).connect(engineFilter); + engineOsc.start(); + this.engineGain = engineGain; + this.engineFilter = engineFilter; + this.started = true; } @@ -144,6 +163,43 @@ class AudioManager { } } + /** Ship engine hum, driven by throttle (0..1) while piloting. Same + * last-quarter-emphasis shape as the camera shake / HUD heat glow so all + * the throttle-reactive feedback kicks in together. Call with 0 when + * leaving piloting to fade it out. */ + setEngineHum(throttle: number) { + if (!this.engineGain || !this.engineFilter || !this.ctx) return; + const t = this.ctx.currentTime; + this.engineGain.gain.setTargetAtTime(throttle * 0.05, t, 0.15); + this.engineFilter.frequency.setTargetAtTime(150 + throttle * 500, t, 0.15); + } + + /** One discrete mining-beam shot: a short laser-ish blip (sawtooth chirp + + * a touch of noise crack), pitched a bit higher and louder when the shot + * actually connects with an asteroid vs. firing into empty space — so + * automatic fire reads as a rhythm of individual shots, not a sustained + * tone, and hits vs. misses are audibly distinguishable. */ + playMiningShot(onTarget: boolean) { + if (!this.master || !this.ctx) return; + const ctx = this.ctx; + const t = ctx.currentTime; + const dur = 0.05; + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(onTarget ? 780 : 520, t); + osc.frequency.exponentialRampToValueAtTime(onTarget ? 420 : 300, t + dur); + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(onTarget ? 0.07 : 0.045, t + 0.005); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.Q.value = 3; + osc.connect(filter).connect(g).connect(this.master); + osc.start(t); + osc.stop(t + dur + 0.02); + } + // --- Surface ambience -------------------------------------------------- /** Build the persistent surface graph once: wind (filtered noise) + rumble. */ diff --git a/src/i18n.ts b/src/i18n.ts index 6509430..9e02c54 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -130,6 +130,9 @@ const STRINGS = { days: 'days', hours: 'hours', millionKm: 'million km', + pickaxe: 'Pickaxe', + gun: 'Gun', + flashlight: 'Flashlight', }, sv: { timeScale: 'Tidsskala', @@ -228,6 +231,9 @@ const STRINGS = { days: 'dygn', hours: 'timmar', millionKm: 'miljoner km', + pickaxe: 'Hacka', + gun: 'Pistol', + flashlight: 'Ficklampa', }, } as const; diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 5e84955..ada18c3 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -2,59 +2,23 @@ import { useEffect, useMemo, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import { InstancedMesh, - IcosahedronGeometry, + Mesh, MeshStandardNodeMaterial, Matrix4, Quaternion, Vector3, - Euler, type BufferGeometry, type Group, } from 'three/webgpu'; import { vec3, float } from 'three/tsl'; import { useStore } from '../store'; import { QUALITY } from '../systems/quality'; -import { WORLD_SCALE } from '../systems/bodies'; - -// Belt sits between Mars and Jupiter, kept inside Jupiter's inner moon shell so -// nothing crosses orbits. Scaled by WORLD_SCALE alongside the body layout. -const INNER = 645 * WORLD_SCALE; -const OUTER = 755 * WORLD_SCALE; -const THICKNESS = 16 * WORLD_SCALE; // full vertical spread; concentrated toward the plane - -/** - * Size/detail tiers — a realistic belt is mostly dust with a few large bodies. - * Geometry detail (poly count) scales with size so the big rocks that read up - * close are high-poly, while the abundant tiny ones stay cheap. Only the - * larger, visibly-tumbling tiers animate per frame, keeping the cost low. - */ -const TIERS = [ - { frac: 0.78, detail: 0, min: 0.12, max: 0.5, variants: 1, rotates: false }, - { frac: 0.18, detail: 1, min: 0.5, max: 1.9, variants: 2, rotates: true }, - { frac: 0.04, detail: 2, min: 1.9, max: 5.2, variants: 3, rotates: true }, -] as const; - -/** Lumpy, crack-free rock from an icosahedron: displace each vertex along its - * own direction by a smooth function of that direction, so shared seam - * vertices move identically. `seed` gives each variant a distinct shape. */ -function rockGeometry(detail: number, seed: number): BufferGeometry { - const g = new IcosahedronGeometry(1, detail); - const pos = g.attributes.position; - const v = new Vector3(); - const amp = 0.38; - for (let i = 0; i < pos.count; i++) { - v.fromBufferAttribute(pos, i).normalize(); - const lump = - Math.sin(v.x * 3.1 + seed * 1.3) * - Math.sin(v.y * 3.7 + seed * 2.1) * - Math.sin(v.z * 2.9 + seed * 0.7); - const f = 1 + amp * lump; - pos.setXYZ(i, v.x * f, v.y * f, v.z * f); - } - pos.needsUpdate = true; - g.computeVertexNormals(); - return g; -} +import { TIERS, BELT_SEED, computeTierVariantCounts, placeAsteroid } from '../systems/asteroidLayout'; +import { buildAsteroidStates } from '../systems/asteroidState'; +import { buildAsteroidGrid, removeFromGrid } from '../systems/asteroidGrid'; +import { asteroidRuntime } from './asteroidRuntime'; +import { rockGeometry } from './rockGeometry'; +import { applyDentToGeometry } from '../systems/asteroidDent'; interface RotItem { pos: Vector3; @@ -67,16 +31,58 @@ interface BeltMesh { inst: InstancedMesh; rotates: boolean; items: RotItem[]; + /** First `asteroidRuntime.states` index covered by this mesh — instance + * index `i` corresponds to global state index `globalOffset + i`. */ + globalOffset: number; +} + +/** A hit asteroid pulled out of its shared InstancedMesh into a standalone, + * individually deformable Mesh (see the promotion API below). `mesh.position` + * is synced from the corresponding `AsteroidState.pos` every frame (the + * existing knockback-drift integration in the tumble loop keeps mutating + * that shared Vector3; `Object3D.position` can't alias it directly since + * three.js declares it read-only) — spin gets its own integration here, + * since promoted asteroids leave the clock-based tumble formula behind for a + * real integrated quaternion (the momentum formula for fragment velocity + * reads `angVel`). */ +interface PromotedEntry { + mesh: Mesh; + geometry: BufferGeometry; + angVel: Vector3; + quat: Quaternion; } +const _zeroScale = new Matrix4().makeScale(0, 0, 0); + +/** Per-frame (@60fps) velocity retention for impact-knockback drift — settles + * a hit rock back to rest within a couple of seconds rather than drifting + * indefinitely. Below this squared speed, velocity snaps to exactly zero. */ +const KNOCKBACK_DAMPING = 0.9; +const KNOCKBACK_MIN_VEL_SQ = 1e-4; + +const _dentLocalPoint = new Vector3(); +const _promoteQ = new Quaternion(); +const _spinAxis = new Vector3(); +const _spinDeltaQ = new Quaternion(); + /** * Asteroid belt rendered as a handful of InstancedMeshes (a few draw calls for * thousands of rocks), split into size/detail tiers for natural variety and * level of detail. The belt drifts slowly on the sim clock; the larger rocks * also tumble individually. Instance count scales with the quality preset. + * + * Alongside the render matrices, builds the parallel per-asteroid state array + * and spatial grid (`asteroidState.ts`/`asteroidGrid.ts`) and publishes them + * on the `asteroidRuntime` singleton for ship collision/mining/fracture code + * to read every frame without subscribing to this component. Also owns the + * "promotion" mechanism (`asteroidRuntime.promotion`) that pulls a hit + * asteroid out of its shared InstancedMesh into a standalone, individually + * deformable mesh — real per-vertex local damage instead of delete+replace. */ export function AsteroidBelt() { - const count = QUALITY[useStore((s) => s.quality)].asteroids; + const quality = useStore((s) => s.quality); + const count = QUALITY[quality].asteroids; + const promotedMax = QUALITY[quality].promotedAsteroidMax; const group = useRef(null); const built = useMemo(() => { @@ -88,64 +94,178 @@ export function AsteroidBelt() { material.metalnessNode = float(0); const meshes: BeltMesh[] = []; - const geometries: BufferGeometry[] = []; + const geometryByKey = new Map(); const m = new Matrix4(); - const q = new Quaternion(); - const e = new Euler(); - - for (const tier of TIERS) { - const tierCount = Math.round(count * tier.frac); - if (tierCount === 0) continue; - // Split this tier's rocks across its shape variants. - for (let vi = 0; vi < tier.variants; vi++) { - const n = - Math.floor(tierCount / tier.variants) + - (vi < tierCount % tier.variants ? 1 : 0); - if (n === 0) continue; - const geom = rockGeometry(tier.detail, vi * 7 + tier.detail * 13 + 1); - geometries.push(geom); - const inst = new InstancedMesh(geom, material, n); - inst.frustumCulled = false; // ring is essentially always partly on-screen - const items: RotItem[] = []; - for (let i = 0; i < n; i++) { - const angle = Math.random() * Math.PI * 2; - const r = INNER + Math.random() * (OUTER - INNER); - // concentrate toward the orbital plane (rand*rand bias) - const y = (Math.random() - 0.5) * (Math.random() ** 2) * THICKNESS; - const pos = new Vector3(Math.cos(angle) * r, y, Math.sin(angle) * r); - const base = tier.min + Math.random() * (tier.max - tier.min); - // irregular, non-uniform scale so rocks aren't spheres - const scale = new Vector3( - base, - base * (0.6 + Math.random() * 0.7), - base * (0.7 + Math.random() * 0.6) - ); - e.set(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2); - q.setFromEuler(e); - m.compose(pos, q, scale); - inst.setMatrixAt(i, m); - if (tier.rotates) { - const axis = new Vector3( - Math.random() - 0.5, - Math.random() - 0.5, - Math.random() - 0.5 - ).normalize(); - items.push({ pos, scale, axis, speed: 0.05 + Math.random() * 0.25, phase: Math.random() * Math.PI * 2 }); - } + + // Single source of truth for which (tier, variant) groups exist and how + // many instances each has — shared with `buildAsteroidStates` below so + // the render loop's running global-index counter and the state array's + // indices agree without either side passing data to the other. Built + // first (not after, as before) so the render loop below can share the + // exact same position Vector3 with each state — impact-knockback physics + // mutates `state.pos` in place, and the tumble loop renders whatever + // `pos` its RotItem holds, so sharing the object means drift is rendered + // automatically with no separate update path. + const groups = computeTierVariantCounts(count); + const states = buildAsteroidStates(count); + let globalOffset = 0; + + for (const g of groups) { + const tier = TIERS[g.tierIdx]; + const geom = rockGeometry(tier.detail, g.variantIdx * 7 + tier.detail * 13 + 1); + geometryByKey.set(`${g.tierIdx}:${g.variantIdx}`, geom); + const inst = new InstancedMesh(geom, material, g.n); + inst.frustumCulled = false; // ring is essentially always partly on-screen + const items: RotItem[] = []; + for (let i = 0; i < g.n; i++) { + // Deterministic placement — see asteroidLayout.ts. Index `i` always + // resolves to the same rock regardless of quality tier. + const placed = placeAsteroid(g.tierIdx, g.variantIdx, i, BELT_SEED); + const statePos = states[globalOffset + i].pos; + m.compose(statePos, placed.quat, placed.scale); + inst.setMatrixAt(i, m); + if (tier.rotates && placed.tumbleAxis) { + items.push({ + pos: statePos, // shared with AsteroidState — see note above + scale: placed.scale, + axis: placed.tumbleAxis, + speed: placed.tumbleSpeed!, + phase: placed.tumblePhase!, + }); } - inst.instanceMatrix.needsUpdate = true; - meshes.push({ inst, rotates: tier.rotates, items }); } + inst.instanceMatrix.needsUpdate = true; + meshes.push({ inst, rotates: tier.rotates, items, globalOffset }); + globalOffset += g.n; } - return { meshes, geometries, material }; + + const grid = buildAsteroidGrid(states); + const promoted = new Map(); + + return { meshes, geometryByKey, material, states, grid, promoted }; }, [count]); + /** Zero a live instance's render matrix (renders nothing) — shared by both + * the kill path and the promotion path (promotion hides the instanced + * copy in favor of the new standalone mesh, without touching `alive`). */ + function hideInstance(globalIdx: number) { + if (!built) return; + const mesh = built.meshes.find( + (bm) => globalIdx >= bm.globalOffset && globalIdx < bm.globalOffset + bm.inst.count, + ); + if (mesh) { + mesh.inst.setMatrixAt(globalIdx - mesh.globalOffset, _zeroScale); + mesh.inst.instanceMatrix.needsUpdate = true; + } + } + + // Publish to the runtime singleton (ship collision / mining / fracture read + // it every frame) and wire the kill callback + promotion API fracture/ + // mining code uses. + useEffect(() => { + if (!built) { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.killAsteroid = null; + asteroidRuntime.promotion = null; + return; + } + asteroidRuntime.states = built.states; + asteroidRuntime.grid = built.grid; + asteroidRuntime.killAsteroid = (globalIdx: number) => { + const state = built.states[globalIdx]; + if (!state || !state.alive) return; + hideInstance(globalIdx); + if (built.grid) removeFromGrid(built.grid, globalIdx, state.pos.x, state.pos.z); + state.alive = false; + const entry = built.promoted.get(globalIdx); + if (entry) { + group.current?.remove(entry.mesh); + entry.geometry.dispose(); + built.promoted.delete(globalIdx); + } + }; + asteroidRuntime.promotion = { + promote: (globalIdx: number): boolean => { + if (built.promoted.has(globalIdx)) return true; + if (built.promoted.size >= promotedMax) return false; + const state = built.states[globalIdx]; + if (!state || !state.alive) return false; + const tier = TIERS[state.tierIdx]; + if (!tier.rotates) return false; // dust: too small/numerous to matter visually + const baseGeom = built.geometryByKey.get(`${state.tierIdx}:${state.variantIdx}`); + const mesh = built.meshes.find( + (bm) => globalIdx >= bm.globalOffset && globalIdx < bm.globalOffset + bm.inst.count, + ); + if (!baseGeom || !mesh) return false; + const it = mesh.items[globalIdx - mesh.globalOffset]; + if (!it) return false; + + const geometry = baseGeom.clone(); + _promoteQ.setFromAxisAngle(it.axis, it.phase); // current tumble angle approximation at promotion time + const obj = new Mesh(geometry, built.material); + // Synced from `state.pos` every frame in the promoted-entry loop + // below (Object3D.position is read-only, can't share the Vector3 + // instance the way RotItem.pos does for InstancedMesh items). + obj.position.copy(state.pos); + obj.quaternion.copy(_promoteQ); + obj.scale.copy(it.scale); + + hideInstance(globalIdx); + const angVel = it.axis.clone().multiplyScalar(it.speed); + built.promoted.set(globalIdx, { mesh: obj, geometry, angVel, quat: _promoteQ.clone() }); + // `state.promoted` itself is set by the caller (asteroidFracture.ts) + // based on this function's return value — single source of truth. + group.current?.add(obj); + return true; + }, + applyDent: (globalIdx: number, worldImpactPoint: Vector3, amount: number) => { + const entry = built.promoted.get(globalIdx); + if (!entry) return; + entry.mesh.updateMatrixWorld(); + _dentLocalPoint.copy(worldImpactPoint); + entry.mesh.worldToLocal(_dentLocalPoint); + applyDentToGeometry(entry.geometry, _dentLocalPoint, amount); + }, + getMomentumInputs: (globalIdx: number) => { + const entry = built.promoted.get(globalIdx); + if (entry) return { angVel: entry.angVel, quat: entry.quat, scale: entry.mesh.scale }; + const state = built.states[globalIdx]; + if (!state) return null; + // Graceful degradation: not promoted (dust tier or budget-full) — + // recompute the pristine placement fresh rather than special-casing + // a null angular velocity in the momentum formula. + const placed = placeAsteroid(state.tierIdx, state.variantIdx, state.instIdx, state.seed); + const angVel = placed.tumbleAxis + ? placed.tumbleAxis.clone().multiplyScalar(placed.tumbleSpeed ?? 0) + : new Vector3(); + return { angVel, quat: placed.quat, scale: placed.scale }; + }, + getSourceGeometry: (globalIdx: number) => { + const entry = built.promoted.get(globalIdx); + if (entry) return entry.geometry; + const state = built.states[globalIdx]; + if (!state) return null; + return built.geometryByKey.get(`${state.tierIdx}:${state.variantIdx}`) ?? null; + }, + getBaseGeometry: (tierIdx: number, variantIdx: number) => built.geometryByKey.get(`${tierIdx}:${variantIdx}`) ?? null, + }; + return () => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.killAsteroid = null; + asteroidRuntime.promotion = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [built, promotedMax]); + // Free GPU resources on quality change / unmount. useEffect(() => { return () => { if (!built) return; built.meshes.forEach((bm) => bm.inst.dispose()); - built.geometries.forEach((g) => g.dispose()); + built.geometryByKey.forEach((g) => g.dispose()); + built.promoted.forEach((entry) => entry.geometry.dispose()); built.material.dispose(); }; }, [built]); @@ -153,23 +273,58 @@ export function AsteroidBelt() { // Reused scratch objects for the per-frame tumble update. const scratch = useRef({ m: new Matrix4(), q: new Quaternion() }); - useFrame((state) => { + useFrame((state, delta) => { if (!built) return; - // Whole-belt orbital drift (respects sim time scale / pause). - if (group.current) group.current.rotation.y = useStore.getState().simTimeDays * 0.0009; + // Whole-belt orbital drift (respects sim time scale / pause). Published + // to the runtime so ship/debris code can transform world-space queries + // into the belt-local space the grid is indexed in. + const yaw = useStore.getState().simTimeDays * 0.0009; + if (group.current) group.current.rotation.y = yaw; + asteroidRuntime.groupYaw = yaw; // Individual tumble for the larger tiers (continues regardless of pause). + const dt = Math.min(delta, 0.05); const t = state.clock.elapsedTime; const { m, q } = scratch.current; for (const bm of built.meshes) { if (!bm.rotates) continue; for (let i = 0; i < bm.items.length; i++) { const it = bm.items[i]; + const globalIdx = bm.globalOffset + i; + const s = built.states[globalIdx]; + if (!s?.alive) continue; // fractured — stays zero-scaled + + // Impact-knockback drift: `it.pos` and `s.pos` (and, once promoted, + // the standalone mesh's own `.position`) are the same Vector3, so + // integrating here is all rendering needs — no separate update path. + if (s.vel.lengthSq() > KNOCKBACK_MIN_VEL_SQ) { + s.pos.addScaledVector(s.vel, dt); + s.vel.multiplyScalar(KNOCKBACK_DAMPING ** (dt * 60)); + } else if (s.vel.x !== 0 || s.vel.y !== 0 || s.vel.z !== 0) { + s.vel.set(0, 0, 0); // snap to rest once negligible + } + + if (s.promoted) continue; // rendering/rotation now owned by the promoted loop below + q.setFromAxisAngle(it.axis, it.phase + t * it.speed); m.compose(it.pos, q, it.scale); bm.inst.setMatrixAt(i, m); } bm.inst.instanceMatrix.needsUpdate = true; } + + // Promoted asteroids: real integrated spin (Object3D auto-updates its + // world matrix from position/quaternion/scale, so no manual compose + // needed here — position is already live via the shared Vector3 above). + for (const [globalIdx, entry] of built.promoted) { + const s = built.states[globalIdx]; + if (!s?.alive) continue; + entry.mesh.position.copy(s.pos); // knockback drift, integrated in the tumble loop above + if (entry.angVel.lengthSq() < 1e-8) continue; + _spinAxis.copy(entry.angVel).normalize(); + _spinDeltaQ.setFromAxisAngle(_spinAxis, entry.angVel.length() * dt); + entry.quat.multiply(_spinDeltaQ); + entry.mesh.quaternion.copy(entry.quat); + } }); if (!built) return null; diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx new file mode 100644 index 0000000..4ec3551 --- /dev/null +++ b/src/scene/AsteroidDebris.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { InstancedMesh, MeshStandardMaterial, Matrix4, Vector3, Color, type Group } from 'three/webgpu'; +import { useStore } from '../store'; +import { QUALITY } from '../systems/quality'; +import { updateDebrisBodies } from '../systems/debrisPhysics'; +import { debrisRuntime, type DebrisBody } from './debrisRuntime'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { rockGeometry } from './rockGeometry'; +import { asteroidRuntime } from './asteroidRuntime'; +import { getFracturePatterns, extractChunkGeometry } from '../systems/asteroidFracturePatterns'; + +const _m = new Matrix4(); +const _scale = new Vector3(); +const _rockColor = new Color(0.4, 0.37, 0.33); +const _oreColor = new Color(0.75, 0.62, 0.25); + +/** Fragments with no `shapeKey` (the jitter-only fallback path, and cascade + * chips) render as this generic tier-0 rock. */ +const GENERIC_BUCKET_KEY = 'generic'; + +/** Per-shape-bucket instance cap. Generous relative to how many concurrent + * fragments typically share one exact extracted-chunk shape; overall debris + * population is separately capped by `QUALITY[...].debrisMax` — a bucket + * hitting this cap just means a few fragments of that specific shape don't + * render this frame (physics/lifetime still runs), never a crash. */ +const BUCKET_CAP = 48; + +interface Bucket { + inst: InstancedMesh; + geometry: ReturnType; + /** Reset to 0 at the start of each frame, incremented as fragments claim a + * slot — becomes `inst.count` at the end of the frame. */ + frameCount: number; +} + +function shapeBucketKey(d: DebrisBody): string { + const k = d.shapeKey; + return k ? `${k.tierIdx}:${k.variantIdx}:${k.patternIdx}:${k.clusterIdx}` : GENERIC_BUCKET_KEY; +} + +/** Build the one representative shape for a bucket. Pattern-based buckets + * extract their cluster from the belt's shared *pristine* base geometry + * (not any single instance's live-dented copy) — required by instancing: + * every instance in one InstancedMesh shares one geometry, so per-bucket + * shape can't track a specific rock's evolving damage, only which chunk of + * which base shape it broke off as. Falls back to the generic rock if no + * belt is mounted to source geometry from (graceful degradation, same + * convention as the rest of the fracture system). */ +function buildBucketGeometry(key: string): ReturnType { + if (key === GENERIC_BUCKET_KEY) return rockGeometry(0, 999); + + const [tierIdx, variantIdx, patternIdx, clusterIdx] = key.split(':').map(Number); + const base = asteroidRuntime.promotion?.getBaseGeometry(tierIdx, variantIdx) ?? null; + if (!base) return rockGeometry(0, 999); + + const patterns = getFracturePatterns(tierIdx, variantIdx, base); + const pattern = patterns[Math.min(patternIdx, patterns.length - 1)]; + return extractChunkGeometry(base, pattern, clusterIdx).geometry; +} + +/** + * Pooled debris/ore-chunk fragments spawned by asteroid fracture (and, later, + * space mining). One InstancedMesh per distinct fragment *shape* — keyed by + * (tierIdx, variantIdx, patternIdx, clusterIdx) for pattern-based fragments, + * or a single shared generic bucket for the jitter-only fallback path and + * cascade chips — so fragments visually read as the actual chunk they broke + * off, not a single generic rock repeated everywhere. Buckets are built + * lazily on first use and kept for the session (the set of distinct shapes + * is small and finite: at most 6 base geometries × 2 patterns × ≤6 clusters). + */ +export function AsteroidDebris() { + const q = QUALITY[useStore((s) => s.quality)]; + const sceneModeType = useStore((s) => s.sceneMode.type); + const group = useRef(null); + const bucketsRef = useRef>(new Map()); + + const material = useMemo(() => { + if (q.debrisMax === 0) return null; + return new MeshStandardMaterial({ color: 0xffffff, roughness: 1, metalness: 0 }); + }, [q.debrisMax]); + + // Publish current quality budgets to the runtime singleton and drop any + // fragments beyond a shrunk cap when quality is lowered mid-flight. + useEffect(() => { + debrisRuntime.maxCount = q.debrisMax; + debrisRuntime.maxLifeSec = q.debrisLifetimeSec; + debrisRuntime.cullDistance = q.debrisCullDistance; + debrisRuntime.cascadeEnabled = q.cascadeFractureEnabled; + if (debrisRuntime.list.length > q.debrisMax) { + debrisRuntime.list.length = q.debrisMax; + } + }, [q.debrisMax, q.debrisLifetimeSec, q.debrisCullDistance, q.cascadeFractureEnabled]); + + // Free GPU resources for every bucket built so far, and reset the map, any + // time the material is (re)built (quality change) or the component unmounts. + useEffect(() => { + const buckets = bucketsRef.current; + const g = group.current; + return () => { + buckets.forEach((b) => { + g?.remove(b.inst); + b.inst.dispose(); + b.geometry.dispose(); + }); + buckets.clear(); + material?.dispose(); + }; + }, [material]); + + useFrame((_, delta) => { + if (!material || !group.current) return; + if (sceneModeType !== 'piloting') return; + const dt = Math.min(delta, 0.05); + const store = useStore.getState(); + updateDebrisBodies( + debrisRuntime.list, + dt, + store.simTimeDays, + shipTelemetry.position, + debrisRuntime.maxLifeSec, + debrisRuntime.cullDistance, + ); + + const buckets = bucketsRef.current; + buckets.forEach((b) => { + b.frameCount = 0; + }); + + const list = debrisRuntime.list; + for (let i = 0; i < list.length; i++) { + const d = list[i]; + const key = shapeBucketKey(d); + let bucket = buckets.get(key); + if (!bucket) { + const geometry = buildBucketGeometry(key); + const inst = new InstancedMesh(geometry, material, BUCKET_CAP); + inst.frustumCulled = false; + inst.count = 0; + group.current.add(inst); + bucket = { inst, geometry, frameCount: 0 }; + buckets.set(key, bucket); + } + if (bucket.frameCount >= BUCKET_CAP) continue; + + const slot = bucket.frameCount++; + _scale.set(d.radius, d.radius, d.radius); + _m.compose(d.pos, d.quat, _scale); + bucket.inst.setMatrixAt(slot, _m); + bucket.inst.setColorAt(slot, d.isOre ? _oreColor : _rockColor); + } + + buckets.forEach((b) => { + b.inst.count = b.frameCount; + b.inst.instanceMatrix.needsUpdate = true; + if (b.inst.instanceColor) b.inst.instanceColor.needsUpdate = true; + }); + }); + + if (!material) return null; + return ; +} diff --git a/src/scene/MiningSparks.tsx b/src/scene/MiningSparks.tsx new file mode 100644 index 0000000..65a7890 --- /dev/null +++ b/src/scene/MiningSparks.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { + BufferGeometry, + BufferAttribute, + Points, + PointsMaterial, + AdditiveBlending, + type Material, +} from 'three/webgpu'; +import { useStore } from '../store'; +import { QUALITY } from '../systems/quality'; +import { miningSparkRuntime } from './miningSparkRuntime'; + +/** + * Pooled impact-chip sparks — a brief, bright burst at the hit point every + * time a mining shot connects, whether or not the hit fractures the target + * (fracture debris is a separate, rarer, longer-lived population in + * `AsteroidDebris.tsx`). Same pooled-Points pattern as `ShipTrail.tsx`. + */ +export function MiningSparks() { + const maxCount = QUALITY[useStore((s) => s.quality)].miningVfxBudget; + const sceneModeType = useStore((s) => s.sceneMode.type); + + const built = useMemo(() => { + if (maxCount === 0) return null; + const positions = new Float32Array(maxCount * 3); + const colors = new Float32Array(maxCount * 3); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('color', new BufferAttribute(colors, 3)); + const material = new PointsMaterial({ + size: 0.5, + sizeAttenuation: true, + vertexColors: true, + transparent: true, + depthWrite: false, + blending: AdditiveBlending, + }); + return { points: new Points(geometry, material), positions, colors }; + }, [maxCount]); + + useEffect(() => { + return () => { + if (!built) return; + built.points.geometry.dispose(); + (built.points.material as Material).dispose(); + }; + }, [built]); + + useEffect(() => { + if (sceneModeType !== 'piloting') miningSparkRuntime.list.length = 0; + }, [sceneModeType]); + + useEffect(() => { + miningSparkRuntime.maxCount = maxCount; + }, [maxCount]); + + useFrame((_, delta) => { + if (!built) return; + if (sceneModeType !== 'piloting') return; + const dt = Math.min(delta, 0.05); + const list = miningSparkRuntime.list; + + for (let i = list.length - 1; i >= 0; i--) { + const p = list[i]; + p.life += dt; + if (p.life >= p.maxLife) { + list[i] = list[list.length - 1]; + list.pop(); + continue; + } + p.pos.addScaledVector(p.vel, dt); + } + + const { positions, colors } = built; + for (let i = 0; i < maxCount; i++) { + const p = list[i]; + const o = i * 3; + if (p) { + const fade = 1 - p.life / p.maxLife; + positions[o] = p.pos.x; + positions[o + 1] = p.pos.y; + positions[o + 2] = p.pos.z; + colors[o] = 1.0 * fade; + colors[o + 1] = 0.6 * fade; + colors[o + 2] = 0.25 * fade; + } else { + colors[o] = colors[o + 1] = colors[o + 2] = 0; + } + } + const posAttr = built.points.geometry.attributes.position as BufferAttribute; + const colorAttr = built.points.geometry.attributes.color as BufferAttribute; + posAttr.needsUpdate = true; + colorAttr.needsUpdate = true; + }); + + if (!built) return null; + return ; +} diff --git a/src/scene/Projectiles.tsx b/src/scene/Projectiles.tsx new file mode 100644 index 0000000..6b3b96a --- /dev/null +++ b/src/scene/Projectiles.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { InstancedMesh, CylinderGeometry, MeshBasicMaterial, Matrix4, Vector3, Quaternion, AdditiveBlending } from 'three/webgpu'; +import { useStore } from '../store'; +import { QUALITY } from '../systems/quality'; +import { updateProjectiles } from '../systems/projectilePhysics'; +import { projectileRuntime } from './projectileRuntime'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { MINING_RANGE } from '../ship/spaceMining'; + +const _m = new Matrix4(); +const _scale = new Vector3(); +const _quat = new Quaternion(); +const _up = new Vector3(0, 1, 0); +const _dirNorm = new Vector3(); + +/** Rendered bolt radius/length — a short visible tracer, not the full flight + * path (the *travel* is what the projectile's own per-frame motion shows). */ +const BOLT_RADIUS = 0.05; +const BOLT_LENGTH = 1.4; + +/** + * Pooled weapon-projectile visuals — one shared InstancedMesh, hard-capped by + * `QUALITY[...].projectileMax`, following the same pooling convention as + * `AsteroidDebris.tsx`/`MiningSparks.tsx`. Each instance is a thin cylinder + * oriented along its own velocity (same "align a mesh to a direction via + * `setFromUnitVectors`" technique as `SpaceMiningController.tsx`'s tracer + * flash), so a shot visibly flies from the ship to whatever it strikes. + */ +export function Projectiles() { + const q = QUALITY[useStore((s) => s.quality)]; + const sceneModeType = useStore((s) => s.sceneMode.type); + + const built = useMemo(() => { + if (q.projectileMax === 0) return null; + const geometry = new CylinderGeometry(BOLT_RADIUS, BOLT_RADIUS, 1, 6, 1, true); + const material = new MeshBasicMaterial({ + color: 0xff8850, + transparent: true, + opacity: 0.9, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + }); + const inst = new InstancedMesh(geometry, material, q.projectileMax); + inst.frustumCulled = false; + inst.count = 0; + return { inst, geometry, material }; + }, [q.projectileMax]); + + useEffect(() => { + projectileRuntime.maxCount = q.projectileMax; + if (projectileRuntime.list.length > q.projectileMax) { + projectileRuntime.list.length = q.projectileMax; + } + }, [q.projectileMax]); + + useEffect(() => { + return () => { + if (!built) return; + built.inst.dispose(); + built.geometry.dispose(); + built.material.dispose(); + }; + }, [built]); + + useFrame((_, delta) => { + if (!built) return; + if (sceneModeType !== 'piloting') return; + const dt = Math.min(delta, 0.05); + const store = useStore.getState(); + updateProjectiles(projectileRuntime.list, dt, store.simTimeDays, shipTelemetry.position, MINING_RANGE); + + const list = projectileRuntime.list; + const inst = built.inst; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + _dirNorm.copy(p.vel).normalize(); + _scale.set(1, BOLT_LENGTH, 1); + _quat.setFromUnitVectors(_up, _dirNorm); + _m.compose(p.pos, _quat, _scale); + inst.setMatrixAt(i, _m); + } + inst.count = list.length; + inst.instanceMatrix.needsUpdate = true; + }); + + if (!built) return null; + return ; +} diff --git a/src/scene/ShipTrail.tsx b/src/scene/ShipTrail.tsx new file mode 100644 index 0000000..205cc48 --- /dev/null +++ b/src/scene/ShipTrail.tsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { + BufferGeometry, + BufferAttribute, + Points, + PointsMaterial, + AdditiveBlending, + Vector3, + type Material, +} from 'three/webgpu'; +import { useStore } from '../store'; +import { QUALITY } from '../systems/quality'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { audio } from '../audio/AudioManager'; + +/** Engine exhaust offset behind the ship's local origin (see ShipModel.tsx's + * ~1.2-unit target length). */ +const EXHAUST_OFFSET = 0.7; +const SPAWN_RATE_MAX = 90; // particles/sec at full throttle +const SPEED_ALONG = 6; // how fast a puff drifts backward, world units/sec +const SPREAD = 0.35; +const LIFE_MIN = 0.35; +const LIFE_MAX = 0.7; + +interface TrailParticle { + x: number; + y: number; + z: number; + vx: number; + vy: number; + vz: number; + life: number; + max: number; +} + +const _forward = new Vector3(); +const _pos = new Vector3(); +const _jitter = new Vector3(); + +/** + * Pooled engine-exhaust particle trail, spawn rate tied to throttle (reads + * the same hot-path `shipTelemetry` singleton the camera/HUD already read — + * no store subscription, no per-frame re-render). Mirrors the swap-remove, + * fixed-cap pooling pattern already used for voxel mining debris + * (`ChunkManager.tsx`'s `Debris[]`/`updateDebris`). + */ +export function ShipTrail() { + const maxCount = QUALITY[useStore((s) => s.quality)].shipTrailParticles; + const sceneModeType = useStore((s) => s.sceneMode.type); + + const built = useMemo(() => { + if (maxCount === 0) return null; + const positions = new Float32Array(maxCount * 3); + const colors = new Float32Array(maxCount * 3); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('color', new BufferAttribute(colors, 3)); + const material = new PointsMaterial({ + size: 1.1, + sizeAttenuation: true, + vertexColors: true, + transparent: true, + depthWrite: false, + blending: AdditiveBlending, + }); + return { points: new Points(geometry, material), positions, colors }; + }, [maxCount]); + + useEffect(() => { + return () => { + if (!built) return; + built.points.geometry.dispose(); + (built.points.material as Material).dispose(); + }; + }, [built]); + + const particles = useRef([]); + const spawnAcc = useRef(0); + + // Leaving piloting: drop the trail immediately rather than let it hang + // frozen in space over the descent/ascent/solar-view scenes, and fade the + // engine hum out. + useEffect(() => { + if (sceneModeType !== 'piloting') { + particles.current.length = 0; + audio.setEngineHum(0); + } + }, [sceneModeType]); + + useFrame((_, delta) => { + if (sceneModeType !== 'piloting') return; + // Engine hum tracks throttle regardless of the particle-trail quality + // tier (audio is cheap, unlike the pooled particles it sits alongside). + audio.setEngineHum(shipTelemetry.throttle); + if (!built) return; + const dt = Math.min(delta, 0.05); + const throttle = shipTelemetry.throttle; + const list = particles.current; + + // Spawn new puffs behind the ship, rate scaled by throttle. + spawnAcc.current += SPAWN_RATE_MAX * throttle * dt; + _forward.set(0, 0, -1).applyQuaternion(shipTelemetry.rotation); + _pos.copy(shipTelemetry.position).addScaledVector(_forward, -EXHAUST_OFFSET); + while (spawnAcc.current >= 1 && list.length < maxCount) { + spawnAcc.current -= 1; + _jitter.set( + (Math.random() - 0.5) * SPREAD, + (Math.random() - 0.5) * SPREAD, + (Math.random() - 0.5) * SPREAD, + ); + list.push({ + x: _pos.x + _jitter.x, + y: _pos.y + _jitter.y, + z: _pos.z + _jitter.z, + vx: -_forward.x * SPEED_ALONG + _jitter.x, + vy: -_forward.y * SPEED_ALONG + _jitter.y, + vz: -_forward.z * SPEED_ALONG + _jitter.z, + life: 0, + max: LIFE_MIN + Math.random() * (LIFE_MAX - LIFE_MIN), + }); + } + if (spawnAcc.current > 1) spawnAcc.current = 1; // avoid a stall's backlog bursting all at once + + // Integrate + swap-remove expired puffs. + for (let i = list.length - 1; i >= 0; i--) { + const p = list[i]; + p.life += dt; + if (p.life >= p.max) { + list[i] = list[list.length - 1]; + list.pop(); + continue; + } + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + } + + // Write into the shared buffers; unused slots collapse to black so + // additive blending contributes nothing for them (Points has no simple + // per-vertex visibility toggle). + const { positions, colors } = built; + for (let i = 0; i < maxCount; i++) { + const p = list[i]; + const o = i * 3; + if (p) { + const fade = 1 - p.life / p.max; + positions[o] = p.x; + positions[o + 1] = p.y; + positions[o + 2] = p.z; + colors[o] = 0.5 * fade; + colors[o + 1] = 0.65 * fade; + colors[o + 2] = 1.0 * fade; + } else { + colors[o] = colors[o + 1] = colors[o + 2] = 0; + } + } + const posAttr = built.points.geometry.attributes.position as BufferAttribute; + const colorAttr = built.points.geometry.attributes.color as BufferAttribute; + posAttr.needsUpdate = true; + colorAttr.needsUpdate = true; + }); + + if (!built) return null; + return ; +} diff --git a/src/scene/SolarSystem.tsx b/src/scene/SolarSystem.tsx index 8c3b4c0..46af320 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -1,7 +1,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react'; import { Canvas, useThree } from '@react-three/fiber'; import type { PerspectiveCamera } from 'three'; -import { AmbientLight, DirectionalLight, PointLight } from 'three'; +import { AmbientLight, DirectionalLight, PointLight, SpotLight } from 'three'; import { WebGPURenderer, ACESFilmicToneMapping, @@ -9,11 +9,18 @@ import { AmbientLightNode, PointLightNode, DirectionalLightNode, + SpotLightNode, } from 'three/webgpu'; import { Starfield } from './Starfield'; import { Sun } from './Sun'; import { SolarWind } from './SolarWind'; import { AsteroidBelt } from './AsteroidBelt'; +import { AsteroidDebris } from './AsteroidDebris'; +import { MiningSparks } from './MiningSparks'; +import { Projectiles } from './Projectiles'; +import { ShipTrail } from './ShipTrail'; +import { SpaceMiningController } from './SpaceMiningController'; +import { SpacePoiField } from './SpacePoiField'; import { SimClock } from './SimClock'; import { AudioReactor } from './AudioReactor'; import { LabelProjector } from './LabelProjector'; @@ -76,6 +83,7 @@ export function SolarSystem() { library.addLight(AmbientLightNode, AmbientLight); library.addLight(PointLightNode, PointLight); library.addLight(DirectionalLightNode, DirectionalLight); + library.addLight(SpotLightNode, SpotLight); // the on-foot flashlight (PlayerController.tsx) renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMappingExposure = 1.15; renderer.shadowMap.enabled = true; @@ -121,6 +129,10 @@ export function SolarSystem() { + + + + {PLANETS.map((p) => ( @@ -136,6 +148,8 @@ export function SolarSystem() { <> + + )} {sceneMode.type === 'descending' && ( diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx new file mode 100644 index 0000000..6cfccbe --- /dev/null +++ b/src/scene/SpaceMiningController.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { Vector3 } from 'three/webgpu'; +import { useStore } from '../store'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { + raycastAsteroids, + installMiningInput, + removeMiningInput, + isFiring, + MINING_RANGE, + FIRE_RATE, + PROJECTILE_SPEED, +} from '../ship/spaceMining'; +import { spaceMiningTelemetry } from '../ship/spaceMiningTelemetry'; +import { projectileRuntime } from './projectileRuntime'; +import { debrisRuntime } from './debrisRuntime'; +import { SHIP_COLLISION_RADIUS } from '../ship/shipPhysics'; +import { audio } from '../audio/AudioManager'; + +/** Ore chunks within this range of the ship start homing toward it (a small + * one-way pull on the ore, never a force on the ship — doesn't reintroduce + * a gravity well, same category of effect as a tractor-beam gadget). */ +const MAGNET_RANGE = 12; +const MAGNET_ACCEL = 40; +/** Collected once within this distance of the ship. */ +const COLLECT_RANGE = SHIP_COLLISION_RADIUS + 0.5; +const ORE_YIELD = 1; + +const FIRE_INTERVAL = 1 / FIRE_RATE; +/** Weapon hardpoint offset from the ship's center, in ship-local space + * (local forward is -Z, matching `ShipModel.tsx`/`computeThrust`'s + * convention) — a fixed point just ahead of and below the nose, so a shot + * visibly leaves the hull rather than materializing at the camera. */ +const HARDPOINT_FORWARD = 0.7; +const HARDPOINT_DOWN = 0.08; + +const _origin = new Vector3(); +const _dir = new Vector3(); +const _hardpointLocal = new Vector3(); +const _hardpointWorld = new Vector3(); +const _aimPoint = new Vector3(); +const _shotDir = new Vector3(); +const _shotVel = new Vector3(); +const _toShip = new Vector3(); + +/** + * Space mining/weapon system: aims a fixed screen-center ray (mirroring the + * voxel mining crosshair convention, and used here only for the crosshair's + * "is something targetable right now" telemetry) and fires discrete, + * automatic shots at a fixed cadence while the trigger is held. Each shot is + * a real traveling projectile — spawned at the ship's own weapon hardpoint + * with its own finite velocity (ship velocity + launch speed, real momentum + * transfer), not an instant hit resolved from the camera. The projectile's + * own per-frame flight/collision/impact pipeline lives in + * `projectilePhysics.ts`/`Projectiles.tsx`; this component only owns firing + * cadence, hardpoint placement, launch audio, aim telemetry, and ore + * magnetism/collection. Runs its own `useFrame` slot, kept separate from + * `ShipController.tsx`'s movement loop — mirrors how `ShipCamera.tsx` is + * already a standalone component reading the shared ship telemetry. + */ +export function SpaceMiningController() { + const camera = useThree((s) => s.camera); + + useEffect(() => { + installMiningInput(); + return () => removeMiningInput(); + }, []); + + // Fire-rate accumulator, plus edge detection so the first shot fires the + // instant the trigger is pulled rather than waiting a full interval + // (standard automatic-weapon feel). + const fireAcc = useRef(0); + const wasFiring = useRef(false); + + const fireShot = (hit: ReturnType) => { + // Launch sound plays immediately; a distinct higher-pitched hit chirp + // plays separately, later, only if this specific shot actually connects + // (projectilePhysics.ts) — travel time means we don't know that yet. + audio.playMiningShot(false); + + _hardpointLocal.set(0, -HARDPOINT_DOWN, -HARDPOINT_FORWARD); + _hardpointWorld.copy(_hardpointLocal).applyQuaternion(shipTelemetry.rotation).add(shipTelemetry.position); + + // Converged aim: the shot flies from the hardpoint TOWARD the point the + // crosshair is actually on (the aimed asteroid's surface, or the + // crosshair ray's far point when aiming at empty space) — NOT parallel + // to the camera ray. The chase camera sits behind/above the ship, so a + // parallel launch is laterally offset from the crosshair line by more + // than a small asteroid's radius and would consistently miss exactly + // what the reticle says is targetable. + if (hit) _aimPoint.copy(hit.point); + else _aimPoint.copy(_origin).addScaledVector(_dir, MINING_RANGE); + _shotDir.copy(_aimPoint).sub(_hardpointWorld); + if (_shotDir.lengthSq() < 1e-6) _shotDir.copy(_dir); + else _shotDir.normalize(); + + _shotVel.copy(shipTelemetry.velocity).addScaledVector(_shotDir, PROJECTILE_SPEED); + projectileRuntime.spawn({ pos: _hardpointWorld, vel: _shotVel }); + }; + + useFrame((_, delta) => { + const store = useStore.getState(); + if (store.sceneMode.type !== 'piloting') { + wasFiring.current = false; + fireAcc.current = 0; + return; + } + const dt = Math.min(delta, 0.05); + + // Raycast every frame regardless of firing — the crosshair reacts to + // "is something targetable right now," not just while the trigger is + // held (matches the genre convention of a reticle that highlights on a + // valid target, e.g. Freelancer/Elite, rather than staying inert). This + // is aim assist for the reticle only — it does not resolve the shot + // itself, which is the spawned projectile's own job. + _origin.copy(camera.position); + camera.getWorldDirection(_dir); + const hit = raycastAsteroids(_origin, _dir, MINING_RANGE); + spaceMiningTelemetry.aiming = hit !== null; + spaceMiningTelemetry.hitPoint = hit ? hit.point : null; + + const firing = isFiring(); + if (firing) { + if (!wasFiring.current) { + wasFiring.current = true; + fireAcc.current = FIRE_INTERVAL; // fire immediately this frame + } + fireAcc.current += dt; + while (fireAcc.current >= FIRE_INTERVAL) { + fireAcc.current -= FIRE_INTERVAL; + fireShot(hit); + } + } else { + wasFiring.current = false; + fireAcc.current = 0; + } + + // Ore magnetism + collection — backward swap-remove, safe regardless of + // whether this runs before or after AsteroidDebris.tsx's integration + // pass this same frame (each pass only touches the array it currently + // sees; JS is single-threaded, so there's no concurrent-mutation hazard). + const list = debrisRuntime.list; + for (let i = list.length - 1; i >= 0; i--) { + const d = list[i]; + if (!d.isOre) continue; + _toShip.copy(shipTelemetry.position).sub(d.pos); + const dist = _toShip.length(); + if (dist <= COLLECT_RANGE) { + if (d.resourceType) { + store.mineResource(d.resourceType, ORE_YIELD, 'space', [d.pos.x, d.pos.y, d.pos.z]); + } + list[i] = list[list.length - 1]; + list.pop(); + continue; + } + if (dist <= MAGNET_RANGE && dist > 1e-4) { + _toShip.multiplyScalar(1 / dist); + d.vel.addScaledVector(_toShip, MAGNET_ACCEL * dt); + } + } + }); + + return null; +} diff --git a/src/scene/SpacePoiField.tsx b/src/scene/SpacePoiField.tsx new file mode 100644 index 0000000..0d3d442 --- /dev/null +++ b/src/scene/SpacePoiField.tsx @@ -0,0 +1,107 @@ +import { useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { + IcosahedronGeometry, + SphereGeometry, + MeshStandardMaterial, + Vector3, + type Group, +} from 'three/webgpu'; +import { useStore } from '../store'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { asteroidRuntime } from './asteroidRuntime'; +import { rotateY } from '../ship/shipCollision'; +import { SPACE_POIS, isAnchoredPoi, type SpacePoiSpec, type FixedSpacePoi } from '../systems/spacePoiProfiles'; + +const _worldPos = new Vector3(); + +/** Resolves a POI's current world position: fixed POIs return their stored + * position; anchored POIs read the live (possibly belt-rotated) position of + * their host asteroid, never a cached value. Returns false if the anchor + * asteroid isn't available yet (belt not built at this quality tier). */ +function resolvePoiWorldPos(spec: SpacePoiSpec, out: Vector3): boolean { + if (isAnchoredPoi(spec)) { + const state = asteroidRuntime.states[spec.anchorGlobalIdx]; + if (!state || !state.alive) return false; + rotateY(state.pos, asteroidRuntime.groupYaw, out); + return true; + } + out.set(spec.pos[0], spec.pos[1], spec.pos[2]); + return true; +} + +const KIND_COLOR: Record = { + wreckage: [0.5, 0.48, 0.45], + anomaly: [0.3, 0.9, 0.8], + resourceCluster: [0.85, 0.65, 0.2], + landmark: [0.6, 0.55, 0.65], +}; + +/** + * Space-specific points of interest: fixed-position markers (anomaly signal, + * resource cluster, distant landmark) plus proximity-based auto-discovery for + * every POI in `spacePoiProfiles.ts`, including asteroid-anchored wreckage. + * Discovery is proximity-triggered rather than a manual "press scan" button + * (the voxel-surface convention) — space POIs are meant to be found by + * flying near them, not by aiming a crosshair, so this reads as "exploration + * finds it" rather than requiring a second parallel scan-UI for the ship. + * Both routes call the exact same `recordDiscovery`/journal the voxel POI + * system already uses (`planet: 'space'`), so no new journal data shape. + */ +export function SpacePoiField() { + const groupRef = useRef(null); + + const fixedMarkers = useMemo(() => { + return SPACE_POIS.filter((s): s is FixedSpacePoi => !isAnchoredPoi(s)).map((spec) => { + const isLandmark = spec.kind === 'landmark'; + const geometry = isLandmark ? new IcosahedronGeometry(6, 1) : new SphereGeometry(1.5, 12, 12); + const [r, g, b] = KIND_COLOR[spec.kind]; + const material = new MeshStandardMaterial({ + color: `rgb(${r * 255}, ${g * 255}, ${b * 255})`, + emissive: spec.kind === 'anomaly' ? `rgb(${r * 255}, ${g * 255}, ${b * 255})` : 0x000000, + emissiveIntensity: spec.kind === 'anomaly' ? 0.8 : 0, + roughness: 0.8, + metalness: 0.2, + }); + return { spec, geometry, material }; + }); + }, []); + + useFrame(() => { + const store = useStore.getState(); + + // Flag anchored POIs' host asteroids indestructible — idempotent, cheap, + // re-applied every frame so it survives a belt rebuild on quality change. + for (const spec of SPACE_POIS) { + if (!isAnchoredPoi(spec)) continue; + const state = asteroidRuntime.states[spec.anchorGlobalIdx]; + if (state && !state.indestructible) state.indestructible = true; + } + + if (store.sceneMode.type !== 'piloting') return; + for (const spec of SPACE_POIS) { + const key = `space:${spec.id}`; + if (store.discovered[key]) continue; + if (!resolvePoiWorldPos(spec, _worldPos)) continue; + if (shipTelemetry.position.distanceTo(_worldPos) <= spec.scanRadius) { + store.recordDiscovery({ + planet: 'space', + id: spec.id, + name: spec.name, + story: spec.story, + clue: spec.clue, + mysteryId: spec.mysteryId, + speculative: spec.speculative, + }); + } + } + }); + + return ( + + {fixedMarkers.map(({ spec, geometry, material }) => ( + + ))} + + ); +} diff --git a/src/scene/asteroidRuntime.ts b/src/scene/asteroidRuntime.ts new file mode 100644 index 0000000..75d5b1a --- /dev/null +++ b/src/scene/asteroidRuntime.ts @@ -0,0 +1,67 @@ +// Hot-path singleton exposing the asteroid belt's per-instance state and +// spatial grid to code outside the AsteroidBelt component tree (ship +// collision, mining, fracture) — same convention as `shipTelemetry.ts`: a +// plain mutable object read/written every frame, never a store subscription, +// since a store subscription here would re-render on every belt rebuild. + +import type { BufferGeometry, Quaternion, Vector3 } from 'three'; +import type { AsteroidState } from '../systems/asteroidState'; +import type { AsteroidGrid } from '../systems/asteroidGrid'; + +/** Inputs the fracture momentum formula needs: the (promoted or, on + * graceful-degradation, freshly-recomputed pristine) asteroid's angular + * velocity, orientation, and scale, so a fragment's initial velocity can + * include "parent angular velocity × offset-from-center" (rigid-body point + * velocity), not just the impact's own direction/force. */ +export interface AsteroidMomentumInputs { + angVel: Vector3; + quat: Quaternion; + scale: Vector3; +} + +/** Set by AsteroidBelt.tsx once a belt is mounted — the promotion API for + * pulling a hit asteroid out of its shared InstancedMesh into a standalone, + * individually deformable mesh (see asteroidFracture.ts's `applyAsteroidDamage`, + * which is the only caller). Null when no belt is mounted. */ +export interface AsteroidPromotionApi { + /** Attempt to promote `globalIdx` (no-op, returns true, if already + * promoted). Returns false if ineligible (dust tier) or over budget — + * callers must gracefully fall back to health-only behavior on false. */ + promote: (globalIdx: number) => boolean; + /** Locally dent a promoted asteroid's geometry at a world-space impact + * point. No-op if `globalIdx` isn't promoted. */ + applyDent: (globalIdx: number, worldImpactPoint: Vector3, amount: number) => void; + /** Never null for a live asteroid — promoted asteroids report their live + * physical state; non-promoted ones report a freshly-recomputed pristine + * placement (graceful degradation, no null case needed by callers). */ + getMomentumInputs: (globalIdx: number) => AsteroidMomentumInputs | null; + /** The geometry to fracture: the promoted mesh's live (possibly dented) + * geometry if promoted, otherwise the shared pristine base geometry for + * that (tier, variant) — never null for a live asteroid. */ + getSourceGeometry: (globalIdx: number) => BufferGeometry | null; + /** The belt's shared *pristine* base geometry for a (tier, variant) pair, + * independent of any specific instance — used by the debris renderer to + * extract a representative shape for a fragment "bucket" (see + * AsteroidDebris.tsx). Null if no belt is mounted or the pair is unknown. */ + getBaseGeometry: (tierIdx: number, variantIdx: number) => BufferGeometry | null; +} + +export const asteroidRuntime: { + states: AsteroidState[]; + grid: AsteroidGrid | null; + /** Belt-local yaw applied this frame (the whole-belt orbital drift) — ship/ + * debris code must undo this before querying the grid, since the grid is + * built and indexed in belt-local space. */ + groupYaw: number; + /** Set by AsteroidBelt.tsx on (re)build; zeroes the given asteroid's + * render instance and marks it dead. Null when no belt is mounted + * (e.g. quality 'low', asteroids: 0) — callers must check before use. */ + killAsteroid: ((globalIdx: number) => void) | null; + promotion: AsteroidPromotionApi | null; +} = { + states: [], + grid: null, + groupYaw: 0, + killAsteroid: null, + promotion: null, +}; diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts new file mode 100644 index 0000000..26b7098 --- /dev/null +++ b/src/scene/debrisRuntime.ts @@ -0,0 +1,94 @@ +// Hot-path singleton for the pooled debris/ore-chunk population spawned by +// asteroid fracture (and, later, space mining) — same convention as +// `shipTelemetry.ts`/`asteroidRuntime.ts`: plain mutable data, no store +// subscription. `AsteroidDebris.tsx` owns the InstancedMesh and integrates +// this list every frame; `asteroidFracture.ts` and (eventually) the mining +// system are the only writers via `spawn()`. + +import { Quaternion, Vector3 } from 'three/webgpu'; +import type { ResourceType } from '../voxel/voxelTypes'; + +export interface DebrisBody { + pos: Vector3; + vel: Vector3; + quat: Quaternion; + /** rad/s, world-space axis*rate — integrated each frame in debrisPhysics.ts. */ + angVel: Vector3; + radius: number; + life: number; + isOre: boolean; + resourceType?: ResourceType; + /** 0 for fracture-spawned fragments, 1 for a cascade "chip" spawned off a + * hard collision — chips never re-trigger a further cascade (hard depth + * cap, see debrisPhysics.ts). */ + cascadeDepth: number; + /** Which extracted chunk shape this fragment is, if it came from + * pattern-based fracture (see asteroidFracturePatterns.ts) — lets + * AsteroidDebris.tsx render it in a bucket matching its actual shape + * instead of a generic rock. Undefined for the jitter-only fallback path + * and for cascade chips, which render as a generic rock. */ + shapeKey?: DebrisShapeKey; +} + +export interface DebrisShapeKey { + tierIdx: number; + variantIdx: number; + patternIdx: number; + clusterIdx: number; +} + +export interface DebrisSpawnSpec { + pos: Vector3; + vel: Vector3; + radius: number; + isOre?: boolean; + resourceType?: ResourceType; + /** Defaults to identity/zero if omitted (e.g. cosmetic-only callers). */ + quat?: Quaternion; + angVel?: Vector3; + cascadeDepth?: number; + shapeKey?: DebrisShapeKey; + /** Initial `life` value (seconds already "lived") — lets short-lived spawns + * (per-hit impact chips) expire well before the global `maxLifeSec` + * without a second lifetime field, so they can't starve the pool budget + * that real fracture fragments draw from. */ + life?: number; +} + +export const debrisRuntime: { + list: DebrisBody[]; + /** Set by AsteroidDebris.tsx from QUALITY[...].debrisMax each render. */ + maxCount: number; + /** Set by AsteroidDebris.tsx from QUALITY[...].debrisLifetimeSec. */ + maxLifeSec: number; + /** Set by AsteroidDebris.tsx from QUALITY[...].debrisCullDistance. */ + cullDistance: number; + /** Set by AsteroidDebris.tsx from QUALITY[...].cascadeFractureEnabled — + * gates whether a hard-enough bounce chips off secondary fragments + * (debrisPhysics.ts). Debris still always bounces regardless (cheap + * reflection math); this only gates the extra population growth from + * cascade chips on low-end tiers. */ + cascadeEnabled: boolean; + spawn: (spec: DebrisSpawnSpec) => void; +} = { + list: [], + maxCount: 0, + maxLifeSec: 12, + cullDistance: 400, + cascadeEnabled: true, + spawn(spec) { + if (debrisRuntime.list.length >= debrisRuntime.maxCount) return; + debrisRuntime.list.push({ + pos: spec.pos.clone(), + vel: spec.vel.clone(), + quat: spec.quat ? spec.quat.clone() : new Quaternion(), + angVel: spec.angVel ? spec.angVel.clone() : new Vector3(), + radius: spec.radius, + life: spec.life ?? 0, + isOre: spec.isOre ?? false, + resourceType: spec.resourceType, + cascadeDepth: spec.cascadeDepth ?? 0, + shapeKey: spec.shapeKey, + }); + }, +}; diff --git a/src/scene/miningSparkRuntime.test.ts b/src/scene/miningSparkRuntime.test.ts new file mode 100644 index 0000000..58d6a76 --- /dev/null +++ b/src/scene/miningSparkRuntime.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { miningSparkRuntime } from './miningSparkRuntime'; + +describe('miningSparkRuntime.spawn', () => { + beforeEach(() => { + miningSparkRuntime.list = []; + miningSparkRuntime.maxCount = 8; + }); + + it('spawns the requested number of particles up to the cap', () => { + miningSparkRuntime.spawn(new Vector3(1, 2, 3), 4); + expect(miningSparkRuntime.list).toHaveLength(4); + for (const p of miningSparkRuntime.list) { + expect(p.pos.equals(new Vector3(1, 2, 3))).toBe(true); + expect(p.life).toBe(0); + expect(p.maxLife).toBeGreaterThan(0); + } + }); + + it('never exceeds maxCount even across multiple spawn calls', () => { + miningSparkRuntime.spawn(new Vector3(), 6); + miningSparkRuntime.spawn(new Vector3(), 6); + expect(miningSparkRuntime.list.length).toBe(8); + }); + + it('spawns nothing once the pool is already full', () => { + miningSparkRuntime.maxCount = 0; + miningSparkRuntime.spawn(new Vector3(), 4); + expect(miningSparkRuntime.list).toHaveLength(0); + }); +}); diff --git a/src/scene/miningSparkRuntime.ts b/src/scene/miningSparkRuntime.ts new file mode 100644 index 0000000..b127ae8 --- /dev/null +++ b/src/scene/miningSparkRuntime.ts @@ -0,0 +1,41 @@ +// Hot-path singleton for the pooled impact-chip spark population — the +// small, purely cosmetic burst spawned on every shot that connects, +// regardless of whether the hit fractures anything. Kept separate from +// `debrisRuntime.ts`'s pool: sparks are far more frequent (one burst per +// shot, every hit) but far cheaper/shorter-lived than real fracture debris, +// and shouldn't compete with that pool's budget. + +import { Vector3 } from 'three/webgpu'; + +export interface SparkParticle { + pos: Vector3; + vel: Vector3; + life: number; + maxLife: number; +} + +export const miningSparkRuntime: { + list: SparkParticle[]; + /** Set by MiningSparks.tsx from QUALITY[...].miningVfxBudget each render. */ + maxCount: number; + spawn: (point: Vector3, count: number) => void; +} = { + list: [], + maxCount: 0, + spawn(point, count) { + for (let i = 0; i < count; i++) { + if (miningSparkRuntime.list.length >= miningSparkRuntime.maxCount) return; + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(Math.random() * 2 - 1); + const speed = 1.5 + Math.random() * 2.5; + miningSparkRuntime.list.push({ + pos: point.clone(), + vel: new Vector3(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi)).multiplyScalar( + speed, + ), + life: 0, + maxLife: 0.15 + Math.random() * 0.15, + }); + } + }, +}; diff --git a/src/scene/projectileRuntime.ts b/src/scene/projectileRuntime.ts new file mode 100644 index 0000000..efaa0aa --- /dev/null +++ b/src/scene/projectileRuntime.ts @@ -0,0 +1,39 @@ +// Hot-path singleton for the pooled weapon-projectile population — same +// convention as `debrisRuntime.ts`/`miningSparkRuntime.ts`: plain mutable +// data, no store subscription. `Projectiles.tsx` owns the InstancedMesh and +// integrates/renders this list every frame; `SpaceMiningController.tsx` is the +// only writer via `spawn()`. + +import { Vector3 } from 'three/webgpu'; +import { PROJECTILE_RADIUS } from '../ship/spaceMining'; + +export interface ProjectileBody { + pos: Vector3; + vel: Vector3; + life: number; + radius: number; +} + +export interface ProjectileSpawnSpec { + pos: Vector3; + vel: Vector3; +} + +export const projectileRuntime: { + list: ProjectileBody[]; + /** Set by Projectiles.tsx from QUALITY[...].projectileMax each render. */ + maxCount: number; + spawn: (spec: ProjectileSpawnSpec) => void; +} = { + list: [], + maxCount: 0, + spawn(spec) { + if (projectileRuntime.list.length >= projectileRuntime.maxCount) return; + projectileRuntime.list.push({ + pos: spec.pos.clone(), + vel: spec.vel.clone(), + life: 0, + radius: PROJECTILE_RADIUS, + }); + }, +}; diff --git a/src/scene/rockGeometry.ts b/src/scene/rockGeometry.ts new file mode 100644 index 0000000..d3a0244 --- /dev/null +++ b/src/scene/rockGeometry.ts @@ -0,0 +1,25 @@ +import { IcosahedronGeometry, Vector3, type BufferGeometry } from 'three/webgpu'; + +/** Lumpy, crack-free rock from an icosahedron: displace each vertex along its + * own direction by a smooth function of that direction, so shared seam + * vertices move identically. `seed` gives each variant a distinct shape. + * Shared by the asteroid belt and the debris pool (fragments reuse the + * cheapest, tier-0 detail level — no new GPU geometry work per fracture). */ +export function rockGeometry(detail: number, seed: number): BufferGeometry { + const g = new IcosahedronGeometry(1, detail); + const pos = g.attributes.position; + const v = new Vector3(); + const amp = 0.38; + for (let i = 0; i < pos.count; i++) { + v.fromBufferAttribute(pos, i).normalize(); + const lump = + Math.sin(v.x * 3.1 + seed * 1.3) * + Math.sin(v.y * 3.7 + seed * 2.1) * + Math.sin(v.z * 2.9 + seed * 0.7); + const f = 1 + amp * lump; + pos.setXYZ(i, v.x * f, v.y * f, v.z * f); + } + pos.needsUpdate = true; + g.computeVertexNormals(); + return g; +} diff --git a/src/ship/ShipController.tsx b/src/ship/ShipController.tsx index 4a88c6e..4754130 100644 --- a/src/ship/ShipController.tsx +++ b/src/ship/ShipController.tsx @@ -8,9 +8,12 @@ import { integrate, ASSIST_DAMPING, DRIFT_DAMPING, + SHIP_COLLISION_RADIUS, type AngularVelocity, type ShipInput, } from './shipPhysics'; +import { resolvePlanetCollision, resolveAsteroidCollision } from './shipCollision'; +import { applyAsteroidDamage } from '../systems/asteroidFracture'; import { readInput, installKeyboardListeners, removeKeyboardListeners } from './shipInput'; import { shipTelemetry, syncTelemetryFromStore, MIRROR_INTERVAL } from './shipTelemetry'; import { decayStick, resetStick } from './virtualStick'; @@ -88,6 +91,12 @@ const AUTO_DECEL = 320; // braking authority (units/s²) — sets the stop ramp const AUTO_TURN_RATE = 2.5; // facing ease rate (1/s) const AUTO_SAFE_RADII = 4; // stop this many body-radii out (matches orbit phase) +// Collision-triggered asteroid damage: a graze at low closing speed shouldn't +// visibly hurt a rock; a real impact should. Both this and deliberate mining +// fire (spaceMining.ts) route through the same applyAsteroidDamage pipeline. +const COLLISION_DAMAGE_MIN_SPEED = 8; // units/s below this, no damage at all +const COLLISION_DAMAGE_SCALE = 0.6; // damage per unit of closing speed above the minimum + export function ShipController() { const groupRef = useRef(null); const angVel = useRef({ pitch: 0, yaw: 0, roll: 0 }); @@ -189,6 +198,22 @@ export function ShipController() { const damping = cfg.flightAssist ? ASSIST_DAMPING : DRIFT_DAMPING; integrate(_pos, _vel, _accel, damping, dt); + // Slide off planets/moons/asteroids on contact — a position/velocity + // constraint applied only on contact, never an added force, so it can't + // reintroduce the gravity well deliberately removed from free flight + // (Phase 11). The asteroid hit info (if any) is available here for + // future collision-triggered damage (fracture/mining systems). + resolvePlanetCollision(_pos, _vel, store.simTimeDays, SHIP_COLLISION_RADIUS); + const asteroidHit = resolveAsteroidCollision(_pos, _vel, SHIP_COLLISION_RADIUS); + if (asteroidHit && asteroidHit.closingSpeed > COLLISION_DAMAGE_MIN_SPEED) { + applyAsteroidDamage( + asteroidHit.globalIdx, + (asteroidHit.closingSpeed - COLLISION_DAMAGE_MIN_SPEED) * COLLISION_DAMAGE_SCALE, + asteroidHit.contactPoint, + _vel, + ); + } + group.position.copy(_pos); group.quaternion.copy(_quat); diff --git a/src/ship/ShipModel.tsx b/src/ship/ShipModel.tsx index d22fa8f..572c790 100644 --- a/src/ship/ShipModel.tsx +++ b/src/ship/ShipModel.tsx @@ -12,6 +12,7 @@ import { } from 'three'; import { useGLTF } from '@react-three/drei'; import { ShipUpgradeVisuals } from './ShipUpgradeVisuals'; +import { shipTelemetry } from './shipTelemetry'; const GLTF_PATH = '/models/spaceship.glb'; const THRUSTER_NAMES = ['thruster', 'engine', 'exhaust', 'nozzle', 'jet']; @@ -70,7 +71,10 @@ function FallbackShip() { useFrame(() => { if (glowRef.current) { - const scale = 0.8 + Math.random() * 0.4; + // Base scale tracks throttle (idle thrusters still glow faintly; full + // burn is visibly larger/brighter); a small residual jitter keeps the + // flame reading as live rather than static. + const scale = 0.55 + 0.55 * shipTelemetry.throttle + Math.random() * 0.1; glowRef.current.scale.set(scale, scale, scale); } }); @@ -140,11 +144,15 @@ function GLTFShip() { }, [cloned]); useFrame(() => { + // Base intensity tracks throttle magnitude directly (thrusters read as + // barely-lit at idle, fully bright at full burn); small residual flicker + // keeps the flame alive rather than a flat brightness. + const base = 0.35 + 0.85 * shipTelemetry.throttle; + const flicker = 0.95 + Math.random() * 0.1; for (const mesh of thrusterMeshes.current) { - const flicker = 0.9 + Math.random() * 0.2; const mat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as MeshStandardMaterial; if (mat && 'emissiveIntensity' in mat) { - mat.emissiveIntensity = 1.2 * flicker; + mat.emissiveIntensity = base * flicker; } } }); diff --git a/src/ship/shipCollision.test.ts b/src/ship/shipCollision.test.ts new file mode 100644 index 0000000..312d474 --- /dev/null +++ b/src/ship/shipCollision.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { resolvePlanetCollision, resolveAsteroidCollision, raySphereHit, raycastPlanets, TANGENTIAL_RETAIN } from './shipCollision'; +import { PLANETS } from '../systems/bodies'; +import { positionAtTime } from '../systems/ephemeris'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { buildAsteroidGrid } from '../systems/asteroidGrid'; +import type { AsteroidState } from '../systems/asteroidState'; + +// Mercury has no moons — simplest body to test sphere-vs-sphere resolution +// against without moon-offset interference. +const mercury = PLANETS.find((p) => p.name === 'Merkurius')!; +const SIM_TIME = 0; +const SHIP_RADIUS = 0.6; + +function mercuryCenter(): Vector3 { + const pos = new Vector3(); + positionAtTime(mercury.elements, mercury.distance, SIM_TIME, pos); + return pos; +} + +describe('resolvePlanetCollision', () => { + it('does nothing when the ship is far from every body', () => { + const center = mercuryCenter(); + const position = center.clone().add(new Vector3(10_000, 0, 0)); + const velocity = new Vector3(-5, 0, 0); + const hit = resolvePlanetCollision(position, velocity.clone(), SIM_TIME, SHIP_RADIUS); + expect(hit).toBe(false); + expect(velocity.equals(new Vector3(-5, 0, 0))).toBe(true); + }); + + it('pushes the ship out to the surface + skin when penetrating', () => { + const center = mercuryCenter(); + // Place the ship dead center inside the planet, along +x from center. + const position = center.clone().add(new Vector3(1, 0, 0)); + const velocity = new Vector3(-3, 0, 0); + const hit = resolvePlanetCollision(position, velocity, SIM_TIME, SHIP_RADIUS); + expect(hit).toBe(true); + const dist = position.distanceTo(center); + expect(dist).toBeCloseTo(mercury.size + SHIP_RADIUS + 0.05, 5); + }); + + it('zeros the into-surface velocity component and retains tangential component', () => { + const center = mercuryCenter(); + // Approach along +x (normal), moving purely inward (-x) and purely + // tangential (+y) — normal component should vanish, tangential retained. + const position = center.clone().add(new Vector3(mercury.size + SHIP_RADIUS - 0.5, 0, 0)); + const velocity = new Vector3(-10, 4, 0); + resolvePlanetCollision(position, velocity, SIM_TIME, SHIP_RADIUS); + + const normal = position.clone().sub(center).normalize(); + const normalSpeed = velocity.dot(normal); + expect(normalSpeed).toBeCloseTo(0, 5); + expect(velocity.y).toBeCloseTo(4 * TANGENTIAL_RETAIN, 5); + }); + + it('does not correct a ship moving away from the surface it is grazing', () => { + const center = mercuryCenter(); + const position = center.clone().add(new Vector3(mercury.size + SHIP_RADIUS - 0.5, 0, 0)); + const velocity = new Vector3(10, 0, 0); // moving outward, away from center + resolvePlanetCollision(position, velocity, SIM_TIME, SHIP_RADIUS); + // Position still gets pushed to the surface (it's penetrating), but the + // outward velocity must be left untouched since it isn't driving further + // penetration. + expect(velocity.x).toBeCloseTo(10, 5); + }); +}); + +describe('raySphereHit', () => { + it('returns the hit distance for a ray that intersects the sphere', () => { + const center = new Vector3(0, 0, -20); + const t = raySphereHit(new Vector3(0, 0, 0), new Vector3(0, 0, -1), 60, center, 2); + expect(t).not.toBeNull(); + expect(t!).toBeCloseTo(18, 5); // 20 - radius 2 + }); + + it('returns null for a ray that misses the sphere', () => { + const center = new Vector3(20, 5, -20); + const t = raySphereHit(new Vector3(0, 0, 0), new Vector3(0, 0, -1), 60, center, 2); + expect(t).toBeNull(); + }); + + it('returns null when the sphere is behind the ray origin', () => { + const center = new Vector3(0, 0, 20); + const t = raySphereHit(new Vector3(0, 0, 0), new Vector3(0, 0, -1), 60, center, 2); + expect(t).toBeNull(); + }); + + it('returns null when the hit is beyond maxDistance', () => { + const center = new Vector3(0, 0, -20); + const t = raySphereHit(new Vector3(0, 0, 0), new Vector3(0, 0, -1), 10, center, 2); + expect(t).toBeNull(); + }); +}); + +describe('raycastPlanets', () => { + it('returns null when nothing along the segment is close to any planet', () => { + const hit = raycastPlanets(new Vector3(1e9, 1e9, 1e9), new Vector3(0, 0, -1), 60, SIM_TIME); + expect(hit).toBeNull(); + }); + + it('hits a planet directly ahead within range', () => { + const center = mercuryCenter(); + const origin = center.clone().add(new Vector3(mercury.size + 30, 0, 0)); + const dir = center.clone().sub(origin).normalize(); + const hit = raycastPlanets(origin, dir, 60, SIM_TIME); + expect(hit).not.toBeNull(); + expect(hit!.point.distanceTo(center)).toBeCloseTo(mercury.size, 4); + }); + + it('does not hit a planet beyond maxDistance', () => { + const center = mercuryCenter(); + const origin = center.clone().add(new Vector3(mercury.size + 1000, 0, 0)); + const dir = center.clone().sub(origin).normalize(); + const hit = raycastPlanets(origin, dir, 60, SIM_TIME); + expect(hit).toBeNull(); + }); +}); + +function mkState(x: number, z: number): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(x, 0, z), + vel: new Vector3(), + radius: 2, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + }; +} + +describe('resolveAsteroidCollision', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + asteroidRuntime.killAsteroid = null; + }); + + it('returns null when there is no belt mounted (grid is null)', () => { + const position = new Vector3(0, 0, 0); + const velocity = new Vector3(-1, 0, 0); + expect(resolveAsteroidCollision(position, velocity, SHIP_RADIUS)).toBeNull(); + }); + + it('detects and resolves a contact in belt-local (unrotated) space', () => { + const states = [mkState(10, 0)]; + asteroidRuntime.states = states; + asteroidRuntime.grid = buildAsteroidGrid(states); + asteroidRuntime.groupYaw = 0; + + const position = new Vector3(10 + 2 + SHIP_RADIUS - 0.5, 0, 0); // penetrating + const velocity = new Vector3(-5, 0, 0); + const hit = resolveAsteroidCollision(position, velocity, SHIP_RADIUS); + + expect(hit).not.toBeNull(); + expect(hit!.globalIdx).toBe(0); + expect(hit!.closingSpeed).toBeCloseTo(5, 5); + expect(position.distanceTo(states[0].pos)).toBeCloseTo(2 + SHIP_RADIUS + 0.05, 5); + }); + + it('accounts for the belt group yaw when querying/resolving', () => { + const states = [mkState(10, 0)]; // belt-local position + asteroidRuntime.states = states; + asteroidRuntime.grid = buildAsteroidGrid(states); + const yaw = Math.PI / 2; // belt rotated 90° in world space + asteroidRuntime.groupYaw = yaw; + + // World position of the same asteroid after a +90° belt rotation: (x,z) -> (x*cos+z*sin, -x*sin+z*cos)... + // rotateY(local, yaw, world) in shipCollision.ts uses world = R(yaw) * local convention below. + const worldAsteroidX = 10 * Math.cos(yaw); + const worldAsteroidZ = -10 * Math.sin(yaw); + const position = new Vector3(worldAsteroidX, 0, worldAsteroidZ).setLength( + new Vector3(worldAsteroidX, 0, worldAsteroidZ).length() - (2 + SHIP_RADIUS - 0.5), + ); + const velocity = new Vector3(worldAsteroidX, 0, worldAsteroidZ).normalize().multiplyScalar(5); + + const hit = resolveAsteroidCollision(position, velocity, SHIP_RADIUS); + expect(hit).not.toBeNull(); + expect(hit!.globalIdx).toBe(0); + }); + + it('ignores dead asteroids', () => { + const states = [{ ...mkState(10, 0), alive: false }]; + asteroidRuntime.states = states; + asteroidRuntime.grid = buildAsteroidGrid(states); + const position = new Vector3(10 + 2 + SHIP_RADIUS - 0.5, 0, 0); + const velocity = new Vector3(-5, 0, 0); + expect(resolveAsteroidCollision(position, velocity, SHIP_RADIUS)).toBeNull(); + }); +}); diff --git a/src/ship/shipCollision.ts b/src/ship/shipCollision.ts new file mode 100644 index 0000000..6ccbd08 --- /dev/null +++ b/src/ship/shipCollision.ts @@ -0,0 +1,301 @@ +import { Vector3 } from 'three'; +import { PLANETS, moonLocalOffset } from '../systems/bodies'; +import { positionAtTime } from '../systems/ephemeris'; +import { queryNearby } from '../systems/asteroidGrid'; +import { TIERS } from '../systems/asteroidLayout'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; + +/** Fraction of tangential velocity retained on contact — slides the ship along + * a surface rather than a dead stop (too abrupt) or a bounce (too jarring at + * flight speeds). Chosen once and reused for every collidable category. */ +export const TANGENTIAL_RETAIN = 0.6; +/** Gap kept between the ship's collision sphere and a surface after a + * correction, so the next frame doesn't immediately re-trigger. */ +const SKIN = 0.05; + +const _bodyPos = new Vector3(); +const _normal = new Vector3(); +const _normalVel = new Vector3(); + +/** + * Analytic sphere-vs-sphere push-out + velocity projection, shared by every + * caller that needs to resolve a body-vs-planet contact (ship, debris). Loops + * `PLANETS` (+ moons) — small N, no partitioning needed, same style as + * `ShipCamera.tsx`'s `sphereCastDistance`. + * + * Mutates `position`/`velocity` in place. Returns true if a correction was + * applied this call. + */ +export function sphereVsPlanets( + position: Vector3, + velocity: Vector3, + simTimeDays: number, + radius: number, + tangentialRetain: number, +): boolean { + let hit = false; + for (const p of PLANETS) { + positionAtTime(p.elements, p.distance, simTimeDays, _bodyPos); + if (resolveSphereContact(position, velocity, _bodyPos, p.size, radius, tangentialRetain) !== null) + hit = true; + + for (const m of p.moons) { + const [ox, oy, oz] = moonLocalOffset(m, simTimeDays); + _bodyPos.x += ox; + _bodyPos.y += oy; + _bodyPos.z += oz; + if (resolveSphereContact(position, velocity, _bodyPos, m.size, radius, tangentialRetain) !== null) + hit = true; + } + } + return hit; +} + +const _rayToCenter = new Vector3(); + +/** Pure ray-vs-sphere test (no mutation) — returns the hit distance `t` along + * `dir` from `origin`, or null on a miss/behind/beyond-`maxDistance`. `dir` + * must be a unit vector. Extracted as a standalone primitive so callers that + * need "did this segment hit something" (a projectile's per-frame swept + * collision check) don't have to go through a contact-resolution function + * that also mutates position/velocity. */ +export function raySphereHit(origin: Vector3, dir: Vector3, maxDistance: number, center: Vector3, radius: number): number | null { + _rayToCenter.copy(center).sub(origin); + const b = _rayToCenter.dot(dir); + if (b < 0) return null; + const perpDistSq = _rayToCenter.lengthSq() - b * b; + const r2 = radius * radius; + if (perpDistSq > r2) return null; + const thc = Math.sqrt(r2 - perpDistSq); + const t = b - thc; + if (t < 0 || t > maxDistance) return null; + return t; +} + +export interface PlanetRayHit { + point: Vector3; +} + +/** Ray-vs-planets(+moons) test for a projectile's flight path this frame — + * the pure-query sibling of `sphereVsPlanets` (which mutates position/ + * velocity for the ship's own slide response; a projectile just needs to + * know where/whether it struck a planetary surface). Returns the nearest + * hit point, or null. */ +export function raycastPlanets(origin: Vector3, dir: Vector3, maxDistance: number, simTimeDays: number): PlanetRayHit | null { + let bestT = Infinity; + for (const p of PLANETS) { + positionAtTime(p.elements, p.distance, simTimeDays, _bodyPos); + const t = raySphereHit(origin, dir, maxDistance, _bodyPos, p.size); + if (t !== null && t < bestT) bestT = t; + + for (const m of p.moons) { + const [ox, oy, oz] = moonLocalOffset(m, simTimeDays); + _bodyPos.x += ox; + _bodyPos.y += oy; + _bodyPos.z += oz; + const mt = raySphereHit(origin, dir, maxDistance, _bodyPos, m.size); + if (mt !== null && mt < bestT) bestT = mt; + } + } + if (bestT === Infinity) return null; + return { point: origin.clone().addScaledVector(dir, bestT) }; +} + +/** Single sphere-vs-sphere contact test + resolution against one body. + * Returns the pre-correction normal (into-surface) speed when a contact was + * resolved, or null on a miss — callers that need "how hard did it hit" + * (collision-triggered asteroid damage) read that value before it's zeroed. */ +export function resolveSphereContact( + position: Vector3, + velocity: Vector3, + center: Vector3, + bodyRadius: number, + radius: number, + tangentialRetain: number, +): number | null { + const minDist = bodyRadius + radius; + _normal.copy(position).sub(center); + const distSq = _normal.lengthSq(); + if (distSq >= minDist * minDist) return null; + + const dist = Math.sqrt(distSq); + if (dist > 1e-6) _normal.multiplyScalar(1 / dist); + else _normal.set(0, 1, 0); // degenerate (exact center overlap) — push up arbitrarily + + // Push the position out to the surface + skin. + position.copy(center).addScaledVector(_normal, minDist + SKIN); + + // Zero the into-surface velocity component, damp-retain the tangential one. + const normalSpeed = velocity.dot(_normal); + if (normalSpeed < 0) { + _normalVel.copy(_normal).multiplyScalar(normalSpeed); + velocity.sub(_normalVel); // strip normal component + velocity.multiplyScalar(tangentialRetain); + } + return normalSpeed < 0 ? -normalSpeed : 0; +} + +/** Ship-vs-planet/moon collision — slide response, never a force/acceleration + * term (must not be mistaken for a reintroduced gravity well; see the + * "Phase 11: pure thrust, no gravity" comment in shipPhysics.ts). */ +export function resolvePlanetCollision( + position: Vector3, + velocity: Vector3, + simTimeDays: number, + shipRadius: number, +): boolean { + return sphereVsPlanets(position, velocity, simTimeDays, shipRadius, TANGENTIAL_RETAIN); +} + +/** Restitution/friction tuned for tumbling rock debris — rubble bounces + * (unlike the ship's slide-and-damp above, which is tuned for piloting feel + * and would be the wrong response for a physical chunk), but loses energy + * each bounce so a debris field settles over time instead of bouncing + * forever. */ +export const DEBRIS_RESTITUTION = 0.35; +export const DEBRIS_TANGENTIAL_FRICTION = 0.8; + +const _tangent = new Vector3(); + +/** + * Sphere-vs-sphere reflection: `v' = v - (1+e)(v·n)n`, plus a fraction of the + * (unchanged-by-reflection) tangential velocity bled off per bounce. Same + * penetration test/push-out as `resolveSphereContact`, genuinely different + * response — that function's strip-and-damp is a "slide," this is a real + * bounce. Returns the pre-correction closing speed (same contract as + * `resolveSphereContact`), or null on a miss. + */ +export function reflectSphereContact( + position: Vector3, + velocity: Vector3, + center: Vector3, + bodyRadius: number, + radius: number, + restitution: number, + tangentialFriction: number, +): number | null { + const minDist = bodyRadius + radius; + _normal.copy(position).sub(center); + const distSq = _normal.lengthSq(); + if (distSq >= minDist * minDist) return null; + + const dist = Math.sqrt(distSq); + if (dist > 1e-6) _normal.multiplyScalar(1 / dist); + else _normal.set(0, 1, 0); // degenerate (exact center overlap) — push up arbitrarily + + position.copy(center).addScaledVector(_normal, minDist + SKIN); + + const normalSpeed = velocity.dot(_normal); + if (normalSpeed < 0) { + velocity.addScaledVector(_normal, -(1 + restitution) * normalSpeed); + // Bleed tangential energy: extract the (reflection-unaffected) tangential + // component and damp it by `1 - tangentialFriction`. + _tangent.copy(velocity).addScaledVector(_normal, -velocity.dot(_normal)); + velocity.addScaledVector(_tangent, -(1 - tangentialFriction)); + } + return normalSpeed < 0 ? -normalSpeed : 0; +} + +/** Reflect-mode sibling of `sphereVsPlanets` for debris — returns the largest + * closing speed observed this call (0 if nothing was touched), which + * cascade-fracture triggers key off. */ +export function reflectSphereVsPlanets( + position: Vector3, + velocity: Vector3, + simTimeDays: number, + radius: number, + restitution: number, + tangentialFriction: number, +): number { + let maxClosing = 0; + for (const p of PLANETS) { + positionAtTime(p.elements, p.distance, simTimeDays, _bodyPos); + const speed = reflectSphereContact(position, velocity, _bodyPos, p.size, radius, restitution, tangentialFriction); + if (speed !== null) maxClosing = Math.max(maxClosing, speed); + + for (const m of p.moons) { + const [ox, oy, oz] = moonLocalOffset(m, simTimeDays); + _bodyPos.x += ox; + _bodyPos.y += oy; + _bodyPos.z += oz; + const moonSpeed = reflectSphereContact(position, velocity, _bodyPos, m.size, radius, restitution, tangentialFriction); + if (moonSpeed !== null) maxClosing = Math.max(maxClosing, moonSpeed); + } + } + return maxClosing; +} + +export interface AsteroidHitInfo { + globalIdx: number; + /** World-space contact point. */ + contactPoint: Vector3; + /** Speed (world units/s) the ship was closing on the asteroid before the + * correction — the "how hard did it hit" §4/§6 collision-damage callers + * need. */ + closingSpeed: number; +} + +/** Largest possible asteroid bounding radius across every tier, used to size + * the broadphase query so nothing large enough to matter is missed. */ +const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); + +const _localPos = new Vector3(); +const _localVel = new Vector3(); +const _worldContact = new Vector3(); + +/** Rotate `(x, z)` about the world Y axis by `angle` (the belt's only degree + * of orbital-drift freedom), writing into `out`. Exported so other code that + * needs to convert between world space and the belt-local frame the + * asteroid grid is indexed in (e.g. debris physics) can reuse it. */ +export function rotateY(v: Vector3, angle: number, out: Vector3): void { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + out.set(v.x * cos + v.z * sin, v.y, -v.x * sin + v.z * cos); +} + +/** + * Ship-vs-asteroid collision — same slide response as planets, but broadphase + * via the belt's spatial grid instead of scanning every instance. The grid is + * indexed in belt-local space (the whole belt rotates as one group each + * frame), so the query point/velocity are rotated into that frame and the + * result rotated back before returning. + * + * Returns info about the hardest-hit asteroid this call (for collision- + * triggered damage in the fracture/mining systems), or null if nothing was + * touched. + */ +export function resolveAsteroidCollision( + position: Vector3, + velocity: Vector3, + shipRadius: number, +): AsteroidHitInfo | null { + const grid = asteroidRuntime.grid; + if (!grid) return null; + + const yaw = asteroidRuntime.groupYaw; + rotateY(position, -yaw, _localPos); + rotateY(velocity, -yaw, _localVel); + + const queryRadius = shipRadius + MAX_ASTEROID_RADIUS; + const candidates = queryNearby(grid, _localPos.x, _localPos.z, queryRadius); + + let best: { globalIdx: number; contactLocal: Vector3; closingSpeed: number } | null = null; + + for (const idx of candidates) { + const s = asteroidRuntime.states[idx]; + if (!s || !s.alive) continue; + const speed = resolveSphereContact(_localPos, _localVel, s.pos, s.radius, shipRadius, TANGENTIAL_RETAIN); + if (speed === null) continue; + if (!best || speed > best.closingSpeed) { + best = { globalIdx: idx, contactLocal: s.pos.clone(), closingSpeed: speed }; + } + } + + // Write the (possibly corrected) local position/velocity back to world space. + rotateY(_localPos, yaw, position); + rotateY(_localVel, yaw, velocity); + + if (!best) return null; + rotateY(best.contactLocal, yaw, _worldContact); + return { globalIdx: best.globalIdx, contactPoint: _worldContact.clone(), closingSpeed: best.closingSpeed }; +} diff --git a/src/ship/shipInput.test.ts b/src/ship/shipInput.test.ts new file mode 100644 index 0000000..60f2117 --- /dev/null +++ b/src/ship/shipInput.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useStore } from '../store'; +import { readInput, setTouchRoll, setTouchJoystick, clearTouchJoystick, setTouchThrottle } from './shipInput'; + +const initialState = useStore.getState(); + +beforeEach(() => { + useStore.setState(initialState, true); + clearTouchJoystick(); + setTouchThrottle(0); + setTouchRoll(0); +}); + +describe('touch roll input', () => { + it('readInput reports zero roll with no touch roll input (regression: was hardcoded to 0 always)', () => { + setTouchJoystick(0.3, 0); + const input = readInput(); + expect(input.roll).toBe(0); + }); + + it('readInput reports a positive roll when the right (↻) touch button is held', () => { + setTouchRoll(1); + const input = readInput(); + expect(input.roll).toBeGreaterThan(0); + }); + + it('readInput reports a negative roll when the left (↺) touch button is held', () => { + setTouchRoll(-1); + const input = readInput(); + expect(input.roll).toBeLessThan(0); + }); + + it('roll alone (no joystick/throttle activity) is still routed through the touch input branch', () => { + setTouchRoll(1); + const input = readInput(); + // Touch branch produces throttleRaw derived from touchThrust (0 here); + // the key behavior under test is that roll is honored even with no + // joystick/throttle activity, not that the branch changes other fields. + expect(input.roll).toBeGreaterThan(0); + expect(input.thrust).toBe(0); + }); + + it('releasing the roll button returns roll to zero', () => { + setTouchRoll(1); + expect(readInput().roll).toBeGreaterThan(0); + setTouchRoll(0); + setTouchJoystick(0.3, 0); // keep the touch branch active via joystick instead + expect(readInput().roll).toBe(0); + }); +}); diff --git a/src/ship/shipInput.ts b/src/ship/shipInput.ts index 912385c..691d463 100644 --- a/src/ship/shipInput.ts +++ b/src/ship/shipInput.ts @@ -142,6 +142,7 @@ function readGamepadRaw(): ShipInput | null { let touchYaw = 0; let touchPitch = 0; let touchThrust = 0; +let touchRoll = 0; let joystickActive = false; /** Raw, unshaped joystick vector — shaping happens centrally in readInput(). */ @@ -161,6 +162,11 @@ export function setTouchThrottle(t: number) { touchThrust = t; } +/** Held roll button state: -1 (roll left / ↺), 0 (released), 1 (roll right / ↻). */ +export function setTouchRoll(v: number) { + touchRoll = v; +} + /** Fine control softens rotation and caps thrust so close-quarters work is * precise. Applied as the last shaping step, after the source is chosen. */ const FINE_ROTATION = 0.4; @@ -208,14 +214,14 @@ export function readInput(): ShipInput { }); } - if (joystickActive || Math.abs(touchThrust) > 0.01) { + if (joystickActive || Math.abs(touchThrust) > 0.01 || Math.abs(touchRoll) > 0.01) { const aim = shapeRadial(touchYaw, touchPitch, cfg.deadzone); return finish({ thrust: throttleCurve(touchThrust), throttleRaw: Math.min(Math.abs(touchThrust), 1), yaw: aim.x, pitch: cfg.invertPitch ? -aim.y : aim.y, - roll: 0, + roll: shapeAxis(touchRoll, cfg.deadzone), }); } diff --git a/src/ship/shipPhysics.ts b/src/ship/shipPhysics.ts index 9ea53f0..189acba 100644 --- a/src/ship/shipPhysics.ts +++ b/src/ship/shipPhysics.ts @@ -1,11 +1,18 @@ import { Quaternion, Vector3 } from 'three'; export const MAX_THRUST = 350; +/** Collision sphere radius (world units) for ship-vs-planet/asteroid contact — + * sized against the ship's ~1.2-unit target length (see ShipModel.tsx). */ +export const SHIP_COLLISION_RADIUS = 0.6; /** Per-frame velocity retention (@60fps) when flight assist is ON — bleeds off * momentum so the ship auto-decelerates to a stop when input is released. */ export const ASSIST_DAMPING = 0.985; /** Near-frictionless Newtonian drift when flight assist is OFF. */ export const DRIFT_DAMPING = 0.9995; +/** Per-frame velocity retention for free-drifting asteroid debris — no + * thrust/rotation input, just inertia (reuses `integrate()` below rather + * than a second physics implementation). */ +export const DEBRIS_DAMPING = 0.999; /** Max angular rate per axis (rad/s), scaled by sensitivity. Capped so the * ship never feels twitchy regardless of how hard the stick is pushed. */ diff --git a/src/ship/spaceMining.test.ts b/src/ship/spaceMining.test.ts new file mode 100644 index 0000000..55a34fd --- /dev/null +++ b/src/ship/spaceMining.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { raycastAsteroids, isFiring, setTouchFiring, installMiningInput, removeMiningInput } from './spaceMining'; +import { rotateY } from './shipCollision'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { buildAsteroidGrid } from '../systems/asteroidGrid'; +import { INNER, OUTER } from '../systems/asteroidLayout'; +import type { AsteroidState } from '../systems/asteroidState'; + +// The grid is tuned for the belt's real scale (radius ~1600-1900 units) — +// tests must place asteroids at a realistic radius, not near the world +// origin, since angle is degenerate at r≈0 and the grid's radial bins clamp +// hard outside the annulus. +const MID_R = (INNER + OUTER) / 2; +const ORIGIN = new Vector3(MID_R, 0, 0); + +function mkState(pos: Vector3, radius = 2): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos, + vel: new Vector3(), + radius, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + }; +} + +describe('raycastAsteroids', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + }); + + it('returns null when no belt is mounted', () => { + expect(raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60)).toBeNull(); + }); + + it('hits an asteroid directly ahead of the ray', () => { + const state = mkState(ORIGIN.clone().add(new Vector3(0, 0, -20))); + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + const hit = raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60); + expect(hit).not.toBeNull(); + expect(hit!.globalIdx).toBe(0); + expect(hit!.distance).toBeCloseTo(18, 5); // 20 - radius 2 + }); + + it('misses an asteroid the ray does not point at', () => { + const state = mkState(ORIGIN.clone().add(new Vector3(20, 5, 0))); // off to the side + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + const hit = raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60); + expect(hit).toBeNull(); + }); + + it('does not hit something beyond maxDistance', () => { + const state = mkState(ORIGIN.clone().add(new Vector3(0, 0, -100))); + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + const hit = raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60); + expect(hit).toBeNull(); + }); + + it('ignores dead asteroids', () => { + const state = { ...mkState(ORIGIN.clone().add(new Vector3(0, 0, -20))), alive: false }; + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + expect(raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60)).toBeNull(); + }); + + it('picks the nearest of several candidates along the ray', () => { + const near = mkState(ORIGIN.clone().add(new Vector3(0, 0, -20))); + const far = mkState(ORIGIN.clone().add(new Vector3(0, 0, -40))); + asteroidRuntime.states = [near, far]; + asteroidRuntime.grid = buildAsteroidGrid([near, far]); + + const hit = raycastAsteroids(ORIGIN.clone(), new Vector3(0, 0, -1), 60); + expect(hit!.globalIdx).toBe(0); + }); + + it('accounts for belt group yaw', () => { + const yaw = Math.PI / 4; + // The asteroid's stored position is always belt-local, and so is the + // ship's position once translated into that frame (undoing the belt's + // rotation) — that's what the grid query actually operates on. Placing + // the asteroid 20 local units from the ship's *local* position (not its + // world position) is what makes this a realistic same-neighborhood case. + const localOrigin = new Vector3(); + rotateY(ORIGIN, -yaw, localOrigin); + const localAsteroidPos = localOrigin.clone().add(new Vector3(0, 0, -20)); + + const state = mkState(localAsteroidPos); + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + asteroidRuntime.groupYaw = yaw; + + const worldAsteroidPos = new Vector3(); + rotateY(localAsteroidPos, yaw, worldAsteroidPos); + const dir = worldAsteroidPos.clone().sub(ORIGIN).normalize(); + const hit = raycastAsteroids(ORIGIN.clone(), dir, 60); + expect(hit).not.toBeNull(); + expect(hit!.globalIdx).toBe(0); + }); +}); + +describe('isFiring / setTouchFiring', () => { + afterEach(() => { + setTouchFiring(false); + removeMiningInput(); + }); + + it('is false when nothing is held', () => { + installMiningInput(); + expect(isFiring()).toBe(false); + }); + + it('is true while the touch fire button is held (regression: touch previously had no way to fire at all)', () => { + installMiningInput(); + setTouchFiring(true); + expect(isFiring()).toBe(true); + }); + + it('returns to false once the touch fire button is released', () => { + installMiningInput(); + setTouchFiring(true); + expect(isFiring()).toBe(true); + setTouchFiring(false); + expect(isFiring()).toBe(false); + }); +}); diff --git a/src/ship/spaceMining.ts b/src/ship/spaceMining.ts new file mode 100644 index 0000000..820380f --- /dev/null +++ b/src/ship/spaceMining.ts @@ -0,0 +1,162 @@ +// Projectile mining/weapon system for space (asteroids only — no ship-to-ship +// combat, no NPCs; this repo has no combat system at all today). Aiming +// mirrors the voxel mining convention (a fixed screen-center ray) for the +// *direction* a shot launches in, but a shot is a real traveling projectile +// (see projectileRuntime.ts/projectilePhysics.ts) — spawned at the ship's +// weapon hardpoint, not resolved instantly from the camera. `raycastAsteroids` +// here is the swept-segment collision test the projectile's own per-frame +// update calls (this module owns it since it's also still used for the +// crosshair's continuous "is something targetable" aim-assist query), +// broadphased through the belt's spatial grid so it never iterates the full +// asteroid population. Fire is discrete, automatic shots at a fixed cadence +// while held (not a continuous beam); each connecting hit feeds the same +// `applyAsteroidDamage` pipeline collision damage uses, so sustained fire +// naturally produces low/medium/high fracture outcomes over several shots. + +import { Vector3 } from 'three'; +import { queryNearby } from '../systems/asteroidGrid'; +import { TIERS } from '../systems/asteroidLayout'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { rotateY } from './shipCollision'; + +/** Max range (world units) a shot can travel before it's spent. */ +export const MINING_RANGE = 60; +/** Automatic fire cadence while the trigger is held (shots/sec). */ +export const FIRE_RATE = 6; +/** Damage dealt per individual shot. */ +export const SHOT_DAMAGE = 3; +/** Projectile travel speed (world units/sec), before adding the ship's own + * velocity (real momentum transfer — a shot fired while moving inherits the + * ship's motion, same as a thrown object would). Fast enough to feel like a + * weapon, slow enough that its flight across `MINING_RANGE` is genuinely + * visible, not instant. */ +export const PROJECTILE_SPEED = 220; +/** Collision sphere radius for the traveling bolt itself. */ +export const PROJECTILE_RADIUS = 0.12; +/** Lifetime cap (seconds) derived from range/speed — a shot that hasn't hit + * anything by the time it could have crossed `MINING_RANGE` expires. */ +export const PROJECTILE_MAX_LIFE_SEC = MINING_RANGE / PROJECTILE_SPEED; + +const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); + +const _localOrigin = new Vector3(); +const _localDir = new Vector3(); +const _toCenter = new Vector3(); +const _localHit = new Vector3(); + +export interface MiningRayHit { + globalIdx: number; + /** World-space hit point. */ + point: Vector3; + distance: number; +} + +/** + * Ray-sphere test against grid-shortlisted asteroid candidates only. `origin` + * is world-space; `dir` must be a world-space unit vector. Returns the + * nearest live asteroid hit within `maxDistance`, or null. + */ +export function raycastAsteroids(origin: Vector3, dir: Vector3, maxDistance: number): MiningRayHit | null { + const grid = asteroidRuntime.grid; + if (!grid) return null; + + const yaw = asteroidRuntime.groupYaw; + rotateY(origin, -yaw, _localOrigin); + rotateY(dir, -yaw, _localDir); + + const candidates = queryNearby(grid, _localOrigin.x, _localOrigin.z, maxDistance + MAX_ASTEROID_RADIUS); + + let best: { idx: number; t: number } | null = null; + for (const idx of candidates) { + const s = asteroidRuntime.states[idx]; + if (!s || !s.alive) continue; + _toCenter.copy(s.pos).sub(_localOrigin); + const b = _toCenter.dot(_localDir); + if (b < 0) continue; // asteroid is behind the ray origin + const perpDistSq = _toCenter.lengthSq() - b * b; + const r2 = s.radius * s.radius; + if (perpDistSq > r2) continue; // ray misses the sphere + const thc = Math.sqrt(r2 - perpDistSq); + const t = b - thc; + if (t < 0 || t > maxDistance) continue; + if (!best || t < best.t) best = { idx, t }; + } + if (!best) return null; + + _localHit.copy(_localOrigin).addScaledVector(_localDir, best.t); + const point = new Vector3(); + rotateY(_localHit, yaw, point); + return { globalIdx: best.idx, point, distance: best.t }; +} + +// --- Firing input: left mouse (while pointer-locked), Space, or gamepad RB +// (button 5) — none of these are otherwise bound during flight, so mining +// fire never conflicts with the existing thrust/yaw/pitch/roll/fine-control +// bindings in shipInput.ts. --------------------------------------------- + +let spaceKeyDown = false; +let mouseDown = false; +let touchFiring = false; +let installed = false; + +/** Touch "Fire" button state (TouchControls.tsx) — held true while pressed, + * false on release. Separate from the mouse/keyboard/gamepad listeners + * below since touch has no equivalent physical event to hook. */ +export function setTouchFiring(active: boolean): void { + touchFiring = active; +} + +function onKeyDown(e: KeyboardEvent) { + if (e.code === 'Space') spaceKeyDown = true; +} +function onKeyUp(e: KeyboardEvent) { + if (e.code === 'Space') spaceKeyDown = false; +} +function onMouseDown(e: MouseEvent) { + if (e.button === 0) mouseDown = true; +} +function onMouseUp(e: MouseEvent) { + if (e.button === 0) mouseDown = false; +} +function onBlur() { + spaceKeyDown = false; + mouseDown = false; + touchFiring = false; +} + +export function installMiningInput(): void { + if (installed) return; + installed = true; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('mousedown', onMouseDown); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('blur', onBlur); +} + +export function removeMiningInput(): void { + installed = false; + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('blur', onBlur); + spaceKeyDown = false; + mouseDown = false; +} + +function gamepadFiring(): boolean { + const pads = navigator.getGamepads?.(); + if (!pads) return false; + for (const gp of pads) { + if (gp) return !!gp.buttons[5]?.pressed; + } + return false; +} + +/** True while any firing input is held (mouse only counts while pointer-locked, + * matching the mouse-flight convention). */ +export function isFiring(): boolean { + const mouseFiring = mouseDown && typeof document !== 'undefined' && !!document.pointerLockElement; + return spaceKeyDown || mouseFiring || gamepadFiring() || touchFiring; +} diff --git a/src/ship/spaceMiningTelemetry.ts b/src/ship/spaceMiningTelemetry.ts new file mode 100644 index 0000000..f4c7492 --- /dev/null +++ b/src/ship/spaceMiningTelemetry.ts @@ -0,0 +1,20 @@ +// Hot-path singleton publishing the current-frame aim state (raycast against +// asteroids, run every frame regardless of firing) — same convention as +// `shipTelemetry.ts`/`asteroidRuntime.ts`: plain mutable data, no store +// subscription, so the crosshair/beam can react every frame without forcing +// a React re-render. + +import { Vector3 } from 'three'; + +export const spaceMiningTelemetry: { + /** True when the current-frame ray is within range of a live asteroid — + * drives the crosshair's on-target feedback independent of whether the + * player is actually firing. */ + aiming: boolean; + /** World-space hit point this frame, or null on a miss. Reused by the + * visual beam so it doesn't need a second raycast. */ + hitPoint: Vector3 | null; +} = { + aiming: false, + hitPoint: null, +}; diff --git a/src/store.test.ts b/src/store.test.ts index 6302524..e84e267 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -326,3 +326,28 @@ describe('applyOfflineProduction', () => { expect(s.stored.iron).toBe(Math.floor(MAX_OFFLINE_SECONDS / 6)); }); }); + +describe('space POI discovery (planet: "space")', () => { + it('records a space discovery under a "space:" key without a new data shape', () => { + const isNew = useStore.getState().recordDiscovery({ planet: 'space', id: 'derelict-hull-belt', name: 'Wreck' }); + expect(isNew).toBe(true); + expect(useStore.getState().discovered['space:derelict-hull-belt']).toBe(true); + expect(useStore.getState().journal[0].planet).toBe('space'); + }); + + it('does not collide with a voxel-surface discovery of the same POI id on a different planet', () => { + useStore.getState().recordDiscovery({ planet: 'space', id: 'poi-1', name: 'Space POI' }); + useStore.getState().recordDiscovery({ planet: 'Jorden', id: 'poi-1', name: 'Surface POI' }); + const s = useStore.getState(); + expect(s.discovered['space:poi-1']).toBe(true); + expect(s.discovered['Jorden:poi-1']).toBe(true); + expect(s.journal).toHaveLength(2); + }); + + it('returns false (no duplicate journal entry) on a repeat discovery', () => { + useStore.getState().recordDiscovery({ planet: 'space', id: 'signal-belt', name: 'Signal' }); + const isNew = useStore.getState().recordDiscovery({ planet: 'space', id: 'signal-belt', name: 'Signal' }); + expect(isNew).toBe(false); + expect(useStore.getState().journal).toHaveLength(1); + }); +}); diff --git a/src/store.ts b/src/store.ts index 1f37d73..4828240 100644 --- a/src/store.ts +++ b/src/store.ts @@ -6,7 +6,7 @@ import type { ResourceType } from './voxel/voxelTypes'; import type { BuildableId } from './voxel/buildables'; import { recipeById, type CraftedItem } from './voxel/recipes'; import { planetPower } from './voxel/power'; -import { saveMode, saveUpgrades, saveInventory, saveItems } from './voxel/persistence'; +import { saveMode, saveUpgrades, saveInventory, saveItems, saveActiveTool, saveFlashlightOn } from './voxel/persistence'; import { UPGRADE_COSTS, UPGRADE_MAX_TIER, @@ -17,6 +17,9 @@ import { type ShipUpgrades, } from './ship/upgrades'; +/** Item wheel: the three on-foot equippable tools. */ +export type VoxelTool = 'pickaxe' | 'gun' | 'flashlight'; + /** Backpack capacity with no cargo upgrades. */ const BASE_BACKPACK_CAPACITY = 50; @@ -462,6 +465,12 @@ interface SimState { /** Phase 11.2 crafted items, and the resources ever discovered (recipe reveal). */ items: Partial>; seenResources: Partial>; + /** Item wheel: which on-foot tool is equipped. Mining only runs when this is + * 'pickaxe'; the gun's alt-mining tick only runs when it's 'gun'. */ + activeTool: VoxelTool; + /** Whether the flashlight is currently lit — independent of whether it's the + * equipped tool, so re-equipping it doesn't force it back on. */ + flashlightOn: boolean; /** Creative mode (the default): placement ignores resource/item costs and no * survival mechanics (oxygen/tethers/death) apply. Survival is opt-in. */ creativeMode: boolean; @@ -599,6 +608,8 @@ interface SimState { craft: (recipeId: string, stationId: number) => boolean; setItems: (items: Partial>) => void; setSeenResources: (seen: Partial>) => void; + setActiveTool: (tool: VoxelTool) => void; + setFlashlightOn: (on: boolean) => void; setCreativeMode: (v: boolean) => void; /** Phase 11.4 survival. */ @@ -682,6 +693,8 @@ export const useStore = create((set, get) => ({ activeBuildable: 'block', items: {}, seenResources: {}, + activeTool: 'pickaxe', + flashlightOn: false, // Creative is the default experience; survival is an explicit opt-in // (Settings). Hydrated from persistence in App so the choice sticks. creativeMode: true, @@ -1169,6 +1182,14 @@ export const useStore = create((set, get) => ({ }, setItems: (items) => set({ items }), setSeenResources: (seenResources) => set({ seenResources }), + setActiveTool: (activeTool) => { + set({ activeTool }); + void saveActiveTool(activeTool); + }, + setFlashlightOn: (flashlightOn) => { + set({ flashlightOn }); + void saveFlashlightOn(flashlightOn); + }, setCreativeMode: (creativeMode) => { // Entering survival always starts with a full supply; leaving it clears any // lingering death overlay so creative shows zero survival UI. diff --git a/src/styles.css b/src/styles.css index 567099d..8a594fc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1103,6 +1103,11 @@ button { background: rgba(130, 200, 255, 0.22); border-color: rgba(160, 210, 255, 0.6); } +/* Read-only equipped-tool chip — same look as the toggle buttons, but inert + (no click target; the wheel is hold-to-open, not click-to-toggle). */ +.voxel-tool-indicator { + cursor: default; +} .voxel-power-chip { pointer-events: none; padding: 6px 12px; @@ -1640,6 +1645,34 @@ button.voxel-item-slot.active { mix-blend-mode: difference; } +/* Space-flight mining reticle — same visual language as .voxel-crosshair, + plus an on-target highlight (Freelancer/Elite-style aim-assist feedback): + brighter, warmer, and slightly larger while a rock is in range. */ +.space-crosshair { + position: fixed; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 8; + opacity: 0.55; + background: + linear-gradient(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.9)) 50% 50% / 2px 16px no-repeat, + linear-gradient(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.9)) 50% 50% / 16px 2px no-repeat; + mix-blend-mode: difference; + transition: width 0.12s ease, height 0.12s ease, opacity 0.12s ease; +} +.space-crosshair.on-target { + width: 22px; + height: 22px; + opacity: 0.9; + background: + linear-gradient(rgba(255, 150, 90, 0.95), rgba(255, 150, 90, 0.95)) 50% 50% / 2px 22px no-repeat, + linear-gradient(rgba(255, 150, 90, 0.95), rgba(255, 150, 90, 0.95)) 50% 50% / 22px 2px no-repeat; +} + /* Voxel disembark/board atmospheric wash */ .voxel-transition { position: fixed; @@ -1732,6 +1765,73 @@ button.voxel-item-slot.active { opacity: 1; box-shadow: 0 0 16px rgba(130, 200, 255, 0.55); } +/* Item-wheel open button — above the joystick (left thumb zone), clear of + both the joystick and the right-side action cluster. Smaller than the + primary action buttons since it's a modifier hold, not a tap action. */ +.voxel-wheel-btn { + position: absolute; + z-index: 1; + bottom: 250px; + left: 72px; + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(200, 190, 255, 0.4); + color: rgba(220, 210, 255, 0.85); + font-family: monospace; + font-size: 11px; + letter-spacing: 1px; + pointer-events: auto; + touch-action: none; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +/* Item wheel overlay — hold-to-open radial selector. Never modal: rendered + directly over the (still-running) game, no backdrop that blocks the view. */ +.voxel-wheel { + position: fixed; + top: 50%; + left: 50%; + width: 220px; + height: 220px; + transform: translate(-50%, -50%); + z-index: 12; + pointer-events: none; +} +.voxel-wheel-ring { + position: absolute; + inset: 0; + border-radius: 50%; + opacity: 0.35; + border: 2px solid rgba(255, 255, 255, 0.4); +} +.voxel-wheel-label { + position: absolute; + top: 50%; + left: 50%; + padding: 6px 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.85); + font-family: monospace; + font-size: 12px; + letter-spacing: 1px; + white-space: nowrap; + transition: transform 0.08s ease, background 0.12s ease, border-color 0.12s ease; +} +.voxel-wheel-label.equipped { + border-color: rgba(255, 255, 255, 0.7); +} +.voxel-wheel-label.hovered { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.9); +} + /* row 3 (top) */ .voxel-deposit-btn { bottom: 208px; @@ -1823,6 +1923,54 @@ button.voxel-item-slot.active { pointer-events: none; } +/* Fire + roll cluster — bottom-right thumb zone, just inboard of the throttle + slider, mirroring the on-foot touch action-button convention (voxel-*-btn). */ +.touch-fire-btn { + position: absolute; + bottom: 104px; + right: 100px; + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(255, 140, 90, 0.14); + border: 2px solid rgba(255, 160, 110, 0.45); + color: rgba(255, 210, 190, 0.95); + font-family: monospace; + font-size: 12px; + letter-spacing: 1px; + pointer-events: auto; + touch-action: none; + display: flex; + align-items: center; + justify-content: center; +} +.touch-fire-btn.active { + background: rgba(255, 140, 90, 0.32); + box-shadow: 0 0 16px rgba(255, 150, 100, 0.5); +} +.touch-roll-btn { + position: absolute; + bottom: 40px; + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.85); + font-size: 22px; + pointer-events: auto; + touch-action: none; + display: flex; + align-items: center; + justify-content: center; +} +.touch-roll-left { + right: 100px; +} +.touch-roll-right { + right: 164px; +} + /* Landscape: push touch controls to bottom edges */ @media (orientation: landscape) and (pointer: coarse) { .touch-joystick { @@ -1833,6 +1981,25 @@ button.voxel-item-slot.active { bottom: 20px; right: 20px; } + /* Shrink + pull the fire/roll cluster in so it stays clear of the throttle + on short landscape screens. */ + .touch-fire-btn { + bottom: 84px; + right: 76px; + width: 62px; + height: 62px; + } + .touch-roll-btn { + bottom: 20px; + width: 46px; + height: 46px; + } + .touch-roll-left { + right: 76px; + } + .touch-roll-right { + right: 130px; + } .ship-hud-telemetry { bottom: 150px; left: 12px; diff --git a/src/systems/asteroidDent.test.ts b/src/systems/asteroidDent.test.ts new file mode 100644 index 0000000..cbc83b5 --- /dev/null +++ b/src/systems/asteroidDent.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { IcosahedronGeometry, Vector3 } from 'three'; +import { applyDentToGeometry, DENT_RADIUS, MIN_RADIAL_FLOOR } from './asteroidDent'; + +function radiusOfVertex(geometry: IcosahedronGeometry, i: number): number { + const pos = geometry.attributes.position; + return new Vector3().fromBufferAttribute(pos, i).length(); +} + +describe('applyDentToGeometry', () => { + it('only moves vertices within DENT_RADIUS of the impact point', () => { + const geom = new IcosahedronGeometry(1, 1); + const pos = geom.attributes.position; + const before: number[] = []; + for (let i = 0; i < pos.count; i++) before.push(radiusOfVertex(geom, i)); + + // Impact point far outside the geometry — nothing should move. + applyDentToGeometry(geom, new Vector3(100, 100, 100), 10); + for (let i = 0; i < pos.count; i++) { + expect(radiusOfVertex(geom, i)).toBeCloseTo(before[i], 5); + } + }); + + it('pushes a vertex directly at the impact point inward, deeper with more damage', () => { + const geom = new IcosahedronGeometry(1, 1); + const pos = geom.attributes.position; + const v0 = new Vector3().fromBufferAttribute(pos, 0); + const before = v0.length(); + + applyDentToGeometry(geom, v0.clone(), 1); + const afterSmall = radiusOfVertex(geom as unknown as IcosahedronGeometry, 0); + expect(afterSmall).toBeLessThan(before); + + const geom2 = new IcosahedronGeometry(1, 1); + applyDentToGeometry(geom2, v0.clone(), 10); + const afterBig = radiusOfVertex(geom2, 0); + expect(afterBig).toBeLessThan(afterSmall); + }); + + it('falls off with distance — the exact impact vertex dents at least as much as any other in-range vertex', () => { + const geom = new IcosahedronGeometry(1, 2); + const pos = geom.attributes.position; + const impact = new Vector3().fromBufferAttribute(pos, 0); + + const distances: number[] = []; + const before: number[] = []; + for (let i = 0; i < pos.count; i++) { + distances.push(new Vector3().fromBufferAttribute(pos, i).distanceTo(impact)); + before.push(radiusOfVertex(geom, i)); + } + + applyDentToGeometry(geom, impact, 5); + + const depthAt0 = before[0] - radiusOfVertex(geom, 0); + expect(depthAt0).toBeGreaterThan(0); + for (let i = 1; i < pos.count; i++) { + if (distances[i] >= DENT_RADIUS) continue; + const depth = before[i] - radiusOfVertex(geom, i); + expect(depth).toBeLessThanOrEqual(depthAt0 + 1e-9); + } + }); + + it('never collapses a vertex through MIN_RADIAL_FLOOR even with extreme repeated damage', () => { + const geom = new IcosahedronGeometry(1, 1); + const pos = geom.attributes.position; + const impact = new Vector3().fromBufferAttribute(pos, 0); + for (let i = 0; i < 50; i++) { + applyDentToGeometry(geom, impact, 1000); + } + for (let i = 0; i < pos.count; i++) { + expect(radiusOfVertex(geom, i)).toBeGreaterThanOrEqual(MIN_RADIAL_FLOOR - 1e-6); + } + }); + + it('accumulates deeper dents on repeated hits in the same spot rather than undoing them', () => { + const geom = new IcosahedronGeometry(1, 1); + const pos = geom.attributes.position; + const impact = new Vector3().fromBufferAttribute(pos, 0); + const before = radiusOfVertex(geom, 0); + applyDentToGeometry(geom, impact, 1); + const afterFirst = radiusOfVertex(geom, 0); + applyDentToGeometry(geom, impact, 1); + const afterSecond = radiusOfVertex(geom, 0); + expect(afterFirst).toBeLessThan(before); + expect(afterSecond).toBeLessThan(afterFirst); + }); + + it('does not change vertex direction, only radial distance', () => { + const geom = new IcosahedronGeometry(1, 1); + const pos = geom.attributes.position; + const impact = new Vector3().fromBufferAttribute(pos, 0); + const beforeDir = new Vector3().fromBufferAttribute(pos, 0).normalize(); + applyDentToGeometry(geom, impact, 3); + const afterDir = new Vector3().fromBufferAttribute(pos, 0).normalize(); + expect(afterDir.dot(beforeDir)).toBeCloseTo(1, 5); + }); +}); diff --git a/src/systems/asteroidDent.ts b/src/systems/asteroidDent.ts new file mode 100644 index 0000000..f870c90 --- /dev/null +++ b/src/systems/asteroidDent.ts @@ -0,0 +1,44 @@ +// Local impact-deformation for a promoted asteroid's individually-owned +// geometry. Reuses rockGeometry.ts's exact per-vertex-mutation recipe +// (mutate `position`, `computeVertexNormals()`) — just scoped to vertices +// near an impact point instead of applied uniformly to every vertex. + +import { Vector3, type BufferGeometry } from 'three'; + +/** Local-space dent falloff radius, tuned against rockGeometry's ~1.0 nominal + * radius (proportional dents on small and large rocks alike, since every + * promoted mesh's geometry is unit-scale regardless of world size). */ +export const DENT_RADIUS = 0.9; +export const DENT_DEPTH_PER_DAMAGE = 0.05; +/** Hard floor so repeated overlapping hits can't collapse a vertex through + * the core. */ +export const MIN_RADIAL_FLOOR = 0.15; + +const _v = new Vector3(); +const _dir = new Vector3(); + +/** + * Push every vertex within `DENT_RADIUS` of `localImpactPoint` inward along + * its own outward direction, scaled by `amount` and a smooth (1-d/R)² + * falloff, floored so a vertex can never collapse through the core. Reads + * the geometry's *current* position each time (not a pristine snapshot), so + * repeated hits in the same area accumulate correctly — direction is + * invariant under this purely-radial displacement, so dents deepen rather + * than drift or fight each other. + */ +export function applyDentToGeometry(geometry: BufferGeometry, localImpactPoint: Vector3, amount: number): void { + const pos = geometry.attributes.position; + for (let i = 0; i < pos.count; i++) { + _v.fromBufferAttribute(pos, i); + const d = _v.distanceTo(localImpactPoint); + if (d >= DENT_RADIUS) continue; + const weight = (1 - d / DENT_RADIUS) ** 2; + const len = _v.length(); + _dir.copy(_v).divideScalar(len || 1); + const depth = amount * DENT_DEPTH_PER_DAMAGE * weight; + const newLen = Math.max(len - depth, MIN_RADIAL_FLOOR); + pos.setXYZ(i, _dir.x * newLen, _dir.y * newLen, _dir.z * newLen); + } + pos.needsUpdate = true; + geometry.computeVertexNormals(); +} diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts new file mode 100644 index 0000000..d089bb2 --- /dev/null +++ b/src/systems/asteroidFracture.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { IcosahedronGeometry, Quaternion, Vector3 } from 'three'; +import { applyAsteroidDamage } from './asteroidFracture'; +import { getFracturePatterns, pickPattern } from './asteroidFracturePatterns'; +import { asteroidRuntime, type AsteroidMomentumInputs } from '../scene/asteroidRuntime'; +import { debrisRuntime } from '../scene/debrisRuntime'; +import type { AsteroidState } from './asteroidState'; + +function mkState(overrides: Partial = {}): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(0, 0, 0), + vel: new Vector3(), + radius: 2, + health: 20, + maxHealth: 20, + seed: 42, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + ...overrides, + }; +} + +const IMPACT_POINT = new Vector3(1, 0, 0); +const IMPACT_VEL = new Vector3(-10, 0, 0); + +describe('applyAsteroidDamage', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + asteroidRuntime.killAsteroid = null; + asteroidRuntime.promotion = null; + debrisRuntime.list = []; + debrisRuntime.maxCount = 1000; + }); + + it('applies a knockback impulse in the shot direction even on a non-lethal hit', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + expect(state.vel.length()).toBe(0); + + applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); // low-impact, survives + expect(state.vel.length()).toBeGreaterThan(0); + // Knockback direction matches the shot's direction of travel (IMPACT_VEL is -x). + expect(state.vel.x).toBeLessThan(0); + expect(state.vel.y).toBe(0); + expect(state.vel.z).toBe(0); + }); + + it('a zero-length impactVelocity does not throw and falls back to a default direction', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + expect(() => applyAsteroidDamage(0, 2, IMPACT_POINT, new Vector3(0, 0, 0))).not.toThrow(); + expect(state.vel.length()).toBeGreaterThan(0); + }); + + it('low-impact damage (health remains) chips off one small fragment and does not kill', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + let killed = false; + asteroidRuntime.killAsteroid = () => { + killed = true; + }; + + const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(result.tier).toBe('low'); + expect(result.debrisSpawned).toHaveLength(1); + expect(state.health).toBe(18); + expect(killed).toBe(false); + expect(debrisRuntime.list).toHaveLength(1); + + const chip = result.debrisSpawned[0]; + expect(chip.cascadeDepth).toBe(1); // chips never fracture further + expect(chip.radius).toBeLessThan(state.radius * 0.2); // small piece, not a fragment + // Pre-aged so it expires well before the full debris lifetime — sustained + // fire must not fill the pool and starve real fracture fragments. + expect(chip.life).toBeGreaterThan(0); + // Spawned at the impact point (world space), not the asteroid center. + expect(chip.pos.distanceTo(IMPACT_POINT)).toBeLessThan(state.radius); + }); + + it('a lethal hit with small overkill spawns 1-5 debris (medium tier) and kills the asteroid', () => { + const state = mkState({ health: 5, maxHealth: 20 }); // small overkill on a 5-damage hit + asteroidRuntime.states = [state]; + let killedIdx: number | null = null; + asteroidRuntime.killAsteroid = (idx) => { + killedIdx = idx; + }; + + const result = applyAsteroidDamage(0, 6, IMPACT_POINT, IMPACT_VEL); + expect(result.tier).toBe('medium'); + expect(result.debrisSpawned.length).toBeGreaterThanOrEqual(1); + expect(result.debrisSpawned.length).toBeLessThanOrEqual(5); + expect(killedIdx).toBe(0); + expect(debrisRuntime.list.length).toBe(result.debrisSpawned.length); + }); + + it('a lethal hit with large overkill spawns 4-8 debris (high tier)', () => { + const state = mkState({ health: 5, maxHealth: 20 }); + asteroidRuntime.states = [state]; + asteroidRuntime.killAsteroid = () => {}; + + // Overkill = 30 damage past a health of 5 -> way past 0. Overkill fraction + // = 30/20 = 1.5, well above the 0.6 high-tier threshold. + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + expect(result.tier).toBe('high'); + expect(result.debrisSpawned.length).toBeGreaterThanOrEqual(4); + expect(result.debrisSpawned.length).toBeLessThanOrEqual(8); + }); + + it('indestructible asteroids no-op regardless of damage', () => { + const state = mkState({ health: 1, indestructible: true }); + asteroidRuntime.states = [state]; + let killed = false; + asteroidRuntime.killAsteroid = () => { + killed = true; + }; + + const result = applyAsteroidDamage(0, 1000, IMPACT_POINT, IMPACT_VEL); + expect(result.tier).toBe('none'); + expect(result.debrisSpawned).toHaveLength(0); + expect(killed).toBe(false); + expect(state.health).toBe(1); // untouched + }); + + it('already-dead asteroids no-op', () => { + const state = mkState({ alive: false }); + asteroidRuntime.states = [state]; + const result = applyAsteroidDamage(0, 100, IMPACT_POINT, IMPACT_VEL); + expect(result.tier).toBe('none'); + }); + + it('attempts promotion on the first hit and only the first hit', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + let promoteCalls = 0; + asteroidRuntime.promotion = { + promote: () => { + promoteCalls += 1; + return true; + }, + applyDent: () => {}, + getMomentumInputs: () => null, + getSourceGeometry: () => null, + getBaseGeometry: () => null, + }; + + applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(promoteCalls).toBe(1); + expect(state.promoted).toBe(true); + + // Second hit: already promoted, must not attempt promotion again. + applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(promoteCalls).toBe(1); + }); + + it('applies a dent AND chips off a small fragment on a non-lethal hit to a promoted asteroid', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + let dentCalls = 0; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: (globalIdx, point, amount) => { + dentCalls += 1; + expect(globalIdx).toBe(0); + expect(point).toBe(IMPACT_POINT); + expect(amount).toBe(2); + }, + getMomentumInputs: () => null, + getSourceGeometry: () => null, + getBaseGeometry: () => null, + }; + + const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(dentCalls).toBe(1); + expect(result.tier).toBe('low'); + expect(result.debrisSpawned).toHaveLength(1); + }); + + it('does not attempt promotion or dent when promotion returns false (over budget / ineligible)', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + let dentCalls = 0; + asteroidRuntime.promotion = { + promote: () => false, + applyDent: () => { + dentCalls += 1; + }, + getMomentumInputs: () => null, + getSourceGeometry: () => null, + getBaseGeometry: () => null, + }; + + applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(state.promoted).toBe(false); + expect(dentCalls).toBe(0); + }); + + it('debris spawn is capped by debrisRuntime.maxCount', () => { + const state = mkState({ health: 5, maxHealth: 20 }); + asteroidRuntime.states = [state]; + asteroidRuntime.killAsteroid = () => {}; + debrisRuntime.maxCount = 2; + + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); // high tier, 4-8 fragments + expect(result.debrisSpawned.length).toBeGreaterThanOrEqual(4); + expect(debrisRuntime.list.length).toBe(2); // capped even though more were "spawned" + }); +}); + +describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { + const geom = new IcosahedronGeometry(1, 1); + + function mkMomentum(overrides: Partial = {}): AsteroidMomentumInputs { + return { + angVel: new Vector3(0, 0, 0), + quat: new Quaternion(), + scale: new Vector3(1, 1, 1), + ...overrides, + }; + } + + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + asteroidRuntime.killAsteroid = () => {}; + debrisRuntime.list = []; + debrisRuntime.maxCount = 1000; + }); + + it('fragment count is clamped to the chosen pattern\'s chunkCount', () => { + const state = mkState({ health: 5, maxHealth: 20 }); + asteroidRuntime.states = [state]; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum(), + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + + const patterns = getFracturePatterns(state.tierIdx, state.variantIdx, geom); + // High-overkill hit -> 'high' tier -> 4-8 requested, but must never + // exceed whichever pattern this exact hit resolves to. + state.hitSeq = 0; // applyAsteroidDamage increments to 1 before hashing + const expectedPattern = pickPattern(patterns, 0, 1, state.seed); + + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + expect(result.debrisSpawned.length).toBeLessThanOrEqual(expectedPattern.chunkCount); + expect(result.debrisSpawned.length).toBeGreaterThan(0); + }); + + it('every pattern-based fragment carries a quaternion and angular velocity', () => { + const state = mkState({ health: 5, maxHealth: 20 }); + asteroidRuntime.states = [state]; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum(), + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + for (const spec of result.debrisSpawned) { + expect(spec.quat).toBeInstanceOf(Quaternion); + expect(spec.angVel).toBeInstanceOf(Vector3); + expect(spec.cascadeDepth).toBe(0); + // Bucketing key for AsteroidDebris.tsx's shape-matched rendering. + expect(spec.shapeKey).toEqual({ + tierIdx: state.tierIdx, + variantIdx: state.variantIdx, + patternIdx: expect.any(Number), + clusterIdx: expect.any(Number), + }); + } + }); + + it("a fragment's velocity includes the parent's angular-velocity-at-offset contribution", () => { + // Same hit, same everything, except the parent's angular velocity — + // the resulting fragment velocities must differ if the momentum formula + // is actually reading angVel (rather than only jitter/impact terms). + const stateA = mkState({ health: 5, maxHealth: 20, vel: new Vector3(0, 0, 0) }); + asteroidRuntime.states = [stateA]; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum({ angVel: new Vector3(0, 0, 0) }), + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + const resultNoSpin = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + + const stateB = mkState({ health: 5, maxHealth: 20, vel: new Vector3(0, 0, 0) }); + asteroidRuntime.states = [stateB]; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum({ angVel: new Vector3(0, 20, 0) }), // fast spin about Y + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + const resultWithSpin = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + + // At least one fragment's velocity should differ once the parent is + // spinning fast, since angVel × offset is now a nonzero contribution + // (unless every extracted chunk happened to centroid at the origin, + // vanishingly unlikely for a real icosahedron cluster). + let anyDiffers = false; + for (let i = 0; i < Math.min(resultNoSpin.debrisSpawned.length, resultWithSpin.debrisSpawned.length); i++) { + const a = resultNoSpin.debrisSpawned[i].vel; + const b = resultWithSpin.debrisSpawned[i].vel; + if (a.distanceTo(b) > 1e-6) anyDiffers = true; + } + expect(anyDiffers).toBe(true); + }); + + it('spawns fragments at the WORLD position of the asteroid when the belt is rotated (regression: local-frame spawn was instantly distance-culled)', () => { + // state.pos is belt-local; with groupYaw = π/2 the asteroid's world + // position is (0, 0, -100) — debris must spawn near there (world space, + // where the debris system simulates), not near the local (100, 0, 0). + const state = mkState({ health: 5, maxHealth: 20, pos: new Vector3(100, 0, 0) }); + asteroidRuntime.states = [state]; + asteroidRuntime.groupYaw = Math.PI / 2; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum(), + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + expect(result.debrisSpawned.length).toBeGreaterThan(0); + const worldPos = new Vector3(0, 0, -100); + for (const spec of result.debrisSpawned) { + expect(spec.pos.distanceTo(worldPos)).toBeLessThan(10); // near the world position... + expect(spec.pos.distanceTo(state.pos)).toBeGreaterThan(50); // ...not the local one + } + }); + + it('applies knockback in the belt-local frame when the belt is rotated (regression: world-frame impulse pushed rocks ~yaw degrees off the shot direction)', () => { + const state = mkState({ health: 20, maxHealth: 20 }); + asteroidRuntime.states = [state]; + asteroidRuntime.groupYaw = Math.PI / 2; + + // World-space shot direction -x; rotated into belt-local space by -π/2 + // that becomes -z (rotateY convention: local = R(-yaw) · world). + applyAsteroidDamage(0, 2, IMPACT_POINT, new Vector3(-10, 0, 0)); + expect(state.vel.z).toBeLessThan(0); + expect(Math.abs(state.vel.x)).toBeLessThan(1e-9); + }); + + it('a fragment\'s position offset scales with the parent scale and rotates with the parent orientation', () => { + const state = mkState({ health: 5, maxHealth: 20, pos: new Vector3(100, 0, 0) }); + asteroidRuntime.states = [state]; + asteroidRuntime.promotion = { + promote: () => true, + applyDent: () => {}, + getMomentumInputs: () => mkMomentum({ scale: new Vector3(5, 5, 5) }), // large parent + getSourceGeometry: () => geom, + getBaseGeometry: () => null, + }; + + const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); + // Every fragment's position must be offset from the parent's own + // position (state.pos) by some nonzero amount that reflects the 5x + // scale-up (chunks extracted from a unit geometry, so a bare offset + // without scaling would be tiny by comparison). + for (const spec of result.debrisSpawned) { + const dist = spec.pos.distanceTo(state.pos); + expect(dist).toBeGreaterThan(0); + } + }); +}); diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts new file mode 100644 index 0000000..6554104 --- /dev/null +++ b/src/systems/asteroidFracture.ts @@ -0,0 +1,301 @@ +// Asteroid damage/fracture model. An asteroid is a composite body with +// localized structural health, not a binary object — low-damage hits just +// wear it down (leaving a persistent local dent, see asteroidDent.ts), a hit +// that finishes it off extracts real chunks of its own geometry (see +// asteroidFracturePatterns.ts) as independent debris fragments whose initial +// velocity/spin follow real rigid-body momentum (parent linear + angular +// velocity at the fragment's offset, plus the impact's own direction/force), +// and a hit that finishes it off with a lot of spare force extracts more, +// larger fragments. Debris never recursively fractures beyond one cascade +// level (see debrisPhysics.ts) — this is the one damage pipeline both +// collision (shipCollision.ts) and deliberate mining/weapon fire +// (spaceMining.ts) route through. + +import { Quaternion, Vector3 } from 'three'; +import { cellHash } from '../voxel/noise'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { debrisRuntime, type DebrisSpawnSpec } from '../scene/debrisRuntime'; +import { rotateY } from '../ship/shipCollision'; +import { + getFracturePatterns, + pickPatternIndex, + pickClusterIndices, + computeClusterCentroid, +} from './asteroidFracturePatterns'; +import type { ResourceType } from '../voxel/voxelTypes'; + +export type FractureTier = 'none' | 'low' | 'medium' | 'high'; + +export interface FractureResult { + tier: FractureTier; + debrisSpawned: DebrisSpawnSpec[]; +} + +/** Overkill (damage beyond zero health) as a fraction of maxHealth, above + * which a fracture is "high" tier instead of "medium". */ +const HIGH_OVERKILL_FRAC = 0.6; +const MEDIUM_DEBRIS_RANGE: [number, number] = [1, 5]; +const HIGH_DEBRIS_RANGE: [number, number] = [4, 8]; + +/** Fraction of a fracture's fragments that come back as collectible ore + * rather than plain inert rock — applies uniformly regardless of whether the + * fracture was mining- or collision-triggered (one damage pipeline, same + * reward either way). */ +const ORE_FRACTION = 0.4; +const ORE_TYPES: ResourceType[] = ['iron', 'silicon', 'titanite', 'hematite', 'lithium']; + +/** Knockback impulse per unit of damage (world units/s per damage point) — + * every hit nudges the asteroid in the shot's direction of travel, not just + * fracturing ones. Tuned so a solid hit visibly shifts a rock without + * flinging it; actual on-screen drift is also damped in AsteroidBelt.tsx. */ +const KNOCKBACK_PER_DAMAGE = 0.35; +/** Cap on the impact speed fed into fragment-ejection energy. The impact + * velocity is the projectile's real flight velocity (~220 u/s) — using it + * raw would eject fragments at 90-150 u/s, fast enough to streak out of + * sight in a fraction of a second (debris read as "nothing spawned"). + * Physically: only a fraction of the projectile's energy transfers to + * fragments. Ejection direction still follows the real impact vector. */ +const MAX_EJECTION_SPEED = 30; +/** Every connecting hit chips off one small, short-lived real fragment at + * the impact point — visible "pieces breaking off" feedback on every shot, + * not just the lethal one. Short lifetime (they expire after + * CHIP_LIFE_SEC, not the full debris lifetime) so sustained fire can't + * fill the debris pool and starve real fracture fragments of budget. */ +const CHIP_RADIUS_FRAC = 0.12; +const CHIP_MIN_RADIUS = 0.06; +const CHIP_LIFE_SEC = 1.5; +/** Fragment spin rate cap (rad/s) — faster/smaller ejecta spin faster, but + * never so fast it reads as jittery noise. */ +const MAX_SPIN_RATE = 6; + +const _dir = new Vector3(); +const _dirLocal = new Vector3(); +const _centroid = new Vector3(); +const _parentOffset = new Vector3(); +const _angVelCross = new Vector3(); +const _fragVel = new Vector3(); +const _fragQuat = new Quaternion(); +const _yawQuat = new Quaternion(); +const _yAxis = new Vector3(0, 1, 0); +const _localPos = new Vector3(); +const _worldPos = new Vector3(); +const _worldVel = new Vector3(); + +interface SharedFragmentProps { + jitter: Vector3; + outwardSpeed: number; + fragRadius: number; + isOre: boolean; + resourceType: ResourceType | undefined; + angVel: Vector3; +} + +/** Per-fragment properties common to both the pattern-based and fallback + * spawn paths — deterministically hashed off this specific hit (never + * `Math.random()`), so repeated hits on the same rock don't collide on + * identical inputs while staying reproducible within the session. */ +function computeSharedFragmentProps( + globalIdx: number, + stream: number, + seed: number, + speed: number, + parentRadius: number, +): SharedFragmentProps { + const theta = cellHash(globalIdx, stream, seed + 6001) * Math.PI * 2; + const phi = Math.acos(cellHash(globalIdx, stream, seed + 6002) * 2 - 1); + const jitter = new Vector3(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi)); + const outwardSpeed = 2 + speed * 0.4; + + // Mass split proportional to radius^3 of the parent (roughly conserved, + // not exact) — approximated here directly as a radius fraction. + const fragFrac = 0.25 + cellHash(globalIdx, stream, seed + 6003) * 0.35; + const fragRadius = parentRadius * fragFrac; + + const oreHash = cellHash(globalIdx, stream, seed + 6004); + const isOre = oreHash < ORE_FRACTION; + const resourceType = isOre + ? ORE_TYPES[Math.floor(cellHash(globalIdx, stream, seed + 6005) * ORE_TYPES.length)] + : undefined; + + // Spin: faster, smaller ejecta tumble faster — physically motivated, cheap. + const spinTheta = cellHash(globalIdx, stream, seed + 6006) * Math.PI * 2; + const spinPhi = Math.acos(cellHash(globalIdx, stream, seed + 6007) * 2 - 1); + const spinAxis = new Vector3( + Math.sin(spinPhi) * Math.cos(spinTheta), + Math.sin(spinPhi) * Math.sin(spinTheta), + Math.cos(spinPhi), + ); + const spinRate = Math.min( + (0.3 + cellHash(globalIdx, stream, seed + 6008) * 1.2) * (outwardSpeed / Math.max(fragRadius, 0.1)), + MAX_SPIN_RATE, + ); + const angVel = spinAxis.multiplyScalar(spinRate); + + return { jitter, outwardSpeed, fragRadius, isOre, resourceType, angVel }; +} + +/** + * Apply `amount` damage to asteroid `globalIdx` from a hit at `impactPoint` + * with `impactVelocity` (world-space; used to bias fragment ejection + * direction and scale their outward speed). Mutates the asteroid's health in + * place; on a lethal hit, kills the asteroid (via `asteroidRuntime`) and + * spawns debris (via `debrisRuntime`) as a side effect, in addition to + * returning the result for inspection/tests. + */ +export function applyAsteroidDamage( + globalIdx: number, + amount: number, + impactPoint: Vector3, + impactVelocity: Vector3, +): FractureResult { + const state = asteroidRuntime.states[globalIdx]; + if (!state || !state.alive || state.indestructible) { + return { tier: 'none', debrisSpawned: [] }; + } + + _dir.copy(impactVelocity); + const speed = _dir.length(); + if (speed < 1e-6) _dir.set(0, 0, -1); + else _dir.normalize(); + + // The impact direction arrives in world space, but `state.pos`/`state.vel` + // live in belt-local space (the whole belt group is rotated by `groupYaw` + // each frame, and the tumble loop integrates pos/vel in that local frame) — + // rotate the direction into the belt frame before using it for anything + // that feeds local state, and rotate spawned debris back out to world + // (debris simulates in world space, so a local-frame spawn position would + // be off by the full belt rotation — thousands of units at belt radius). + const yaw = asteroidRuntime.groupYaw; + rotateY(_dir, -yaw, _dirLocal); + const ejectionSpeed = Math.min(speed, MAX_EJECTION_SPEED); + + // Knockback: every hit nudges the asteroid in the shot's direction of + // travel, whether or not it fractures — a physical reaction to being hit, + // not just a destruction effect. Integrated/damped in AsteroidBelt.tsx. + state.vel.addScaledVector(_dirLocal, amount * KNOCKBACK_PER_DAMAGE); + + // Promote on the first damaging hit (tier/budget permitting — see + // AsteroidBelt.tsx's promotion API) so local damage has a standalone, + // individually deformable mesh to actually dent instead of just decrementing + // health invisibly. This is the single source of truth for `state.promoted` + // — the promotion API itself just reports success/failure. + if (!state.promoted && asteroidRuntime.promotion?.promote(globalIdx)) { + state.promoted = true; + } + + state.health -= amount; + state.hitSeq += 1; + + if (state.health > 0) { + // Low impact: real local damage — a persistent crater at the impact + // point (no-op if this rock wasn't promoted: dust tier, or the promotion + // budget was full) — plus one small, short-lived chip knocked off at the + // impact point, so every connecting shot visibly breaks a piece off + // instead of only decrementing health. Chip position/velocity are + // world-frame throughout (impactPoint and _dir both arrive in world + // space), matching the world-space debris simulation. + if (state.promoted) asteroidRuntime.promotion?.applyDent(globalIdx, impactPoint, amount); + + const shared = computeSharedFragmentProps(globalIdx, state.hitSeq * 100 + 99, state.seed, ejectionSpeed, state.radius); + const chipRadius = Math.max(state.radius * CHIP_RADIUS_FRAC, CHIP_MIN_RADIUS); + const chip: DebrisSpawnSpec = { + pos: impactPoint.clone().addScaledVector(shared.jitter, chipRadius), + vel: shared.jitter + .clone() + .multiplyScalar(shared.outwardSpeed * 0.6) + .addScaledVector(_dir, ejectionSpeed * 0.15), + radius: chipRadius, + isOre: false, + angVel: shared.angVel, + cascadeDepth: 1, // chips never fracture further + life: Math.max(0, debrisRuntime.maxLifeSec - CHIP_LIFE_SEC), + }; + debrisRuntime.spawn(chip); + return { tier: 'low', debrisSpawned: [chip] }; + } + + const overkillFrac = -state.health / state.maxHealth; + const tier: FractureTier = overkillFrac >= HIGH_OVERKILL_FRAC ? 'high' : 'medium'; + const [minN, maxN] = tier === 'high' ? HIGH_DEBRIS_RANGE : MEDIUM_DEBRIS_RANGE; + const countHash = cellHash(globalIdx, state.hitSeq, state.seed + 6000); + const count = minN + Math.floor(countHash * (maxN - minN + 1)); + + const geometry = asteroidRuntime.promotion?.getSourceGeometry(globalIdx) ?? null; + const momentum = asteroidRuntime.promotion?.getMomentumInputs(globalIdx) ?? null; + + const debrisSpawned: DebrisSpawnSpec[] = []; + + if (geometry && momentum) { + // Pattern-based extraction: fragments come from real clusters of the + // asteroid's own (possibly already-dented) geometry, positioned/launched + // via real rigid-body momentum — parent linear velocity + parent angular + // velocity at the fragment's offset-from-center, plus the existing + // impact-driven ejection terms. + const patterns = getFracturePatterns(state.tierIdx, state.variantIdx, geometry); + const patternIdx = pickPatternIndex(patterns, globalIdx, state.hitSeq, state.seed); + const pattern = patterns[patternIdx]; + const clusterIndices = pickClusterIndices(pattern, count, globalIdx, state.hitSeq, state.seed); + + // Fragment kinematics are computed in the belt-local frame (state.pos/ + // state.vel/momentum.quat/momentum.angVel all live there), then rotated + // to world for the spawn — debris simulates in world space. + _yawQuat.setFromAxisAngle(_yAxis, yaw); + + for (let k = 0; k < clusterIndices.length; k++) { + const stream = state.hitSeq * 100 + k; + const shared = computeSharedFragmentProps(globalIdx, stream, state.seed, ejectionSpeed, state.radius); + + computeClusterCentroid(geometry, pattern, clusterIndices[k], _centroid); + _parentOffset.copy(_centroid).multiply(momentum.scale).applyQuaternion(momentum.quat); + + _fragVel + .copy(state.vel) + .add(_angVelCross.copy(momentum.angVel).cross(_parentOffset)) + .addScaledVector(shared.jitter, shared.outwardSpeed) + .addScaledVector(_dirLocal, ejectionSpeed * 0.3); + + _localPos.copy(state.pos).add(_parentOffset); + rotateY(_localPos, yaw, _worldPos); + rotateY(_fragVel, yaw, _worldVel); + // Fragment starts oriented like the parent it broke from — the + // parent's local orientation composed with the belt's own rotation. + _fragQuat.copy(_yawQuat).multiply(momentum.quat); + + debrisSpawned.push({ + pos: _worldPos.clone(), + vel: _worldVel.clone(), + radius: shared.fragRadius, + isOre: shared.isOre, + resourceType: shared.resourceType, + angVel: shared.angVel, + quat: _fragQuat.clone(), + cascadeDepth: 0, + shapeKey: { tierIdx: state.tierIdx, variantIdx: state.variantIdx, patternIdx, clusterIdx: clusterIndices[k] }, + }); + } + } else { + // Fallback (no promotion API registered — e.g. no belt mounted): the + // simpler jitter-only spawn, no momentum/geometry inputs available. + for (let k = 0; k < count; k++) { + const stream = state.hitSeq * 100 + k; + const shared = computeSharedFragmentProps(globalIdx, stream, state.seed, ejectionSpeed, state.radius); + const vel = shared.jitter.clone().multiplyScalar(shared.outwardSpeed).addScaledVector(_dir, ejectionSpeed * 0.3); + const pos = impactPoint.clone().addScaledVector(shared.jitter, shared.fragRadius * 0.5); + + debrisSpawned.push({ + pos, + vel, + radius: shared.fragRadius, + isOre: shared.isOre, + resourceType: shared.resourceType, + angVel: shared.angVel, + cascadeDepth: 0, + }); + } + } + + for (const spec of debrisSpawned) debrisRuntime.spawn(spec); + asteroidRuntime.killAsteroid?.(globalIdx); + + return { tier, debrisSpawned }; +} diff --git a/src/systems/asteroidFracturePatterns.test.ts b/src/systems/asteroidFracturePatterns.test.ts new file mode 100644 index 0000000..74f1e86 --- /dev/null +++ b/src/systems/asteroidFracturePatterns.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { IcosahedronGeometry, Vector3 } from 'three'; +import { + getFracturePatterns, + pickPattern, + pickClusterIndices, + computeClusterCentroid, + extractChunkGeometry, +} from './asteroidFracturePatterns'; + +describe('getFracturePatterns', () => { + it('is deterministic — same (tier, variant) always yields the same cluster assignment', () => { + const geomA = new IcosahedronGeometry(1, 1); + const geomB = new IcosahedronGeometry(1, 1); + const patternsA = getFracturePatterns(0, 0, geomA); + // Second call for the same key should hit the cache and return the exact + // same (cached) object, regardless of which geometry instance is passed. + const patternsB = getFracturePatterns(0, 0, geomB); + expect(patternsB).toBe(patternsA); + }); + + it('produces 2 patterns, each with chunkCount in [3,6]', () => { + const geom = new IcosahedronGeometry(1, 2); + const patterns = getFracturePatterns(1, 2, geom); + expect(patterns).toHaveLength(2); + for (const p of patterns) { + expect(p.chunkCount).toBeGreaterThanOrEqual(3); + expect(p.chunkCount).toBeLessThanOrEqual(6); + } + }); + + it('assigns every face to a valid cluster index', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(2, 0, geom); + for (const p of patterns) { + for (const clusterId of p.faceClusterId) { + expect(clusterId).toBeGreaterThanOrEqual(0); + expect(clusterId).toBeLessThan(p.chunkCount); + } + } + }); + + it('different (tier, variant) combos produce different-looking patterns', () => { + const geom = new IcosahedronGeometry(1, 1); + const patternsA = getFracturePatterns(3, 0, geom); + const patternsB = getFracturePatterns(4, 0, geom); + // Not a strict inequality requirement (could coincidentally match), just + // sanity that distinct keys aren't silently sharing cached state. + expect(patternsA).not.toBe(patternsB); + }); +}); + +describe('pickPattern', () => { + it('is deterministic for the same (globalIdx, hitSeq, seed)', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(0, 0, geom); + const a = pickPattern(patterns, 42, 3, 100); + const b = pickPattern(patterns, 42, 3, 100); + expect(a).toBe(b); + }); + + it('always returns one of the precomputed patterns', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(0, 1, geom); + for (let hitSeq = 0; hitSeq < 20; hitSeq++) { + const pattern = pickPattern(patterns, 7, hitSeq, 5); + expect(patterns).toContain(pattern); + } + }); +}); + +describe('pickClusterIndices', () => { + it('returns distinct indices, never repeating within one call', () => { + const geom = new IcosahedronGeometry(1, 2); + const patterns = getFracturePatterns(2, 1, geom); + const pattern = patterns[0]; + const indices = pickClusterIndices(pattern, pattern.chunkCount, 10, 1, 50); + expect(new Set(indices).size).toBe(indices.length); + for (const idx of indices) { + expect(idx).toBeGreaterThanOrEqual(0); + expect(idx).toBeLessThan(pattern.chunkCount); + } + }); + + it('clamps to chunkCount when count exceeds it', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(1, 0, geom); + const pattern = patterns[0]; + const indices = pickClusterIndices(pattern, 999, 1, 1, 1); + expect(indices.length).toBe(pattern.chunkCount); + }); +}); + +describe('computeClusterCentroid / extractChunkGeometry', () => { + it('extractChunkGeometry recenters the chunk on its own centroid (origin ~ 0,0,0)', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(0, 0, geom); + const pattern = patterns[0]; + const { geometry } = extractChunkGeometry(geom, pattern, 0); + const pos = geometry.attributes.position; + const center = new Vector3(); + for (let i = 0; i < pos.count; i++) center.add(new Vector3().fromBufferAttribute(pos, i)); + center.divideScalar(pos.count); + expect(center.length()).toBeLessThan(1e-5); + }); + + it('computeClusterCentroid matches extractChunkGeometry\'s reported centroid', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(0, 0, geom); + const pattern = patterns[0]; + const { centroid: fromExtract } = extractChunkGeometry(geom, pattern, 0); + const fromCompute = new Vector3(); + computeClusterCentroid(geom, pattern, 0, fromCompute); + expect(fromCompute.distanceTo(fromExtract)).toBeLessThan(1e-6); + }); + + it('produces non-empty geometry for every valid cluster index', () => { + const geom = new IcosahedronGeometry(1, 1); + const patterns = getFracturePatterns(1, 1, geom); + const pattern = patterns[0]; + for (let c = 0; c < pattern.chunkCount; c++) { + // Only assert non-empty if the cluster actually has faces assigned + // (possible, if unlikely, for a cluster to end up empty on a small + // low-poly geometry) — guard against that rather than assume. + const hasFaces = pattern.faceClusterId.includes(c); + if (!hasFaces) continue; + const { geometry } = extractChunkGeometry(geom, pattern, c); + expect(geometry.attributes.position.count).toBeGreaterThan(0); + } + }); +}); diff --git a/src/systems/asteroidFracturePatterns.ts b/src/systems/asteroidFracturePatterns.ts new file mode 100644 index 0000000..6e4c77a --- /dev/null +++ b/src/systems/asteroidFracturePatterns.ts @@ -0,0 +1,188 @@ +// Precomputed "fracture patterns" for lethal asteroid hits. Real-time CSG/ +// Voronoi mesh splitting has no precedent in this codebase and is too heavy +// for a tumbling background rock; this is a pragmatic approximation instead: +// partition each base geometry's faces into a handful of contiguous clusters +// once (via nearest-seed-point/Voronoi-on-sphere clustering of face +// centroids — no adjacency graph needed, just a distance/dot-product test +// per face), cache the partition, and at fracture time extract whichever +// clusters are needed straight from the *current* source geometry (which may +// already be locally dented — see asteroidDent.ts) so fragments visually +// read as pieces of the specific rock that broke, not generic rubble. + +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { cellHash } from '../voxel/noise'; + +const PATTERNS_PER_GEOMETRY = 2; +const MIN_CHUNKS = 3; +const MAX_CHUNKS = 6; // MIN_CHUNKS..MAX_CHUNKS inclusive + +export interface FracturePattern { + chunkCount: number; + /** One entry per face (triangle), value = which chunk cluster it belongs to. */ + faceClusterId: Int32Array; +} + +const patternCache = new Map(); + +function faceCount(geometry: BufferGeometry): number { + const index = geometry.index; + return Math.floor((index ? index.count : geometry.attributes.position.count) / 3); +} + +const _va = new Vector3(); +const _vb = new Vector3(); +const _vc = new Vector3(); + +function faceIndices(geometry: BufferGeometry, faceIdx: number): [number, number, number] { + const index = geometry.index; + if (index) return [index.getX(faceIdx * 3), index.getX(faceIdx * 3 + 1), index.getX(faceIdx * 3 + 2)]; + const base = faceIdx * 3; + return [base, base + 1, base + 2]; +} + +function faceCentroidDirection(geometry: BufferGeometry, faceIdx: number, out: Vector3): Vector3 { + const pos = geometry.attributes.position; + const [a, b, c] = faceIndices(geometry, faceIdx); + _va.fromBufferAttribute(pos, a); + _vb.fromBufferAttribute(pos, b); + _vc.fromBufferAttribute(pos, c); + return out.copy(_va).add(_vb).add(_vc).divideScalar(3).normalize(); +} + +/** + * Precomputed patterns for one (tierIdx, variantIdx)'s base geometry topology + * — dents only move vertex *positions*, never add/remove faces, so a face's + * cluster assignment computed against the pristine topology stays valid + * forever, including against a live dented promoted mesh. Cached lazily on + * first use (mirrors `rockGeometry`'s one-time-factory framing) — a handful + * of patterns total across the belt's 6 base geometries, not a per-frame cost. + */ +export function getFracturePatterns(tierIdx: number, variantIdx: number, geometry: BufferGeometry): FracturePattern[] { + const key = `${tierIdx}:${variantIdx}`; + const cached = patternCache.get(key); + if (cached) return cached; + + const nFaces = faceCount(geometry); + const patterns: FracturePattern[] = []; + const centroid = new Vector3(); + + for (let p = 0; p < PATTERNS_PER_GEOMETRY; p++) { + const chunkCount = + MIN_CHUNKS + Math.floor(cellHash(tierIdx, variantIdx * 2 + p, 8000) * (MAX_CHUNKS - MIN_CHUNKS + 1)); + const seeds: Vector3[] = []; + for (let k = 0; k < chunkCount; k++) { + const stream = variantIdx * 100 + p * 10 + k; + const theta = cellHash(tierIdx, stream, 8001) * Math.PI * 2; + const phi = Math.acos(cellHash(tierIdx, stream, 8002) * 2 - 1); + seeds.push(new Vector3(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi))); + } + const faceClusterId = new Int32Array(nFaces); + for (let f = 0; f < nFaces; f++) { + faceCentroidDirection(geometry, f, centroid); + let best = 0; + let bestDot = -Infinity; + for (let k = 0; k < seeds.length; k++) { + const dot = centroid.dot(seeds[k]); + if (dot > bestDot) { + bestDot = dot; + best = k; + } + } + faceClusterId[f] = best; + } + patterns.push({ chunkCount, faceClusterId }); + } + + patternCache.set(key, patterns); + return patterns; +} + +/** Deterministic pattern index for a specific hit — matches the existing + * `cellHash`-based hashing convention used throughout the fracture system. + * Exported (not just inlined in `pickPattern`) so callers that need to + * bucket rendering by shape (see AsteroidDebris.tsx) can know *which* + * pattern was picked, not just get the pattern object back. */ +export function pickPatternIndex(patterns: FracturePattern[], globalIdx: number, hitSeq: number, seed: number): number { + const idx = cellHash(globalIdx, hitSeq, seed + 7000) < 0.5 ? 0 : 1; + return Math.min(idx, patterns.length - 1); +} + +export function pickPattern(patterns: FracturePattern[], globalIdx: number, hitSeq: number, seed: number): FracturePattern { + return patterns[pickPatternIndex(patterns, globalIdx, hitSeq, seed)]; +} + +/** Deterministically pick `count` distinct cluster indices out of + * `pattern.chunkCount` (count is expected to already be clamped to + * chunkCount by the caller, so this never needs to repeat an index). */ +export function pickClusterIndices(pattern: FracturePattern, count: number, globalIdx: number, hitSeq: number, seed: number): number[] { + const pool = Array.from({ length: pattern.chunkCount }, (_, i) => i); + const picked: number[] = []; + const n = Math.min(count, pattern.chunkCount); + for (let k = 0; k < n; k++) { + const h = cellHash(globalIdx, hitSeq * 100 + k, seed + 7100); + const idx = Math.floor(h * pool.length); + picked.push(pool.splice(idx, 1)[0]); + } + return picked; +} + +/** Cheap centroid-only computation (unit-object-space direction of the + * cluster's center) — used by the momentum formula, which only needs the + * offset-from-parent-center, not a renderable geometry. */ +export function computeClusterCentroid(sourceGeometry: BufferGeometry, pattern: FracturePattern, clusterIdx: number, out: Vector3): Vector3 { + const pos = sourceGeometry.attributes.position; + out.set(0, 0, 0); + let n = 0; + for (let f = 0; f < pattern.faceClusterId.length; f++) { + if (pattern.faceClusterId[f] !== clusterIdx) continue; + const [a, b, c] = faceIndices(sourceGeometry, f); + _va.fromBufferAttribute(pos, a); + _vb.fromBufferAttribute(pos, b); + _vc.fromBufferAttribute(pos, c); + out.add(_va).add(_vb).add(_vc); + n += 3; + } + if (n > 0) out.divideScalar(n); + return out; +} + +/** + * Full extraction: pulls the cluster's faces from the *current* source + * buffer into a standalone, non-indexed, recentered BufferGeometry — this is + * what makes a fragment visually read as a piece of the specific (possibly + * already-dented) rock it came from, since clustering topology is shared but + * vertex data is always fresh. Returns the geometry plus the same centroid + * `computeClusterCentroid` would produce (in the *original*, un-recentered + * source space) so callers can use it as the fragment's offset-from-parent- + * center for the momentum formula. + */ +export function extractChunkGeometry( + sourceGeometry: BufferGeometry, + pattern: FracturePattern, + clusterIdx: number, +): { geometry: BufferGeometry; centroid: Vector3 } { + const pos = sourceGeometry.attributes.position; + const positions: number[] = []; + for (let f = 0; f < pattern.faceClusterId.length; f++) { + if (pattern.faceClusterId[f] !== clusterIdx) continue; + const [a, b, c] = faceIndices(sourceGeometry, f); + for (const vi of [a, b, c]) { + positions.push(pos.getX(vi), pos.getY(vi), pos.getZ(vi)); + } + } + + const centroid = new Vector3(); + const nVerts = positions.length / 3; + for (let i = 0; i < nVerts; i++) centroid.add(new Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2])); + if (nVerts > 0) centroid.divideScalar(nVerts); + for (let i = 0; i < nVerts; i++) { + positions[i * 3] -= centroid.x; + positions[i * 3 + 1] -= centroid.y; + positions[i * 3 + 2] -= centroid.z; + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(positions), 3)); + geometry.computeVertexNormals(); + return { geometry, centroid }; +} diff --git a/src/systems/asteroidGrid.test.ts b/src/systems/asteroidGrid.test.ts new file mode 100644 index 0000000..e03d32d --- /dev/null +++ b/src/systems/asteroidGrid.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { buildAsteroidGrid, queryNearby, removeFromGrid, insertIntoGrid, RADIAL_CELL } from './asteroidGrid'; +import type { AsteroidState } from './asteroidState'; +import { INNER, OUTER } from './asteroidLayout'; + +function mkState(x: number, z: number, alive = true): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(x, 0, z), + vel: new Vector3(), + radius: 1, + health: 10, + maxHealth: 10, + seed: 0, + alive, + indestructible: false, + hitSeq: 0, + promoted: false, + }; +} + +const MID_R = (INNER + OUTER) / 2; + +describe('asteroidGrid', () => { + it('finds a nearby asteroid via queryNearby', () => { + const states = [mkState(MID_R, 0)]; + const grid = buildAsteroidGrid(states); + const candidates = queryNearby(grid, MID_R, 0, RADIAL_CELL); + expect(candidates).toContain(0); + }); + + it('does not return an asteroid far outside the query radius/ring', () => { + const states = [mkState(MID_R, 0), mkState(MID_R + 500, 0)]; + const grid = buildAsteroidGrid(states); + const candidates = queryNearby(grid, MID_R, 0, RADIAL_CELL); + expect(candidates).toContain(0); + expect(candidates).not.toContain(1); + }); + + it('handles angular wraparound at 0/2π', () => { + // One asteroid just past angle 0, one just before 2π — should be + // neighbors in angle-space despite being far apart numerically. + const nearZero = new Vector3(MID_R * Math.cos(0.001), 0, MID_R * Math.sin(0.001)); + const nearTwoPi = new Vector3( + MID_R * Math.cos(Math.PI * 2 - 0.001), + 0, + MID_R * Math.sin(Math.PI * 2 - 0.001), + ); + const states = [ + { ...mkState(0, 0), pos: nearZero }, + { ...mkState(0, 0), pos: nearTwoPi }, + ]; + const grid = buildAsteroidGrid(states); + const candidates = queryNearby(grid, nearZero.x, nearZero.z, RADIAL_CELL); + expect(candidates).toContain(0); + expect(candidates).toContain(1); + }); + + it('excludes dead asteroids from the built grid', () => { + const states = [mkState(MID_R, 0, false)]; + const grid = buildAsteroidGrid(states); + const candidates = queryNearby(grid, MID_R, 0, RADIAL_CELL); + expect(candidates).not.toContain(0); + }); + + it('removeFromGrid/insertIntoGrid update membership', () => { + const states = [mkState(MID_R, 0)]; + const grid = buildAsteroidGrid(states); + expect(queryNearby(grid, MID_R, 0, RADIAL_CELL)).toContain(0); + + removeFromGrid(grid, 0, MID_R, 0); + expect(queryNearby(grid, MID_R, 0, RADIAL_CELL)).not.toContain(0); + + insertIntoGrid(grid, 0, MID_R, 0); + expect(queryNearby(grid, MID_R, 0, RADIAL_CELL)).toContain(0); + }); + + it('returns nothing when queried far outside the annulus', () => { + const states = [mkState(MID_R, 0)]; + const grid = buildAsteroidGrid(states); + const candidates = queryNearby(grid, 0, 0, RADIAL_CELL); // center of the sun, well inside INNER + expect(candidates).not.toContain(0); + }); +}); diff --git a/src/systems/asteroidGrid.ts b/src/systems/asteroidGrid.ts new file mode 100644 index 0000000..df7ae46 --- /dev/null +++ b/src/systems/asteroidGrid.ts @@ -0,0 +1,114 @@ +// Spatial broadphase for the asteroid belt. The belt is a thin torus, not a +// cube volume — a plain 3D world-space hash grid would waste huge numbers of +// empty cells outside the annulus, so this uses a cylindrical grid (radial + +// angular bins) instead. No vertical subdivision: THICKNESS is small enough +// relative to belt population that a 3rd axis wouldn't reduce candidate +// counts meaningfully. +// +// Queries must be done in *belt-local* space (i.e. with the whole-belt +// `group.rotation.y` orbital drift undone) — the grid itself never needs a +// per-frame rebuild for that drift, only for fracture events that add/remove +// asteroids. + +import type { AsteroidState } from './asteroidState'; +import { INNER, OUTER } from './asteroidLayout'; +import { WORLD_SCALE } from './bodies'; + +export const RADIAL_CELL = 8 * WORLD_SCALE; +export const ANGULAR_BINS = 600; +const RADIAL_BINS = Math.max(1, Math.ceil((OUTER - INNER) / RADIAL_CELL)); +const ANGULAR_WIDTH = (Math.PI * 2) / ANGULAR_BINS; + +export interface AsteroidGrid { + /** cell key -> array of global asteroid-state indices currently in it. */ + cells: Map; + radialBins: number; +} + +function radialBinOf(r: number): number { + const b = Math.floor((r - INNER) / RADIAL_CELL); + return Math.min(RADIAL_BINS - 1, Math.max(0, b)); +} + +function angularBinOf(theta: number): number { + const t = ((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + return Math.min(ANGULAR_BINS - 1, Math.floor(t / ANGULAR_WIDTH)); +} + +function cellKey(radialBin: number, angularBin: number): number { + return radialBin * ANGULAR_BINS + angularBin; +} + +function cellKeyForPos(x: number, z: number): number { + return cellKey(radialBinOf(Math.hypot(x, z)), angularBinOf(Math.atan2(z, x))); +} + +/** Full O(n) rebuild — call only at (re)construction or after a fracture + * batch, never every frame (the belt's whole-group rotation doesn't require + * it; only genuine population/position changes do). */ +export function buildAsteroidGrid(states: AsteroidState[]): AsteroidGrid { + const cells = new Map(); + for (let i = 0; i < states.length; i++) { + const s = states[i]; + if (!s.alive) continue; + const key = cellKeyForPos(s.pos.x, s.pos.z); + let bucket = cells.get(key); + if (!bucket) { + bucket = []; + cells.set(key, bucket); + } + bucket.push(i); + } + return { cells, radialBins: RADIAL_BINS }; +} + +/** + * Candidate global indices within `radius` of belt-local `(x, z)`. Returns a + * superset (cell-granularity, not an exact distance test) — callers do the + * precise sphere test against the shortlist. Ring size grows with `radius` + * relative to the cell size so a wider query (e.g. mining range) still finds + * everything relevant. + */ +export function queryNearby(grid: AsteroidGrid, x: number, z: number, radius: number): number[] { + const r = Math.hypot(x, z); + const theta = Math.atan2(z, x); + const rb = radialBinOf(r); + const ab = angularBinOf(theta); + const ring = Math.max(1, Math.ceil(radius / RADIAL_CELL)); + + const result: number[] = []; + for (let dr = -ring; dr <= ring; dr++) { + const rbin = rb + dr; + if (rbin < 0 || rbin >= grid.radialBins) continue; + for (let da = -ring; da <= ring; da++) { + const abin = ((ab + da) % ANGULAR_BINS + ANGULAR_BINS) % ANGULAR_BINS; + const bucket = grid.cells.get(cellKey(rbin, abin)); + if (bucket) result.push(...bucket); + } + } + return result; +} + +/** Remove one asteroid from the grid (called on fracture — the parent is + * destroyed). No-op if it isn't present. */ +export function removeFromGrid(grid: AsteroidGrid, globalIdx: number, x: number, z: number): void { + const bucket = grid.cells.get(cellKeyForPos(x, z)); + if (!bucket) return; + const i = bucket.indexOf(globalIdx); + if (i >= 0) { + bucket[i] = bucket[bucket.length - 1]; + bucket.pop(); + } +} + +/** Insert one asteroid/debris index into the grid (called on fracture — + * spawned fragments that live in the belt-local frame). */ +export function insertIntoGrid(grid: AsteroidGrid, globalIdx: number, x: number, z: number): void { + const key = cellKeyForPos(x, z); + let bucket = grid.cells.get(key); + if (!bucket) { + bucket = []; + grid.cells.set(key, bucket); + } + bucket.push(globalIdx); +} diff --git a/src/systems/asteroidLayout.test.ts b/src/systems/asteroidLayout.test.ts new file mode 100644 index 0000000..a1c83dc --- /dev/null +++ b/src/systems/asteroidLayout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { placeAsteroid, TIERS, BELT_SEED } from './asteroidLayout'; + +describe('placeAsteroid determinism', () => { + it('is a pure function of (tier, variant, index, seed) — same inputs, same output', () => { + const a = placeAsteroid(1, 0, 42, BELT_SEED); + const b = placeAsteroid(1, 0, 42, BELT_SEED); + expect(a.pos.equals(b.pos)).toBe(true); + expect(a.scale.equals(b.scale)).toBe(true); + expect(a.quat.equals(b.quat)).toBe(true); + }); + + it('quality-tier truncation is stable — an index does not move when the tier population changes size', () => { + // The layout function never takes total tier count as input, so this is + // really asserting the API shape holds the determinism contract: indices + // below any truncation point are computed identically regardless of how + // many indices exist above them. + const low = placeAsteroid(0, 0, 5, BELT_SEED); + const high = placeAsteroid(0, 0, 5, BELT_SEED); + expect(low.pos.toArray()).toEqual(high.pos.toArray()); + }); + + it('different indices produce different placements (no accidental salt collisions)', () => { + const seen = new Set(); + for (let i = 0; i < 50; i++) { + const p = placeAsteroid(2, 1, i, BELT_SEED); + const key = `${p.pos.x.toFixed(3)},${p.pos.y.toFixed(3)},${p.pos.z.toFixed(3)}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + }); + + it('different tiers/variants at the same index do not collide on the same hash stream', () => { + const a = placeAsteroid(0, 0, 0, BELT_SEED); + const b = placeAsteroid(1, 0, 0, BELT_SEED); + const c = placeAsteroid(2, 0, 0, BELT_SEED); + expect(a.pos.equals(b.pos)).toBe(false); + expect(b.pos.equals(c.pos)).toBe(false); + }); + + it('rotating tiers get tumble data, non-rotating tiers do not', () => { + const rotating = placeAsteroid(1, 0, 0, BELT_SEED); // TIERS[1].rotates === true + const still = placeAsteroid(0, 0, 0, BELT_SEED); // TIERS[0].rotates === false + expect(TIERS[1].rotates).toBe(true); + expect(TIERS[0].rotates).toBe(false); + expect(rotating.tumbleAxis).toBeDefined(); + expect(still.tumbleAxis).toBeUndefined(); + }); + + it('placement stays within the belt annulus radius bounds', () => { + for (let i = 0; i < 30; i++) { + const p = placeAsteroid(0, 0, i, BELT_SEED); + const r = Math.hypot(p.pos.x, p.pos.z); + expect(r).toBeGreaterThanOrEqual(645 * 2.5 - 1e-6); + expect(r).toBeLessThanOrEqual(755 * 2.5 + 1e-6); + } + }); +}); diff --git a/src/systems/asteroidLayout.ts b/src/systems/asteroidLayout.ts new file mode 100644 index 0000000..5ac479d --- /dev/null +++ b/src/systems/asteroidLayout.ts @@ -0,0 +1,197 @@ +// Deterministic asteroid-belt placement. Replaces the old unseeded +// `Math.random()` generation (every quality-tier rebuild used to reshuffle +// the whole belt) with a pure function of `(tier, variant, index, seed)`, +// using the same `cellHash`/`seedFromName` primitives already established +// for voxel POI placement (`src/voxel/noise.ts`, `src/voxel/worldGen.ts`) — +// no new PRNG. +// +// Determinism contract: `placeAsteroid(tier, variantIdx, i, seed)` depends +// only on its own arguments, never on the total instance count for the +// tier/quality. That means a lower quality tier's asteroids are always a +// strict prefix of a higher tier's — index `i` names the same physical rock +// at every quality setting, which lets narrative content anchor a wreck to a +// specific asteroid (`globalIdx`) without it moving when the player changes +// quality. + +import { Vector3, Quaternion, Euler } from 'three'; +import { cellHash, seedFromName } from '../voxel/noise'; +import { WORLD_SCALE } from './bodies'; + +export const BELT_SEED = seedFromName('sol-asteroid-belt'); + +// Belt sits between Mars and Jupiter, kept inside Jupiter's inner moon shell +// so nothing crosses orbits. Scaled by WORLD_SCALE alongside the body layout. +export const INNER = 645 * WORLD_SCALE; +export const OUTER = 755 * WORLD_SCALE; +export const THICKNESS = 16 * WORLD_SCALE; // full vertical spread; concentrated toward the plane + +/** + * Size/detail tiers — a realistic belt is mostly dust with a few large + * bodies. Geometry detail (poly count) scales with size so the big rocks + * that read up close are high-poly, while the abundant tiny ones stay cheap. + * Only the larger, visibly-tumbling tiers animate per frame. + */ +export const TIERS = [ + { frac: 0.78, detail: 0, min: 0.12, max: 0.5, variants: 1, rotates: false }, + { frac: 0.18, detail: 1, min: 0.5, max: 1.9, variants: 2, rotates: true }, + { frac: 0.04, detail: 2, min: 1.9, max: 5.2, variants: 3, rotates: true }, +] as const; + +// Structured (not uniform) placement: the belt is split into angular sectors +// whose density multiplier is itself deterministic (§3a — gap sectors read as +// flyable corridors, dense sectors anchor resource-cluster POIs). Assigning +// each index's sector via a weighted hash (rather than dropping/adding +// instances per sector) keeps the "lower tier is a prefix of higher tier" +// guarantee — the weighting only biases *which* sector an index lands in, +// it never changes how many indices exist. +const SECTOR_COUNT = 24; +const SECTOR_DENSITY_BANDS = [0.15, 0.6, 1.0, 1.6]; // gap / sparse / normal / dense + +const SALT = { + sectorDensity: 101, + sectorPick: 1, + sectorFrac: 2, + radius: 3, + y: 4, + yBias: 15, + scaleBase: 5, + scaleY: 6, + scaleZ: 7, + eulerX: 8, + eulerY: 9, + eulerZ: 10, + tumbleAxisTheta: 11, + tumbleAxisPhi: 12, + tumbleSpeed: 13, + tumblePhase: 14, +}; + +/** Sector weight (0..1.6ish) from a deterministic hash — computed once. */ +const sectorCumWeight: number[] = (() => { + const cum: number[] = []; + let total = 0; + for (let s = 0; s < SECTOR_COUNT; s++) { + const h = cellHash(s, 0, BELT_SEED + SALT.sectorDensity); + const band = Math.min(3, Math.floor(h * 4)); + total += SECTOR_DENSITY_BANDS[band]; + cum.push(total); + } + for (let s = 0; s < SECTOR_COUNT; s++) cum[s] /= total; + return cum; +})(); + +function pickSector(h: number): number { + for (let s = 0; s < SECTOR_COUNT; s++) { + if (h < sectorCumWeight[s]) return s; + } + return SECTOR_COUNT - 1; +} + +export interface TierVariantGroup { + tierIdx: number; + variantIdx: number; + n: number; +} + +/** + * Shared tier/variant instance-count split, used identically by the render + * loop (`AsteroidBelt.tsx`) and the parallel per-asteroid state array + * (`asteroidState.ts`) so the two never drift out of sync — both must agree + * on exactly which (tier, variant, i) triples exist for a given quality + * tier's `count`. + */ +export function computeTierVariantCounts(count: number): TierVariantGroup[] { + const groups: TierVariantGroup[] = []; + for (let tierIdx = 0; tierIdx < TIERS.length; tierIdx++) { + const tier = TIERS[tierIdx]; + const tierCount = Math.round(count * tier.frac); + if (tierCount === 0) continue; + for (let variantIdx = 0; variantIdx < tier.variants; variantIdx++) { + const n = + Math.floor(tierCount / tier.variants) + (variantIdx < tierCount % tier.variants ? 1 : 0); + if (n === 0) continue; + groups.push({ tierIdx, variantIdx, n }); + } + } + return groups; +} + +export interface PlacedAsteroid { + pos: Vector3; + scale: Vector3; + quat: Quaternion; + radius: number; // approximate bounding radius (~max scale axis) + /** Only set for tiers marked `rotates: true`. */ + tumbleAxis?: Vector3; + tumbleSpeed?: number; + tumblePhase?: number; +} + +const _euler = new Euler(); + +/** + * Deterministic placement for one asteroid instance. `tierVariantAxis` + * combines the tier and variant indices into the hash's second axis so + * different tiers/variants never share a hash stream at the same `i`. + */ +export function placeAsteroid( + tierIdx: number, + variantIdx: number, + i: number, + seed: number, +): PlacedAsteroid { + const tier = TIERS[tierIdx]; + const z = tierIdx * 100 + variantIdx; + + // Sector-weighted angle (structured placement, §3a). + const hSector = cellHash(i, z, seed + SALT.sectorPick); + const sector = pickSector(hSector); + const hFrac = cellHash(i, z, seed + SALT.sectorFrac); + const sectorWidth = (Math.PI * 2) / SECTOR_COUNT; + const angle = (sector + hFrac) * sectorWidth; + + const r = INNER + cellHash(i, z, seed + SALT.radius) * (OUTER - INNER); + // Concentrate toward the orbital plane: sign+magnitude from one hash, a + // second hash squared biases the distribution toward zero (thin disc), + // mirroring the original `(rand-0.5) * rand^2 * THICKNESS` shape. + const ySign = cellHash(i, z, seed + SALT.y) - 0.5; + const yBias = cellHash(i, z, seed + SALT.yBias); + const y = ySign * (yBias * yBias) * THICKNESS; + + const pos = new Vector3(Math.cos(angle) * r, y, Math.sin(angle) * r); + + const base = tier.min + cellHash(i, z, seed + SALT.scaleBase) * (tier.max - tier.min); + const scale = new Vector3( + base, + base * (0.6 + cellHash(i, z, seed + SALT.scaleY) * 0.7), + base * (0.7 + cellHash(i, z, seed + SALT.scaleZ) * 0.6), + ); + + _euler.set( + cellHash(i, z, seed + SALT.eulerX) * Math.PI * 2, + cellHash(i, z, seed + SALT.eulerY) * Math.PI * 2, + cellHash(i, z, seed + SALT.eulerZ) * Math.PI * 2, + ); + const quat = new Quaternion().setFromEuler(_euler); + + const result: PlacedAsteroid = { + pos, + scale, + quat, + radius: Math.max(scale.x, scale.y, scale.z), + }; + + if (tier.rotates) { + const theta = cellHash(i, z, seed + SALT.tumbleAxisTheta) * Math.PI * 2; + const phi = Math.acos(cellHash(i, z, seed + SALT.tumbleAxisPhi) * 2 - 1); + result.tumbleAxis = new Vector3( + Math.sin(phi) * Math.cos(theta), + Math.sin(phi) * Math.sin(theta), + Math.cos(phi), + ); + result.tumbleSpeed = 0.05 + cellHash(i, z, seed + SALT.tumbleSpeed) * 0.25; + result.tumblePhase = cellHash(i, z, seed + SALT.tumblePhase) * Math.PI * 2; + } + + return result; +} diff --git a/src/systems/asteroidState.test.ts b/src/systems/asteroidState.test.ts new file mode 100644 index 0000000..24a7abe --- /dev/null +++ b/src/systems/asteroidState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildAsteroidStates } from './asteroidState'; +import { computeTierVariantCounts } from './asteroidLayout'; + +describe('buildAsteroidStates', () => { + it('produces exactly as many states as computeTierVariantCounts implies', () => { + const count = 500; + const groups = computeTierVariantCounts(count); + const expectedTotal = groups.reduce((sum, g) => sum + g.n, 0); + const states = buildAsteroidStates(count); + expect(states.length).toBe(expectedTotal); + }); + + it('all built states start alive, non-indestructible, at full health', () => { + const states = buildAsteroidStates(100); + for (const s of states) { + expect(s.alive).toBe(true); + expect(s.indestructible).toBe(false); + expect(s.health).toBe(s.maxHealth); + } + }); + + it('bigger asteroids (larger radius) have more max health', () => { + const states = buildAsteroidStates(2000); + const sorted = [...states].sort((a, b) => a.radius - b.radius); + const smallest = sorted[0]; + const largest = sorted[sorted.length - 1]; + expect(largest.maxHealth).toBeGreaterThan(smallest.maxHealth); + }); + + it('is deterministic across repeated calls with the same seed', () => { + const a = buildAsteroidStates(200); + const b = buildAsteroidStates(200); + expect(a.length).toBe(b.length); + for (let i = 0; i < a.length; i++) { + expect(a[i].pos.equals(b[i].pos)).toBe(true); + expect(a[i].maxHealth).toBe(b[i].maxHealth); + } + }); +}); diff --git a/src/systems/asteroidState.ts b/src/systems/asteroidState.ts new file mode 100644 index 0000000..9d73ce3 --- /dev/null +++ b/src/systems/asteroidState.ts @@ -0,0 +1,86 @@ +// Per-asteroid identity/mutable-state, coexisting with the InstancedMesh-only +// rendering in AsteroidBelt.tsx. Asteroids have no individual JS identity +// beyond their baked transform matrix; this is the parallel plain-array-of- +// structs that gives them one — health, indestructibility, alive/dead — index- +// aligned with (but not stored inside) each InstancedMesh's instance index. +// Mirrors the `Debris[]` convention already used for voxel mining debris +// (`src/voxel/ChunkManager.tsx`), not a Map or class hierarchy. + +import { Vector3 } from 'three'; +import { placeAsteroid, computeTierVariantCounts, BELT_SEED } from './asteroidLayout'; + +export interface AsteroidState { + tierIdx: number; + variantIdx: number; + /** Index within this asteroid's (tier, variant) InstancedMesh. */ + instIdx: number; + pos: Vector3; + /** Impact-knockback velocity (world units/s) — zero unless something has + * hit this rock recently. Only visually applied for tumbling tiers (see + * AsteroidBelt.tsx); non-rotating dust-tier instances never redraw their + * matrix after the initial build, so knockback there would be invisible + * anyway. Integrated + damped in AsteroidBelt.tsx's per-frame tumble loop. */ + vel: Vector3; + /** Approximate bounding radius (world units), from the baked scale. */ + radius: number; + health: number; + maxHealth: number; + seed: number; + alive: boolean; + /** Narrative-anchored asteroids (§9) no-op on damage. */ + indestructible: boolean; + /** Incremented on every damage hit — feeds the deterministic debris-spawn + * hash so repeated hits on the same rock don't collide on identical + * hash inputs (see asteroidFracture.ts). */ + hitSeq: number; + /** True once this asteroid has been pulled out of its shared InstancedMesh + * into a standalone, individually deformable mesh (see AsteroidBelt.tsx's + * promotion API on `asteroidRuntime`) — set by `asteroidFracture.ts` on + * the first damaging hit, budget/tier permitting. */ + promoted: boolean; +} + +/** Bigger rocks take more hits to fracture — scales with bounding radius. + * Tuned so a realistic burst of sustained fire (a couple of seconds, not a + * perfectly-held aim for 5+ seconds) reliably finishes off even the largest + * tier-2 rocks — the original curve (8 + r*18) made big rocks take so long + * to kill that aim naturally drifting onto neighboring asteroids mid-belt + * meant damage got spread thin across many rocks (visible dents) without + * ever concentrating enough on one to fracture it (no debris). */ +function maxHealthForRadius(radius: number): number { + return 5 + radius * 10; +} + +/** + * Builds the full per-asteroid state array for a given quality tier's + * instance `count`. Iterates `computeTierVariantCounts` — the exact same + * group order/sizes `AsteroidBelt.tsx`'s render loop uses — so a state + * array index (`globalIdx`) and the render loop's running instance counter + * always agree without either side needing to share mutable data. + */ +export function buildAsteroidStates(count: number, seed: number = BELT_SEED): AsteroidState[] { + const states: AsteroidState[] = []; + for (const g of computeTierVariantCounts(count)) { + for (let i = 0; i < g.n; i++) { + const placed = placeAsteroid(g.tierIdx, g.variantIdx, i, seed); + const maxHealth = maxHealthForRadius(placed.radius); + states.push({ + tierIdx: g.tierIdx, + variantIdx: g.variantIdx, + instIdx: i, + pos: placed.pos.clone(), + vel: new Vector3(), + radius: placed.radius, + health: maxHealth, + maxHealth, + seed, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + }); + } + } + return states; +} + diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts new file mode 100644 index 0000000..77c8893 --- /dev/null +++ b/src/systems/debrisPhysics.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Quaternion, Vector3 } from 'three'; +import { updateDebrisBodies } from './debrisPhysics'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { buildAsteroidGrid } from './asteroidGrid'; +import { debrisRuntime, type DebrisBody } from '../scene/debrisRuntime'; +import type { AsteroidState } from './asteroidState'; +import { PLANETS } from './bodies'; +import { positionAtTime } from './ephemeris'; + +const SIM_TIME = 0; +const mercury = PLANETS.find((p) => p.name === 'Merkurius')!; + +function mercuryCenter(): Vector3 { + const pos = new Vector3(); + positionAtTime(mercury.elements, mercury.distance, SIM_TIME, pos); + return pos; +} + +function mkDebris(pos: Vector3, vel = new Vector3(), radius = 0.3): DebrisBody { + return { pos, vel, radius, life: 0, isOre: false, quat: new Quaternion(), angVel: new Vector3(), cascadeDepth: 0 }; +} + +describe('updateDebrisBodies', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + debrisRuntime.list = []; + debrisRuntime.maxCount = 0; + debrisRuntime.cascadeEnabled = true; + }); + + it('integrates a free-drifting fragment forward each frame', () => { + const d = mkDebris(new Vector3(0, 0, 0), new Vector3(10, 0, 0)); + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 400); + expect(list).toHaveLength(1); + expect(d.pos.x).toBeGreaterThan(0); + }); + + it('expires (removes) a fragment that lives past its lifetime', () => { + const d = mkDebris(new Vector3(0, 0, 0)); + d.life = 100; + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 400); + expect(list).toHaveLength(0); + }); + + it('force-culls a fragment beyond cullDistance from the ship', () => { + const d = mkDebris(new Vector3(10_000, 0, 0)); + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 400); + expect(list).toHaveLength(0); + }); + + it('bounces off a planet instead of sticking — velocity reflects, fragment persists', () => { + const center = mercuryCenter(); + const d = mkDebris(center.clone().add(new Vector3(mercury.size + 0.1, 0, 0)), new Vector3(-5, 0, 0)); + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(list).toHaveLength(1); + expect(d.vel.x).toBeGreaterThan(0); // reflected away from the surface + }); + + it('bounces off an asteroid instead of sticking', () => { + const state: AsteroidState = { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(50, 0, 0), + vel: new Vector3(), + radius: 2, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + }; + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + asteroidRuntime.groupYaw = 0; + + const d = mkDebris(new Vector3(52, 0, 0), new Vector3(-5, 0, 0)); // overlapping, closing on the asteroid + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(list).toHaveLength(1); + expect(d.vel.x).toBeGreaterThan(0); // reflected away from the surface + }); + + it('bounces apart on debris-vs-debris contact rather than sticking', () => { + const a = mkDebris(new Vector3(0, 0, 0), new Vector3(1, 0, 0), 1); + const b = mkDebris(new Vector3(1, 0, 0), new Vector3(-1, 0, 0), 1); // overlapping (radii sum to 2 > distance 1), closing + const list = [a, b]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(1000, 1000, 1000), 12, 4000); + expect(list).toHaveLength(2); + expect(a.vel.x).toBeLessThan(1); // pushed apart, away from its prior closing direction + expect(b.vel.x).toBeGreaterThan(-1); + }); + + it('caps cascade fracture at one extra level — a cascadeDepth:1 chip never re-fractures', () => { + // A cascadeDepth:1 body slamming into a planet at very high closing speed + // must bounce (population growth stays bounded) but never spawn further chips. + const center = mercuryCenter(); + const d = mkDebris(center.clone().add(new Vector3(mercury.size + 0.1, 0, 0)), new Vector3(-50, 0, 0)); + d.cascadeDepth = 1; + const list = [d]; + const before = debrisRuntime.list.length; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(list).toHaveLength(1); + expect(debrisRuntime.list.length).toBe(before); // no chips spawned into the pool + }); + + it('chips a hard-enough cascadeDepth:0 impact into secondary fragments', () => { + const center = mercuryCenter(); + const d = mkDebris(center.clone().add(new Vector3(mercury.size + 0.1, 0, 0)), new Vector3(-50, 0, 0), 1); + const list = [d]; + debrisRuntime.list = []; + debrisRuntime.maxCount = 50; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(debrisRuntime.list.length).toBeGreaterThan(0); + expect(debrisRuntime.list.every((c) => c.cascadeDepth === 1)).toBe(true); + expect(d.radius).toBeLessThan(1); // parent shrank + }); + + it('suppresses cascade chip spawning when cascadeEnabled is off, but still bounces', () => { + const center = mercuryCenter(); + const d = mkDebris(center.clone().add(new Vector3(mercury.size + 0.1, 0, 0)), new Vector3(-50, 0, 0), 1); + const list = [d]; + debrisRuntime.list = []; + debrisRuntime.maxCount = 50; + debrisRuntime.cascadeEnabled = false; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(debrisRuntime.list.length).toBe(0); // no chips + expect(list).toHaveLength(1); + expect(d.vel.x).toBeGreaterThan(0); // still reflected, not stuck + expect(d.radius).toBe(1); // parent didn't shrink (no chip event) + }); +}); diff --git a/src/systems/debrisPhysics.ts b/src/systems/debrisPhysics.ts new file mode 100644 index 0000000..0bb7a94 --- /dev/null +++ b/src/systems/debrisPhysics.ts @@ -0,0 +1,217 @@ +// Debris integration + collision. Debris is a free-flying fragment once +// spawned — it drifts in inertial (world) space independent of the belt's +// orbital rotation, integrated via the exact same `integrate()` helper the +// ship uses (reused, not duplicated), with a debris-specific damping and no +// thrust/rotation input. +// +// Contact (planet, asteroid, other debris) is a real bounce — reflection +// physics via `reflectSphereContact`/`reflectSphereVsPlanets` +// (`shipCollision.ts`), not "detect overlap → delete". A fragment persists +// and keeps drifting/bouncing until it times out or is culled by distance. +// A hard-enough hit (closing speed past `SECONDARY_FRACTURE_SPEED`) chips off +// a couple of small secondary fragments — capped to exactly one extra +// cascade level (`cascadeDepth`), so population growth is a bounded constant +// multiply, never recursive. +// +// Debris population is capped small (QUALITY[...].debrisMax, at most a +// couple hundred), so an O(n^2) debris-vs-debris pass is cheap, while +// debris-vs-asteroid reuses the belt's existing spatial grid (population +// there can be thousands). + +import { Quaternion, Vector3 } from 'three'; +import { integrate, DEBRIS_DAMPING } from '../ship/shipPhysics'; +import { + reflectSphereVsPlanets, + reflectSphereContact, + rotateY, + DEBRIS_RESTITUTION, + DEBRIS_TANGENTIAL_FRICTION, +} from '../ship/shipCollision'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { debrisRuntime, type DebrisBody } from '../scene/debrisRuntime'; +import { queryNearby } from './asteroidGrid'; +import { TIERS } from './asteroidLayout'; + +const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); + +/** Closing speed (world units/s) past which a bounce is hard enough to chip + * off secondary fragments. */ +const SECONDARY_FRACTURE_SPEED = 4; +/** The parent fragment shrinks rather than being destroyed when it chips. */ +const PARENT_SHRINK = 0.85; +const CHIP_COUNT_RANGE: [number, number] = [1, 2]; +const CHIP_RADIUS_FRAC = 0.3; +const CHIP_SPIN_RATE = 4; + +const _localPos = new Vector3(); +const _localVel = new Vector3(); +const ZERO_ACCEL = new Vector3(0, 0, 0); +const _spinAxis = new Vector3(); +const _spinDeltaQ = new Quaternion(); +const _pairNormal = new Vector3(); +const _relVel = new Vector3(); +const _chipDir = new Vector3(); +const _chipAngVel = new Vector3(); + +/** Per-frame scratch: closing speed a pair collision imparted on the + * lower-indexed body of the pair, read back when that body gets its own + * turn later in the same downward pass (see `updateDebrisBodies`). */ +const _pairClosing: number[] = []; + +/** Real two-body impulse (mass ∝ radius^3), plus a proportional push-out so + * overlapping fragments don't visibly sink into each other. Returns the + * closing speed (0 if not overlapping or already separating). */ +function resolveDebrisPair(a: DebrisBody, b: DebrisBody): number { + _pairNormal.copy(a.pos).sub(b.pos); + const distSq = _pairNormal.lengthSq(); + const minDist = a.radius + b.radius; + if (distSq >= minDist * minDist) return 0; + + const dist = Math.sqrt(distSq); + if (dist > 1e-6) _pairNormal.multiplyScalar(1 / dist); + else _pairNormal.set(0, 1, 0); + + const invMassA = 1 / (a.radius * a.radius * a.radius); + const invMassB = 1 / (b.radius * b.radius * b.radius); + const invMassSum = invMassA + invMassB; + + const overlap = minDist - dist; + a.pos.addScaledVector(_pairNormal, overlap * (invMassA / invMassSum)); + b.pos.addScaledVector(_pairNormal, -overlap * (invMassB / invMassSum)); + + _relVel.copy(a.vel).sub(b.vel); + const closingSpeed = -_relVel.dot(_pairNormal); + if (closingSpeed <= 0) return 0; + + const impulse = ((1 + DEBRIS_RESTITUTION) * closingSpeed) / invMassSum; + a.vel.addScaledVector(_pairNormal, impulse * invMassA); + b.vel.addScaledVector(_pairNormal, -impulse * invMassB); + + return closingSpeed; +} + +/** Reflect-mode debris-vs-asteroid contact, mirroring + * `resolveAsteroidCollision`'s belt-local transform (the grid is indexed in + * belt-local space; the whole belt rotates as one group each frame). Returns + * the largest closing speed observed this call, or 0 if nothing was hit. */ +function reflectAsteroidContact(debris: DebrisBody): number { + const grid = asteroidRuntime.grid; + if (!grid) return 0; + + const yaw = asteroidRuntime.groupYaw; + rotateY(debris.pos, -yaw, _localPos); + rotateY(debris.vel, -yaw, _localVel); + + const candidates = queryNearby(grid, _localPos.x, _localPos.z, debris.radius + MAX_ASTEROID_RADIUS); + let maxClosing = 0; + for (const idx of candidates) { + const s = asteroidRuntime.states[idx]; + if (!s || !s.alive) continue; + const speed = reflectSphereContact( + _localPos, + _localVel, + s.pos, + s.radius, + debris.radius, + DEBRIS_RESTITUTION, + DEBRIS_TANGENTIAL_FRICTION, + ); + if (speed !== null) maxClosing = Math.max(maxClosing, speed); + } + + rotateY(_localPos, yaw, debris.pos); + rotateY(_localVel, yaw, debris.vel); + return maxClosing; +} + +/** Chip 1-2 small secondary fragments off a hard-enough bounce and shrink the + * parent — never recurses (chips spawn at `cascadeDepth: 1`, and callers + * only invoke this for `cascadeDepth === 0` bodies). Chip placement/spin + * uses `Math.random()`, per this codebase's convention for purely cosmetic, + * non-seeded ejecta (same precedent as `miningSparkRuntime.ts`) — cascade + * chips aren't part of the persisted/seeded belt state. */ +function spawnCascadeChips(d: DebrisBody): void { + const chipCount = CHIP_COUNT_RANGE[0] + Math.floor(Math.random() * (CHIP_COUNT_RANGE[1] - CHIP_COUNT_RANGE[0] + 1)); + const chipRadius = d.radius * CHIP_RADIUS_FRAC; + + for (let k = 0; k < chipCount; k++) { + _chipDir.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1); + if (_chipDir.lengthSq() < 1e-6) _chipDir.set(1, 0, 0); + else _chipDir.normalize(); + _chipAngVel.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(CHIP_SPIN_RATE); + + debrisRuntime.spawn({ + pos: d.pos.clone().addScaledVector(_chipDir, d.radius * 0.5), + vel: d.vel.clone().addScaledVector(_chipDir, 2 + Math.random() * 2), + radius: chipRadius, + isOre: d.isOre, + resourceType: d.resourceType, + angVel: _chipAngVel.clone(), + cascadeDepth: 1, + }); + } + + d.radius *= PARENT_SHRINK; +} + +/** + * Integrate + resolve every debris body one frame. Contact bounces rather + * than sticking; only lifetime timeout or distance-cull remove a fragment + * (swap-removed from `list` in place). `shipPos` drives the distance-cull; + * `simTimeDays` is needed for the planet sphere test (bodies move in the sim + * clock). + */ +export function updateDebrisBodies( + list: DebrisBody[], + dt: number, + simTimeDays: number, + shipPos: Vector3, + maxLifeSec: number, + cullDistance: number, +): void { + if (_pairClosing.length < list.length) _pairClosing.length = list.length; + for (let k = 0; k < list.length; k++) _pairClosing[k] = 0; + + for (let i = list.length - 1; i >= 0; i--) { + const d = list[i]; + d.life += dt; + + const expired = d.life >= maxLifeSec || d.pos.distanceTo(shipPos) > cullDistance; + + if (!expired) { + integrate(d.pos, d.vel, ZERO_ACCEL, DEBRIS_DAMPING, dt); + if (d.angVel.lengthSq() > 1e-8) { + _spinAxis.copy(d.angVel).normalize(); + _spinDeltaQ.setFromAxisAngle(_spinAxis, d.angVel.length() * dt); + d.quat.multiply(_spinDeltaQ); + } + + let closing = _pairClosing[i]; + closing = Math.max( + closing, + reflectSphereVsPlanets(d.pos, d.vel, simTimeDays, d.radius, DEBRIS_RESTITUTION, DEBRIS_TANGENTIAL_FRICTION), + ); + closing = Math.max(closing, reflectAsteroidContact(d)); + + // Debris-vs-debris: only check against lower indices, so each + // unordered pair is resolved exactly once per frame (the pair is + // covered when the outer loop reaches the larger of the two indices). + for (let j = 0; j < i; j++) { + const speed = resolveDebrisPair(d, list[j]); + if (speed > 0) { + closing = Math.max(closing, speed); + _pairClosing[j] = Math.max(_pairClosing[j], speed); + } + } + + if (debrisRuntime.cascadeEnabled && d.cascadeDepth === 0 && closing > SECONDARY_FRACTURE_SPEED) { + spawnCascadeChips(d); + } + } + + if (expired) { + list[i] = list[list.length - 1]; + list.pop(); + } + } +} diff --git a/src/systems/projectilePhysics.test.ts b/src/systems/projectilePhysics.test.ts new file mode 100644 index 0000000..08b62cc --- /dev/null +++ b/src/systems/projectilePhysics.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { updateProjectiles } from './projectilePhysics'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { buildAsteroidGrid } from './asteroidGrid'; +import { debrisRuntime } from '../scene/debrisRuntime'; +import { miningSparkRuntime } from '../scene/miningSparkRuntime'; +import type { ProjectileBody } from '../scene/projectileRuntime'; +import type { AsteroidState } from './asteroidState'; +import { PLANETS } from './bodies'; +import { positionAtTime } from './ephemeris'; +import { MINING_RANGE, PROJECTILE_MAX_LIFE_SEC } from '../ship/spaceMining'; +import { INNER, OUTER } from './asteroidLayout'; + +const SIM_TIME = 0; +const mercury = PLANETS.find((p) => p.name === 'Merkurius')!; +// The grid is tuned for the belt's real scale (radius ~1600-1900 units) — +// asteroid-hit tests must place things at a realistic radius, not near the +// world origin, since angle is degenerate at r≈0 and the grid's radial bins +// clamp hard outside the annulus (see spaceMining.test.ts's identical note). +const MID_R = (INNER + OUTER) / 2; +const BELT_ORIGIN = new Vector3(MID_R, 0, 0); + +function mercuryCenter(): Vector3 { + const pos = new Vector3(); + positionAtTime(mercury.elements, mercury.distance, SIM_TIME, pos); + return pos; +} + +function mkProjectile(pos: Vector3, vel: Vector3): ProjectileBody { + return { pos, vel, life: 0, radius: 0.12 }; +} + +function mkAsteroidState(pos: Vector3, radius = 2): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos, + vel: new Vector3(), + radius, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + promoted: false, + }; +} + +describe('updateProjectiles', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + debrisRuntime.list = []; + debrisRuntime.maxCount = 50; + miningSparkRuntime.list = []; + miningSparkRuntime.maxCount = 50; + }); + + it('integrates a free-flying projectile forward each frame', () => { + const p = mkProjectile(new Vector3(0, 0, 0), new Vector3(0, 0, -100)); + const list = [p]; + updateProjectiles(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), MINING_RANGE); + expect(list).toHaveLength(1); + expect(p.pos.z).toBeLessThan(0); + }); + + it('expires once its lifetime (range/speed) is exceeded, with nothing in its path', () => { + const p = mkProjectile(new Vector3(0, 0, 0), new Vector3(0, 0, -100)); + p.life = PROJECTILE_MAX_LIFE_SEC; + const list = [p]; + updateProjectiles(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), MINING_RANGE); + expect(list).toHaveLength(0); + }); + + it('force-culls a projectile that ends up far beyond the ship', () => { + const p = mkProjectile(new Vector3(10_000, 0, 0), new Vector3(0, 0, -1)); + const list = [p]; + updateProjectiles(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), MINING_RANGE); + expect(list).toHaveLength(0); + }); + + it('hits an asteroid its flight segment sweeps through this frame, applies damage, and expires', () => { + const state = mkAsteroidState(BELT_ORIGIN.clone().add(new Vector3(0, 0, -5)), 2); + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + // Fast enough that a naive end-point-only test could tunnel past the + // asteroid within one frame — the swept-segment test must still catch it. + const p = mkProjectile(BELT_ORIGIN.clone(), new Vector3(0, 0, -600)); + const list = [p]; + updateProjectiles(list, 1 / 60, SIM_TIME, BELT_ORIGIN, MINING_RANGE); + + expect(list).toHaveLength(0); // expired on the connecting hit + expect(state.health).toBeLessThan(state.maxHealth); + expect(miningSparkRuntime.list.length).toBeGreaterThan(0); + }); + + it('does not hit an asteroid the flight segment does not pass through', () => { + const state = mkAsteroidState(BELT_ORIGIN.clone().add(new Vector3(50, 50, -5)), 2); // well off to the side + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + + const p = mkProjectile(BELT_ORIGIN.clone(), new Vector3(0, 0, -100)); + const list = [p]; + updateProjectiles(list, 1 / 60, SIM_TIME, BELT_ORIGIN, MINING_RANGE); + + expect(list).toHaveLength(1); + expect(state.health).toBe(state.maxHealth); + }); + + it('stops at a planet surface in its path and expires', () => { + const center = mercuryCenter(); + const p = mkProjectile(center.clone().add(new Vector3(mercury.size + 30, 0, 0)), new Vector3(-600, 0, 0)); + const list = [p]; + // A few frames at this speed easily closes the 30-unit gap to the surface. + for (let i = 0; i < 10 && list.length > 0; i++) { + updateProjectiles(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), MINING_RANGE); + } + expect(list).toHaveLength(0); + }); +}); diff --git a/src/systems/projectilePhysics.ts b/src/systems/projectilePhysics.ts new file mode 100644 index 0000000..a991b85 --- /dev/null +++ b/src/systems/projectilePhysics.ts @@ -0,0 +1,79 @@ +// Weapon-projectile integration + collision. A shot is a real traveling +// body, not an instant hitscan resolution — each frame it moves forward at +// its own finite velocity, and the *segment* it just swept through this frame +// (not just its new point position) is tested against asteroids/planets, so +// a fast bolt can't tunnel through a small target between frames. On a hit it +// feeds the exact same `applyAsteroidDamage` pipeline collision damage uses +// (real momentum: the projectile's own velocity, not a synthesized bias), then +// expires — projectiles don't bounce or cascade like debris does. + +import { Vector3 } from 'three'; +import { integrate } from '../ship/shipPhysics'; +import { raycastAsteroids, PROJECTILE_MAX_LIFE_SEC, SHOT_DAMAGE } from '../ship/spaceMining'; +import { raycastPlanets } from '../ship/shipCollision'; +import { applyAsteroidDamage } from './asteroidFracture'; +import { miningSparkRuntime } from '../scene/miningSparkRuntime'; +import { audio } from '../audio/AudioManager'; +import type { ProjectileBody } from '../scene/projectileRuntime'; + +/** Impact-chip spark count spawned at the exact point of contact. */ +const SPARKS_PER_HIT = 4; +/** Beyond this multiple of a shot's own max travel distance from the ship, + * force-cull regardless of lifetime (handles the ship flying away from a + * shot that's still nominally "alive"). */ +const CULL_RANGE_FACTOR = 3; + +const ZERO_ACCEL = new Vector3(0, 0, 0); +const _prevPos = new Vector3(); +const _delta = new Vector3(); + +/** + * Integrate + resolve every in-flight projectile one frame. A connecting hit + * (asteroid or planet/moon) expires the shot immediately; otherwise it keeps + * flying until its lifetime or range is exceeded. `list` is mutated in place + * (swap-remove on expiry, matching the rest of this codebase's pooled-runtime + * convention). + */ +export function updateProjectiles( + list: ProjectileBody[], + dt: number, + simTimeDays: number, + shipPos: Vector3, + maxRange: number, +): void { + for (let i = list.length - 1; i >= 0; i--) { + const p = list[i]; + p.life += dt; + + let expired = p.life >= PROJECTILE_MAX_LIFE_SEC || p.pos.distanceTo(shipPos) > maxRange * CULL_RANGE_FACTOR; + + if (!expired) { + _prevPos.copy(p.pos); + integrate(p.pos, p.vel, ZERO_ACCEL, 1, dt); // straight-line flight, no drag/decay + _delta.copy(p.pos).sub(_prevPos); + const segLen = _delta.length(); + + if (segLen > 1e-6) { + _delta.multiplyScalar(1 / segLen); + const asteroidHit = raycastAsteroids(_prevPos, _delta, segLen); + if (asteroidHit) { + applyAsteroidDamage(asteroidHit.globalIdx, SHOT_DAMAGE, asteroidHit.point, p.vel); + miningSparkRuntime.spawn(asteroidHit.point, SPARKS_PER_HIT); + audio.playMiningShot(true); + expired = true; + } else { + const planetHit = raycastPlanets(_prevPos, _delta, segLen, simTimeDays); + if (planetHit) { + miningSparkRuntime.spawn(planetHit.point, SPARKS_PER_HIT); + expired = true; + } + } + } + } + + if (expired) { + list[i] = list[list.length - 1]; + list.pop(); + } + } +} diff --git a/src/systems/quality.ts b/src/systems/quality.ts index 907da02..f003afd 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -38,6 +38,29 @@ export interface QualitySettings { voxelScatter: number; /** Max instanced trees in the voxel world (World Richness Phase 7). */ voxelTrees: number; + /** Pooled engine-exhaust particle cap for the piloting ship trail. */ + shipTrailParticles: number; + /** Max simultaneous asteroid-fracture debris/ore-chunk fragments. */ + debrisMax: number; + /** Debris fragment lifetime (seconds) before it force-expires. */ + debrisLifetimeSec: number; + /** Debris beyond this distance from the ship is force-culled each frame. */ + debrisCullDistance: number; + /** Max simultaneous mining impact-chip spark particles (cosmetic only). */ + miningVfxBudget: number; + /** Max simultaneous in-flight weapon projectiles. Short-lived (flight time + * is bounded by MINING_RANGE/PROJECTILE_SPEED), so this stays small even + * at the fastest fire rate. */ + projectileMax: number; + /** Max concurrently "promoted" asteroids — pulled out of the shared + * InstancedMesh into a standalone, individually deformable mesh on first + * damage. Small regardless of total asteroid count: each is a real draw + * call plus potential per-vertex dent work. */ + promotedAsteroidMax: number; + /** Whether a hard-enough debris bounce can chip off secondary fragments. + * Debris always bounces (cheap reflection math); this only gates the + * extra population growth from cascade chips on low-end tiers. */ + cascadeFractureEnabled: boolean; } export const QUALITY: Record = { @@ -48,7 +71,7 @@ export const QUALITY: Record = { atmosphereSegments: 24, stars: 600, solarWind: 300, - asteroids: 0, + asteroids: 500, dprMax: 1, bloomStrength: 0, bloomRadius: 0.4, @@ -64,6 +87,14 @@ export const QUALITY: Record = { voxelParticles: 180, voxelScatter: 120, voxelTrees: 60, + shipTrailParticles: 0, + debrisMax: 12, + debrisLifetimeSec: 8, + debrisCullDistance: 250, + miningVfxBudget: 8, + projectileMax: 6, + promotedAsteroidMax: 0, + cascadeFractureEnabled: false, }, medium: { planetSegments: 40, @@ -88,6 +119,14 @@ export const QUALITY: Record = { voxelParticles: 450, voxelScatter: 340, voxelTrees: 160, + shipTrailParticles: 40, + debrisMax: 24, + debrisLifetimeSec: 12, + debrisCullDistance: 400, + miningVfxBudget: 16, + projectileMax: 10, + promotedAsteroidMax: 8, + cascadeFractureEnabled: true, }, high: { planetSegments: 64, @@ -112,6 +151,14 @@ export const QUALITY: Record = { voxelParticles: 900, voxelScatter: 680, voxelTrees: 320, + shipTrailParticles: 90, + debrisMax: 64, + debrisLifetimeSec: 18, + debrisCullDistance: 700, + miningVfxBudget: 32, + projectileMax: 16, + promotedAsteroidMax: 16, + cascadeFractureEnabled: true, }, ultra: { planetSegments: 96, @@ -136,6 +183,14 @@ export const QUALITY: Record = { voxelParticles: 1500, voxelScatter: 1100, voxelTrees: 550, + shipTrailParticles: 160, + debrisMax: 128, + debrisLifetimeSec: 24, + debrisCullDistance: 1000, + miningVfxBudget: 48, + projectileMax: 24, + promotedAsteroidMax: 32, + cascadeFractureEnabled: true, }, }; diff --git a/src/systems/spacePoiProfiles.test.ts b/src/systems/spacePoiProfiles.test.ts new file mode 100644 index 0000000..2b1b9a8 --- /dev/null +++ b/src/systems/spacePoiProfiles.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { SPACE_POIS, isAnchoredPoi } from './spacePoiProfiles'; +import { computeTierVariantCounts } from './asteroidLayout'; +import { QUALITY } from './quality'; + +describe('spacePoiProfiles', () => { + it('has at least one of each POI kind, demonstrating the full system', () => { + const kinds = new Set(SPACE_POIS.map((s) => s.kind)); + expect(kinds.has('wreckage')).toBe(true); + expect(kinds.has('anomaly')).toBe(true); + expect(kinds.has('resourceCluster')).toBe(true); + expect(kinds.has('landmark')).toBe(true); + }); + + it('every POI id is unique', () => { + const ids = SPACE_POIS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('isAnchoredPoi correctly discriminates fixed vs anchored specs', () => { + for (const spec of SPACE_POIS) { + if (isAnchoredPoi(spec)) { + expect('anchorGlobalIdx' in spec).toBe(true); + } else { + expect('pos' in spec).toBe(true); + } + } + }); + + it('every anchored POI index resolves within the belt at every non-zero quality tier', () => { + const anchored = SPACE_POIS.filter(isAnchoredPoi); + expect(anchored.length).toBeGreaterThan(0); + for (const tierName of ['medium', 'high', 'ultra'] as const) { + const count = QUALITY[tierName].asteroids; + const groups = computeTierVariantCounts(count); + const total = groups.reduce((sum, g) => sum + g.n, 0); + for (const spec of anchored) { + expect(spec.anchorGlobalIdx).toBeLessThan(total); + } + } + }); +}); diff --git a/src/systems/spacePoiProfiles.ts b/src/systems/spacePoiProfiles.ts new file mode 100644 index 0000000..144f089 --- /dev/null +++ b/src/systems/spacePoiProfiles.ts @@ -0,0 +1,111 @@ +// Space-specific points of interest: wreckage, signal anomalies, resource +// clusters, and long-distance landmarks — the narrative/exploration-density +// layer for the open-space view. Deliberately small and Act-1-scoped (dozens +// of entries, not thousands), so a flat deterministic array is enough; no +// spatial grid needed at this population size (contrast the belt's +// thousands-of-instances grid in asteroidGrid.ts). +// +// Mirrors the existing two-layer (real/speculative) discovery architecture in +// store.ts rather than inventing a new one: wreckage/anomalies record into +// the same `recordDiscovery`/`recordWarLoreDiscovery` journal the voxel- +// surface POI system already uses, just with `planet: 'space'`. Explicitly +// out of scope: faction reputation, trade economy, NPC ships, dynamic wreck +// generation beyond this fixed set — those are later-act systems. + +import type { DiscoveryStory } from '../store'; +import { WORLD_SCALE } from './bodies'; + +export type SpacePoiKind = 'wreckage' | 'anomaly' | 'resourceCluster' | 'landmark'; + +interface SpacePoiBase { + id: string; + name: string; + kind: SpacePoiKind; + /** Distance (world units) within which the POI is considered "found". */ + scanRadius: number; + /** True for Layer 2 (speculative) content — read directly, never inferred. */ + speculative?: boolean; + story?: DiscoveryStory; + /** Contributes to the existing cross-body mystery-clue mechanic + * (`mysteryClues`/`MYSTERY_THRESHOLD`) instead of a new resolution system. */ + clue?: string; + mysteryId?: string; +} + +export interface FixedSpacePoi extends SpacePoiBase { + /** Fixed world position — landmarks and anomalies not tied to a specific + * belt asteroid. */ + pos: [number, number, number]; +} + +export interface AnchoredSpacePoi extends SpacePoiBase { + /** Anchors to a specific belt asteroid's *live* position (resolved via + * `asteroidRuntime` at query time, never cached) — the anchor asteroid is + * flagged indestructible (see §9 in the design notes / SpacePoiField.tsx) + * so a story-critical wreck can't be blown apart by fracture testing. */ + anchorGlobalIdx: number; +} + +export type SpacePoiSpec = FixedSpacePoi | AnchoredSpacePoi; + +export function isAnchoredPoi(spec: SpacePoiSpec): spec is AnchoredSpacePoi { + return 'anchorGlobalIdx' in spec; +} + +const WS = WORLD_SCALE; + +/** + * A small, fixed, hand-placed set of Act 1 space POIs. Index 137 is deep + * inside the belt's tier-0 (most numerous) group, which exists at every + * non-zero quality tier — see asteroidLayout.ts's determinism guarantee — + * so the anchor always resolves to the same physical rock. + */ +export const SPACE_POIS: SpacePoiSpec[] = [ + { + id: 'derelict-hull-belt', + kind: 'wreckage', + name: 'Derelict hull fragment', + scanRadius: 40, + anchorGlobalIdx: 137, + story: { + base: 'A hull section, decades old, wedged into the rock rather than resting on it — it arrived with force.', + disruption: 'No registry markings survive. Whatever struck it here erased them first.', + human: 'Someone built this to leave. It never got the chance.', + }, + }, + { + id: 'signal-belt', + kind: 'anomaly', + name: 'Buried transmission (belt)', + scanRadius: 30, + pos: [700 * WS, 6 * WS, 720 * WS], + mysteryId: 'signal', + clue: 'signal:belt', + story: { + base: 'A faint, structured transmission, looping on a dead channel.', + human: 'It repeats every few seconds, like it is still waiting for a reply.', + }, + }, + { + id: 'resource-cluster-alpha', + kind: 'resourceCluster', + name: 'Dense ore pocket', + scanRadius: 50, + pos: [660 * WS, 2 * WS, 680 * WS], + story: { + base: 'A tight cluster of metal-rich rock, denser than the surrounding field.', + human: 'Good mining, if you can hold position — the traffic through here is worse than most of the belt.', + }, + }, + { + id: 'landmark-shattered-core', + kind: 'landmark', + name: 'Shattered proto-planet core', + scanRadius: 80, + pos: [745 * WS, 10 * WS, 300 * WS], + story: { + base: 'A single iron-nickel fragment, far larger than anything else in the field — the exposed core of a world that never finished forming.', + human: 'You can see it from the inner belt. Everyone uses it to get their bearings out here.', + }, + }, +]; diff --git a/src/ui/ControlHints.tsx b/src/ui/ControlHints.tsx index 560fc41..c060911 100644 --- a/src/ui/ControlHints.tsx +++ b/src/ui/ControlHints.tsx @@ -45,6 +45,7 @@ const HINTS: Record s.sceneMode.type); + const [onTarget, setOnTarget] = useState(false); + + useEffect(() => { + if (sceneModeType !== 'piloting') return; + const id = setInterval(() => setOnTarget(spaceMiningTelemetry.aiming), 100); + return () => clearInterval(id); + }, [sceneModeType]); + + if (sceneModeType !== 'piloting') return null; + + return