From 110534dca9c605b75e549aa6ade3613554e5d224 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:47:09 +0000 Subject: [PATCH 01/22] Add ship-vs-planet collision and throttle-reactive flight feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship now slides off planets/moons on contact (analytic sphere test, push-out + tangential-velocity retention) instead of flying through them, gated to piloting mode so descent/ascent cinematics are untouched. No acceleration term is added, so the deliberate "no gravity well" flight model is preserved. Also fixes the thruster glow (previously a random flicker unrelated to throttle) to track actual throttle magnitude, adds a pooled engine-exhaust particle trail, and an engine hum whose gain/pitch track throttle — all reading the existing shipTelemetry hot-path singleton rather than the reactive store. --- src/audio/AudioManager.ts | 30 ++++++ src/scene/ShipTrail.tsx | 166 +++++++++++++++++++++++++++++++++ src/scene/SolarSystem.tsx | 2 + src/ship/ShipController.tsx | 7 ++ src/ship/ShipModel.tsx | 14 ++- src/ship/shipCollision.test.ts | 64 +++++++++++++ src/ship/shipCollision.ts | 90 ++++++++++++++++++ src/ship/shipPhysics.ts | 3 + src/systems/quality.ts | 6 ++ 9 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 src/scene/ShipTrail.tsx create mode 100644 src/ship/shipCollision.test.ts create mode 100644 src/ship/shipCollision.ts diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 9760468..8d6c34e 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,17 @@ 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); + } + // --- Surface ambience -------------------------------------------------- /** Build the persistent surface graph once: wind (filtered noise) + rumble. */ 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..8050aa5 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -14,6 +14,7 @@ import { Starfield } from './Starfield'; import { Sun } from './Sun'; import { SolarWind } from './SolarWind'; import { AsteroidBelt } from './AsteroidBelt'; +import { ShipTrail } from './ShipTrail'; import { SimClock } from './SimClock'; import { AudioReactor } from './AudioReactor'; import { LabelProjector } from './LabelProjector'; @@ -136,6 +137,7 @@ export function SolarSystem() { <> + )} {sceneMode.type === 'descending' && ( diff --git a/src/ship/ShipController.tsx b/src/ship/ShipController.tsx index 4a88c6e..aaf3d69 100644 --- a/src/ship/ShipController.tsx +++ b/src/ship/ShipController.tsx @@ -8,9 +8,11 @@ import { integrate, ASSIST_DAMPING, DRIFT_DAMPING, + SHIP_COLLISION_RADIUS, type AngularVelocity, type ShipInput, } from './shipPhysics'; +import { resolvePlanetCollision } from './shipCollision'; import { readInput, installKeyboardListeners, removeKeyboardListeners } from './shipInput'; import { shipTelemetry, syncTelemetryFromStore, MIRROR_INTERVAL } from './shipTelemetry'; import { decayStick, resetStick } from './virtualStick'; @@ -189,6 +191,11 @@ export function ShipController() { const damping = cfg.flightAssist ? ASSIST_DAMPING : DRIFT_DAMPING; integrate(_pos, _vel, _accel, damping, dt); + // Slide off planets/moons 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). + resolvePlanetCollision(_pos, _vel, store.simTimeDays, SHIP_COLLISION_RADIUS); + 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..9f933f8 --- /dev/null +++ b/src/ship/shipCollision.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { resolvePlanetCollision, TANGENTIAL_RETAIN } from './shipCollision'; +import { PLANETS } from '../systems/bodies'; +import { positionAtTime } from '../systems/ephemeris'; + +// 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); + }); +}); diff --git a/src/ship/shipCollision.ts b/src/ship/shipCollision.ts new file mode 100644 index 0000000..6e5910a --- /dev/null +++ b/src/ship/shipCollision.ts @@ -0,0 +1,90 @@ +import { Vector3 } from 'three'; +import { PLANETS, moonLocalOffset } from '../systems/bodies'; +import { positionAtTime } from '../systems/ephemeris'; + +/** 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 (resolveSphere(position, velocity, _bodyPos, p.size, radius, tangentialRetain)) 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 (resolveSphere(position, velocity, _bodyPos, m.size, radius, tangentialRetain)) hit = true; + } + } + return hit; +} + +/** Single sphere-vs-sphere contact test + resolution against one body. */ +function resolveSphere( + position: Vector3, + velocity: Vector3, + center: Vector3, + bodyRadius: number, + radius: number, + tangentialRetain: number, +): boolean { + const minDist = bodyRadius + radius; + _normal.copy(position).sub(center); + const distSq = _normal.lengthSq(); + if (distSq >= minDist * minDist) return false; + + 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 true; +} + +/** 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); +} diff --git a/src/ship/shipPhysics.ts b/src/ship/shipPhysics.ts index 9ea53f0..ead22af 100644 --- a/src/ship/shipPhysics.ts +++ b/src/ship/shipPhysics.ts @@ -1,6 +1,9 @@ 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; diff --git a/src/systems/quality.ts b/src/systems/quality.ts index 907da02..827ee43 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -38,6 +38,8 @@ 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; } export const QUALITY: Record = { @@ -64,6 +66,7 @@ export const QUALITY: Record = { voxelParticles: 180, voxelScatter: 120, voxelTrees: 60, + shipTrailParticles: 0, }, medium: { planetSegments: 40, @@ -88,6 +91,7 @@ export const QUALITY: Record = { voxelParticles: 450, voxelScatter: 340, voxelTrees: 160, + shipTrailParticles: 40, }, high: { planetSegments: 64, @@ -112,6 +116,7 @@ export const QUALITY: Record = { voxelParticles: 900, voxelScatter: 680, voxelTrees: 320, + shipTrailParticles: 90, }, ultra: { planetSegments: 96, @@ -136,6 +141,7 @@ export const QUALITY: Record = { voxelParticles: 1500, voxelScatter: 1100, voxelTrees: 550, + shipTrailParticles: 160, }, }; From a0862a86802ab4e6bbba3ee7e28ff8a31b002220 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:50:48 +0000 Subject: [PATCH 02/22] Make asteroid belt placement deterministic and structured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the belt's unseeded Math.random() generation with a pure function of (tier, variant, index, seed) using the existing cellHash/seedFromName primitives, so the layout no longer reshuffles on every quality-tier change and a given index always resolves to the same physical rock — required for narrative content to anchor to a specific asteroid later, and for any future collision/fracture state to stay in sync with the render matrices. Also folds in structured (sector-weighted) placement: 24 angular sectors get a deterministic density multiplier (gap/sparse/normal/ dense), biasing which sector an index lands in without changing how many asteroids exist per tier, so gaps read as flyable corridors instead of uniform noise. --- src/scene/AsteroidBelt.tsx | 60 +++-------- src/systems/asteroidLayout.test.ts | 58 ++++++++++ src/systems/asteroidLayout.ts | 168 +++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 src/systems/asteroidLayout.test.ts create mode 100644 src/systems/asteroidLayout.ts diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 5e84955..41c9bef 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -7,32 +7,13 @@ import { 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; +import { TIERS, BELT_SEED, placeAsteroid } from '../systems/asteroidLayout'; /** Lumpy, crack-free rock from an icosahedron: displace each vertex along its * own direction by a smooth function of that direction, so shared seam @@ -90,10 +71,9 @@ export function AsteroidBelt() { const meshes: BeltMesh[] = []; const geometries: BufferGeometry[] = []; const m = new Matrix4(); - const q = new Quaternion(); - const e = new Euler(); - for (const tier of TIERS) { + for (let tierIdx = 0; tierIdx < TIERS.length; tierIdx++) { + const tier = TIERS[tierIdx]; const tierCount = Math.round(count * tier.frac); if (tierCount === 0) continue; // Split this tier's rocks across its shape variants. @@ -108,29 +88,19 @@ export function AsteroidBelt() { 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); + // Deterministic placement — see asteroidLayout.ts. Index `i` always + // resolves to the same rock regardless of quality tier. + const placed = placeAsteroid(tierIdx, vi, i, BELT_SEED); + m.compose(placed.pos, placed.quat, placed.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 }); + if (tier.rotates && placed.tumbleAxis) { + items.push({ + pos: placed.pos, + scale: placed.scale, + axis: placed.tumbleAxis, + speed: placed.tumbleSpeed!, + phase: placed.tumblePhase!, + }); } } inst.instanceMatrix.needsUpdate = true; 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..d724911 --- /dev/null +++ b/src/systems/asteroidLayout.ts @@ -0,0 +1,168 @@ +// 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 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; +} From 5f18d804a5766f20b57069073117ac03709abfb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:55:30 +0000 Subject: [PATCH 03/22] Add per-asteroid identity, spatial grid, and a runtime handle Asteroids get a parallel plain-array-of-structs (position, radius, health, alive/indestructible flags) index-aligned with each tier's InstancedMesh, built via the same deterministic placement the belt already uses so the state array and render matrices never disagree about which (tier, variant, i) exists. A cylindrical spatial grid (radial x angular bins, sized for the belt's thin-torus shape) gives O(1)-amortized nearby-asteroid queries instead of scanning up to 9000 instances. Both are published on a new asteroidRuntime singleton (shipTelemetry's hot-path-singleton convention) along with a killAsteroid callback that zeroes a dead asteroid's render instance and drops it from the grid, so upcoming collision/mining/fracture code can destroy an asteroid without reaching into AsteroidBelt's internal Three.js objects. --- src/scene/AsteroidBelt.tsx | 126 +++++++++++++++++++++--------- src/scene/asteroidRuntime.ts | 26 ++++++ src/systems/asteroidGrid.test.ts | 84 ++++++++++++++++++++ src/systems/asteroidGrid.ts | 114 +++++++++++++++++++++++++++ src/systems/asteroidLayout.ts | 29 +++++++ src/systems/asteroidState.test.ts | 40 ++++++++++ src/systems/asteroidState.ts | 62 +++++++++++++++ 7 files changed, 445 insertions(+), 36 deletions(-) create mode 100644 src/scene/asteroidRuntime.ts create mode 100644 src/systems/asteroidGrid.test.ts create mode 100644 src/systems/asteroidGrid.ts create mode 100644 src/systems/asteroidState.test.ts create mode 100644 src/systems/asteroidState.ts diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 41c9bef..ef388e7 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -13,7 +13,10 @@ import { import { vec3, float } from 'three/tsl'; import { useStore } from '../store'; import { QUALITY } from '../systems/quality'; -import { TIERS, BELT_SEED, placeAsteroid } from '../systems/asteroidLayout'; +import { TIERS, BELT_SEED, computeTierVariantCounts, placeAsteroid } from '../systems/asteroidLayout'; +import { buildAsteroidStates } from '../systems/asteroidState'; +import { buildAsteroidGrid, removeFromGrid } from '../systems/asteroidGrid'; +import { asteroidRuntime } from './asteroidRuntime'; /** Lumpy, crack-free rock from an icosahedron: displace each vertex along its * own direction by a smooth function of that direction, so shared seam @@ -48,13 +51,23 @@ 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; } +const _zeroScale = new Matrix4().makeScale(0, 0, 0); + /** * 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. */ export function AsteroidBelt() { const count = QUALITY[useStore((s) => s.quality)].asteroids; @@ -72,44 +85,79 @@ export function AsteroidBelt() { const geometries: BufferGeometry[] = []; const m = new Matrix4(); - for (let tierIdx = 0; tierIdx < TIERS.length; tierIdx++) { - const tier = TIERS[tierIdx]; - 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++) { - // Deterministic placement — see asteroidLayout.ts. Index `i` always - // resolves to the same rock regardless of quality tier. - const placed = placeAsteroid(tierIdx, vi, i, BELT_SEED); - m.compose(placed.pos, placed.quat, placed.scale); - inst.setMatrixAt(i, m); - if (tier.rotates && placed.tumbleAxis) { - items.push({ - pos: placed.pos, - scale: placed.scale, - axis: placed.tumbleAxis, - speed: placed.tumbleSpeed!, - phase: placed.tumblePhase!, - }); - } + // 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. + const groups = computeTierVariantCounts(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); + geometries.push(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); + m.compose(placed.pos, placed.quat, placed.scale); + inst.setMatrixAt(i, m); + if (tier.rotates && placed.tumbleAxis) { + items.push({ + pos: placed.pos, + 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 states = buildAsteroidStates(count); + const grid = buildAsteroidGrid(states); + + return { meshes, geometries, material, states, grid }; }, [count]); + // Publish to the runtime singleton (ship collision / mining / fracture read + // it every frame) and wire the kill callback fracture/mining code uses to + // zero a dead asteroid's render instance and drop it from the grid. + useEffect(() => { + if (!built) { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.killAsteroid = null; + return; + } + asteroidRuntime.states = built.states; + asteroidRuntime.grid = built.grid; + asteroidRuntime.killAsteroid = (globalIdx: number) => { + const state = built.states[globalIdx]; + if (!state || !state.alive) 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; + } + if (built.grid) removeFromGrid(built.grid, globalIdx, state.pos.x, state.pos.z); + state.alive = false; + }; + return () => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.killAsteroid = null; + }; + }, [built]); + // Free GPU resources on quality change / unmount. useEffect(() => { return () => { @@ -125,8 +173,12 @@ export function AsteroidBelt() { useFrame((state) => { 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 t = state.clock.elapsedTime; const { m, q } = scratch.current; @@ -134,6 +186,8 @@ export function AsteroidBelt() { if (!bm.rotates) continue; for (let i = 0; i < bm.items.length; i++) { const it = bm.items[i]; + const globalIdx = bm.globalOffset + i; + if (!built.states[globalIdx]?.alive) continue; // fractured — stays zero-scaled q.setFromAxisAngle(it.axis, it.phase + t * it.speed); m.compose(it.pos, q, it.scale); bm.inst.setMatrixAt(i, m); diff --git a/src/scene/asteroidRuntime.ts b/src/scene/asteroidRuntime.ts new file mode 100644 index 0000000..10aab51 --- /dev/null +++ b/src/scene/asteroidRuntime.ts @@ -0,0 +1,26 @@ +// 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 { AsteroidState } from '../systems/asteroidState'; +import type { AsteroidGrid } from '../systems/asteroidGrid'; + +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; +} = { + states: [], + grid: null, + groupYaw: 0, + killAsteroid: null, +}; diff --git a/src/systems/asteroidGrid.test.ts b/src/systems/asteroidGrid.test.ts new file mode 100644 index 0000000..4b22872 --- /dev/null +++ b/src/systems/asteroidGrid.test.ts @@ -0,0 +1,84 @@ +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), + radius: 1, + health: 10, + maxHealth: 10, + seed: 0, + alive, + indestructible: 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.ts b/src/systems/asteroidLayout.ts index d724911..5ac479d 100644 --- a/src/systems/asteroidLayout.ts +++ b/src/systems/asteroidLayout.ts @@ -87,6 +87,35 @@ function pickSector(h: number): number { 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; 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..57a3940 --- /dev/null +++ b/src/systems/asteroidState.ts @@ -0,0 +1,62 @@ +// 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 type { 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; + /** 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; +} + +/** Bigger rocks take more hits to fracture — scales with bounding radius. */ +function maxHealthForRadius(radius: number): number { + return 8 + radius * 18; +} + +/** + * 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(), + radius: placed.radius, + health: maxHealth, + maxHealth, + seed, + alive: true, + indestructible: false, + }); + } + } + return states; +} + From dc07f2107cc475d5136b9f26108749b9e7ccce8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 13:58:08 +0000 Subject: [PATCH 04/22] Add ship-vs-asteroid collision via the spatial grid Ship now slides off asteroids on contact using the same push-out + tangential-retain response as planet collision, broadphased through the belt's spatial grid instead of scanning every instance. Since the grid is indexed in belt-local space (the belt rotates as one group each frame), the query/response rotate the ship's position and velocity into that frame and back via the belt's published yaw. Generalizes the previously-private sphere-vs-sphere resolver into a shared resolveSphereContact used by both planet and asteroid contacts, and returns hit info (which asteroid, contact point, closing speed) for the upcoming collision-triggered damage in the fracture/mining systems. --- src/ship/ShipController.tsx | 11 ++-- src/ship/shipCollision.test.ts | 83 ++++++++++++++++++++++++++++- src/ship/shipCollision.ts | 95 +++++++++++++++++++++++++++++++--- 3 files changed, 176 insertions(+), 13 deletions(-) diff --git a/src/ship/ShipController.tsx b/src/ship/ShipController.tsx index aaf3d69..485477f 100644 --- a/src/ship/ShipController.tsx +++ b/src/ship/ShipController.tsx @@ -12,7 +12,7 @@ import { type AngularVelocity, type ShipInput, } from './shipPhysics'; -import { resolvePlanetCollision } from './shipCollision'; +import { resolvePlanetCollision, resolveAsteroidCollision } from './shipCollision'; import { readInput, installKeyboardListeners, removeKeyboardListeners } from './shipInput'; import { shipTelemetry, syncTelemetryFromStore, MIRROR_INTERVAL } from './shipTelemetry'; import { decayStick, resetStick } from './virtualStick'; @@ -191,10 +191,13 @@ export function ShipController() { const damping = cfg.flightAssist ? ASSIST_DAMPING : DRIFT_DAMPING; integrate(_pos, _vel, _accel, damping, dt); - // Slide off planets/moons 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). + // 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); + resolveAsteroidCollision(_pos, _vel, SHIP_COLLISION_RADIUS); group.position.copy(_pos); group.quaternion.copy(_quat); diff --git a/src/ship/shipCollision.test.ts b/src/ship/shipCollision.test.ts index 9f933f8..3bea9e9 100644 --- a/src/ship/shipCollision.test.ts +++ b/src/ship/shipCollision.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { Vector3 } from 'three'; -import { resolvePlanetCollision, TANGENTIAL_RETAIN } from './shipCollision'; +import { resolvePlanetCollision, resolveAsteroidCollision, 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. @@ -62,3 +65,79 @@ describe('resolvePlanetCollision', () => { expect(velocity.x).toBeCloseTo(10, 5); }); }); + +function mkState(x: number, z: number): AsteroidState { + return { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(x, 0, z), + radius: 2, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: 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 index 6e5910a..9d81ea5 100644 --- a/src/ship/shipCollision.ts +++ b/src/ship/shipCollision.ts @@ -1,6 +1,9 @@ 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 @@ -33,32 +36,37 @@ export function sphereVsPlanets( let hit = false; for (const p of PLANETS) { positionAtTime(p.elements, p.distance, simTimeDays, _bodyPos); - if (resolveSphere(position, velocity, _bodyPos, p.size, radius, tangentialRetain)) hit = true; + 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 (resolveSphere(position, velocity, _bodyPos, m.size, radius, tangentialRetain)) hit = true; + if (resolveSphereContact(position, velocity, _bodyPos, m.size, radius, tangentialRetain) !== null) + hit = true; } } return hit; } -/** Single sphere-vs-sphere contact test + resolution against one body. */ -function resolveSphere( +/** 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, -): boolean { +): number | null { const minDist = bodyRadius + radius; _normal.copy(position).sub(center); const distSq = _normal.lengthSq(); - if (distSq >= minDist * minDist) return false; + if (distSq >= minDist * minDist) return null; const dist = Math.sqrt(distSq); if (dist > 1e-6) _normal.multiplyScalar(1 / dist); @@ -74,7 +82,7 @@ function resolveSphere( velocity.sub(_normalVel); // strip normal component velocity.multiplyScalar(tangentialRetain); } - return true; + return normalSpeed < 0 ? -normalSpeed : 0; } /** Ship-vs-planet/moon collision — slide response, never a force/acceleration @@ -88,3 +96,76 @@ export function resolvePlanetCollision( ): boolean { return sphereVsPlanets(position, velocity, simTimeDays, shipRadius, TANGENTIAL_RETAIN); } + +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`. */ +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 }; +} From c0298707c2be0c3b2ea4efa89d0ee3ef5e61468b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:05:32 +0000 Subject: [PATCH 05/22] Add asteroid fracture and pooled debris physics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asteroids now hold real health instead of being binary objects: 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, each with a deterministically hashed spawn direction/size (not Math.random()) so a given hit's outcome is reproducible. Debris never recursively fractures — a hard budget stop. Both collision (a fast graze now chips an asteroid's health) and the upcoming mining/weapon system route through this same applyAsteroidDamage pipeline. Debris is a pooled, quality-budgeted population (AsteroidDebris.tsx, same InstancedMesh swap-remove pattern as the voxel mining debris burst) that drifts freely in world space via the ship's existing integrate() helper, sticks-and-expires on any contact (planet, asteroid, other debris) rather than continuing to bounce, and is force-culled by lifetime and distance from the ship — all four budgets are new QUALITY tiers. --- src/scene/AsteroidBelt.tsx | 24 +----- src/scene/AsteroidDebris.tsx | 90 +++++++++++++++++++++ src/scene/SolarSystem.tsx | 2 + src/scene/debrisRuntime.ts | 53 +++++++++++++ src/scene/rockGeometry.ts | 25 ++++++ src/ship/ShipController.tsx | 17 +++- src/ship/shipCollision.test.ts | 1 + src/ship/shipCollision.ts | 6 +- src/ship/shipPhysics.ts | 4 + src/systems/asteroidFracture.test.ts | 114 +++++++++++++++++++++++++++ src/systems/asteroidFracture.ts | 100 +++++++++++++++++++++++ src/systems/asteroidGrid.test.ts | 1 + src/systems/asteroidState.ts | 5 ++ src/systems/debrisPhysics.test.ts | 93 ++++++++++++++++++++++ src/systems/debrisPhysics.ts | 88 +++++++++++++++++++++ src/systems/quality.ts | 18 +++++ 16 files changed, 615 insertions(+), 26 deletions(-) create mode 100644 src/scene/AsteroidDebris.tsx create mode 100644 src/scene/debrisRuntime.ts create mode 100644 src/scene/rockGeometry.ts create mode 100644 src/systems/asteroidFracture.test.ts create mode 100644 src/systems/asteroidFracture.ts create mode 100644 src/systems/debrisPhysics.test.ts create mode 100644 src/systems/debrisPhysics.ts diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index ef388e7..5860d0c 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import { InstancedMesh, - IcosahedronGeometry, MeshStandardNodeMaterial, Matrix4, Quaternion, @@ -17,28 +16,7 @@ import { TIERS, BELT_SEED, computeTierVariantCounts, placeAsteroid } from '../sy import { buildAsteroidStates } from '../systems/asteroidState'; import { buildAsteroidGrid, removeFromGrid } from '../systems/asteroidGrid'; import { asteroidRuntime } from './asteroidRuntime'; - -/** 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 { rockGeometry } from './rockGeometry'; interface RotItem { pos: Vector3; diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx new file mode 100644 index 0000000..30a4ed0 --- /dev/null +++ b/src/scene/AsteroidDebris.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { InstancedMesh, MeshStandardMaterial, Matrix4, Color, type Material } from 'three/webgpu'; +import { useStore } from '../store'; +import { QUALITY } from '../systems/quality'; +import { updateDebrisBodies } from '../systems/debrisPhysics'; +import { debrisRuntime } from './debrisRuntime'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { rockGeometry } from './rockGeometry'; + +const _m = new Matrix4(); +const _zero = new Matrix4().makeScale(0, 0, 0); +const _rockColor = new Color(0.4, 0.37, 0.33); +const _oreColor = new Color(0.75, 0.62, 0.25); + +/** + * Pooled debris/ore-chunk fragments spawned by asteroid fracture (and, + * later, space mining). One shared InstancedMesh, hard-capped by + * `QUALITY[...].debrisMax`, following the exact swap-remove + scale-to-zero + * pooling pattern already used for voxel mining debris + * (`ChunkManager.tsx`'s `Debris[]`/`updateDebris`) — reuses the belt's + * cheapest (tier-0) rock geometry rather than building new GPU resources. + */ +export function AsteroidDebris() { + const q = QUALITY[useStore((s) => s.quality)]; + const sceneModeType = useStore((s) => s.sceneMode.type); + + const built = useMemo(() => { + if (q.debrisMax === 0) return null; + const geometry = rockGeometry(0, 999); + const material = new MeshStandardMaterial({ color: 0xffffff, roughness: 1, metalness: 0 }); + const inst = new InstancedMesh(geometry, material, q.debrisMax); + inst.frustumCulled = false; + return { inst, geometry, material }; + }, [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; + if (debrisRuntime.list.length > q.debrisMax) { + debrisRuntime.list.length = q.debrisMax; + } + }, [q.debrisMax, q.debrisLifetimeSec, q.debrisCullDistance]); + + useEffect(() => { + return () => { + if (!built) return; + built.inst.dispose(); + built.geometry.dispose(); + (built.material as Material).dispose(); + }; + }, [built]); + + useFrame((_, delta) => { + if (!built) 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 list = debrisRuntime.list; + const inst = built.inst; + for (let i = 0; i < q.debrisMax; i++) { + const d = list[i]; + if (d) { + _m.makeScale(d.radius, d.radius, d.radius); + _m.setPosition(d.pos.x, d.pos.y, d.pos.z); + inst.setMatrixAt(i, _m); + inst.setColorAt(i, d.isOre ? _oreColor : _rockColor); + } else { + inst.setMatrixAt(i, _zero); + } + } + inst.instanceMatrix.needsUpdate = true; + if (inst.instanceColor) inst.instanceColor.needsUpdate = true; + }); + + if (!built) return null; + return ; +} diff --git a/src/scene/SolarSystem.tsx b/src/scene/SolarSystem.tsx index 8050aa5..46ef997 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -14,6 +14,7 @@ import { Starfield } from './Starfield'; import { Sun } from './Sun'; import { SolarWind } from './SolarWind'; import { AsteroidBelt } from './AsteroidBelt'; +import { AsteroidDebris } from './AsteroidDebris'; import { ShipTrail } from './ShipTrail'; import { SimClock } from './SimClock'; import { AudioReactor } from './AudioReactor'; @@ -122,6 +123,7 @@ export function SolarSystem() { + {PLANETS.map((p) => ( diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts new file mode 100644 index 0000000..9d38062 --- /dev/null +++ b/src/scene/debrisRuntime.ts @@ -0,0 +1,53 @@ +// 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 { Vector3 } from 'three/webgpu'; +import type { ResourceType } from '../voxel/voxelTypes'; + +export interface DebrisBody { + pos: Vector3; + vel: Vector3; + radius: number; + life: number; + isOre: boolean; + resourceType?: ResourceType; +} + +export interface DebrisSpawnSpec { + pos: Vector3; + vel: Vector3; + radius: number; + isOre?: boolean; + resourceType?: ResourceType; +} + +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; + spawn: (spec: DebrisSpawnSpec) => void; +} = { + list: [], + maxCount: 0, + maxLifeSec: 12, + cullDistance: 400, + spawn(spec) { + if (debrisRuntime.list.length >= debrisRuntime.maxCount) return; + debrisRuntime.list.push({ + pos: spec.pos.clone(), + vel: spec.vel.clone(), + radius: spec.radius, + life: 0, + isOre: spec.isOre ?? false, + resourceType: spec.resourceType, + }); + }, +}; 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 485477f..4754130 100644 --- a/src/ship/ShipController.tsx +++ b/src/ship/ShipController.tsx @@ -13,6 +13,7 @@ import { 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'; @@ -90,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 }); @@ -197,7 +204,15 @@ export function ShipController() { // (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); - resolveAsteroidCollision(_pos, _vel, 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/shipCollision.test.ts b/src/ship/shipCollision.test.ts index 3bea9e9..a4a8dd4 100644 --- a/src/ship/shipCollision.test.ts +++ b/src/ship/shipCollision.test.ts @@ -78,6 +78,7 @@ function mkState(x: number, z: number): AsteroidState { seed: 0, alive: true, indestructible: false, + hitSeq: 0, }; } diff --git a/src/ship/shipCollision.ts b/src/ship/shipCollision.ts index 9d81ea5..75ca6d6 100644 --- a/src/ship/shipCollision.ts +++ b/src/ship/shipCollision.ts @@ -116,8 +116,10 @@ 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`. */ -function rotateY(v: Vector3, angle: number, out: Vector3): void { + * 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); diff --git a/src/ship/shipPhysics.ts b/src/ship/shipPhysics.ts index ead22af..189acba 100644 --- a/src/ship/shipPhysics.ts +++ b/src/ship/shipPhysics.ts @@ -9,6 +9,10 @@ export const SHIP_COLLISION_RADIUS = 0.6; 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/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts new file mode 100644 index 0000000..8913e7a --- /dev/null +++ b/src/systems/asteroidFracture.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { applyAsteroidDamage } from './asteroidFracture'; +import { asteroidRuntime } 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), + radius: 2, + health: 20, + maxHealth: 20, + seed: 42, + alive: true, + indestructible: false, + hitSeq: 0, + ...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.killAsteroid = null; + debrisRuntime.list = []; + debrisRuntime.maxCount = 1000; + }); + + it('low-impact damage (health remains) spawns no debris 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('none'); + expect(result.debrisSpawned).toHaveLength(0); + expect(state.health).toBe(18); + expect(killed).toBe(false); + expect(debrisRuntime.list).toHaveLength(0); + }); + + 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('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" + }); +}); diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts new file mode 100644 index 0000000..553b5f9 --- /dev/null +++ b/src/systems/asteroidFracture.ts @@ -0,0 +1,100 @@ +// 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 (no debris), a hit that finishes it off spawns a handful of +// independent debris fragments, and a hit that finishes it off with a lot of +// spare force spawns more, larger fragments. Debris never recursively +// fractures (hard budget stop) — this is the one damage pipeline both +// collision (shipCollision.ts) and deliberate mining/weapon fire +// (spaceMining.ts) route through. + +import { Vector3 } from 'three'; +import { cellHash } from '../voxel/noise'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { debrisRuntime, type DebrisSpawnSpec } from '../scene/debrisRuntime'; + +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]; + +const _dir = new Vector3(); +const _jitter = new Vector3(); + +/** + * 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: [] }; + } + + state.health -= amount; + if (state.health > 0) { + return { tier: 'none', debrisSpawned: [] }; // low impact: crater only, no debris + } + + 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; + + // Deterministic fragment count/directions from a hash of this specific + // hit — a per-asteroid hit counter (not Math.random()) so repeated hits on + // the same rock don't collide on identical hash inputs while staying + // reproducible within the session. + state.hitSeq += 1; + const countHash = cellHash(globalIdx, state.hitSeq, state.seed + 6000); + const count = minN + Math.floor(countHash * (maxN - minN + 1)); + + _dir.copy(impactVelocity); + if (_dir.lengthSq() < 1e-6) _dir.set(0, 0, -1); + else _dir.normalize(); + const speed = impactVelocity.length(); + + const debrisSpawned: DebrisSpawnSpec[] = []; + for (let k = 0; k < count; k++) { + const stream = state.hitSeq * 100 + k; + const theta = cellHash(globalIdx, stream, state.seed + 6001) * Math.PI * 2; + const phi = Math.acos(cellHash(globalIdx, stream, state.seed + 6002) * 2 - 1); + _jitter.set(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi)); + + // Outward "explosion" component scaled by damage, biased toward the + // impact direction — narratively plausible, not rigorously simulated. + const outwardSpeed = 2 + speed * 0.4; + const vel = _jitter + .clone() + .multiplyScalar(outwardSpeed) + .addScaledVector(_dir, speed * 0.3); + + // 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, state.seed + 6003) * 0.35; + const fragRadius = state.radius * fragFrac; + const pos = impactPoint.clone().addScaledVector(_jitter, fragRadius * 0.5); + + debrisSpawned.push({ pos, vel, radius: fragRadius }); + } + + for (const spec of debrisSpawned) debrisRuntime.spawn(spec); + asteroidRuntime.killAsteroid?.(globalIdx); + + return { tier, debrisSpawned }; +} diff --git a/src/systems/asteroidGrid.test.ts b/src/systems/asteroidGrid.test.ts index 4b22872..397fd6d 100644 --- a/src/systems/asteroidGrid.test.ts +++ b/src/systems/asteroidGrid.test.ts @@ -16,6 +16,7 @@ function mkState(x: number, z: number, alive = true): AsteroidState { seed: 0, alive, indestructible: false, + hitSeq: 0, }; } diff --git a/src/systems/asteroidState.ts b/src/systems/asteroidState.ts index 57a3940..c0de99c 100644 --- a/src/systems/asteroidState.ts +++ b/src/systems/asteroidState.ts @@ -23,6 +23,10 @@ export interface AsteroidState { 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; } /** Bigger rocks take more hits to fracture — scales with bounding radius. */ @@ -54,6 +58,7 @@ export function buildAsteroidStates(count: number, seed: number = BELT_SEED): As seed, alive: true, indestructible: false, + hitSeq: 0, }); } } diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts new file mode 100644 index 0000000..41d24dd --- /dev/null +++ b/src/systems/debrisPhysics.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { updateDebrisBodies } from './debrisPhysics'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { buildAsteroidGrid } from './asteroidGrid'; +import 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 }; +} + +describe('updateDebrisBodies', () => { + beforeEach(() => { + asteroidRuntime.states = []; + asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; + }); + + 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('sticks and expires on planet contact rather than bouncing', () => { + 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(0); + }); + + it('sticks and expires on asteroid contact', () => { + const state: AsteroidState = { + tierIdx: 0, + variantIdx: 0, + instIdx: 0, + pos: new Vector3(50, 0, 0), + radius: 2, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + }; + asteroidRuntime.states = [state]; + asteroidRuntime.grid = buildAsteroidGrid([state]); + asteroidRuntime.groupYaw = 0; + + const d = mkDebris(new Vector3(52, 0, 0)); // overlapping the asteroid (radius 2 + debris 0.3) + const list = [d]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(0, 0, 0), 12, 4000); + expect(list).toHaveLength(0); + }); + + it('sticks and expires on debris-vs-debris contact', () => { + const a = mkDebris(new Vector3(0, 0, 0), new Vector3(), 1); + const b = mkDebris(new Vector3(1, 0, 0), new Vector3(), 1); // overlapping (radii sum to 2 > distance 1) + const list = [a, b]; + updateDebrisBodies(list, 1 / 60, SIM_TIME, new Vector3(1000, 1000, 1000), 12, 4000); + expect(list.length).toBeLessThan(2); + }); +}); diff --git a/src/systems/debrisPhysics.ts b/src/systems/debrisPhysics.ts new file mode 100644 index 0000000..b81ab84 --- /dev/null +++ b/src/systems/debrisPhysics.ts @@ -0,0 +1,88 @@ +// 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. +// +// On any contact (planet, asteroid, other debris) a fragment sticks and +// expires rather than continuing to bounce — bounded and simple, per the +// brief's "avoid overcomplicating physics" — so this module never needs a +// second broadphase for the general case: 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 { Vector3 } from 'three'; +import { integrate, DEBRIS_DAMPING } from '../ship/shipPhysics'; +import { sphereVsPlanets, rotateY } from '../ship/shipCollision'; +import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { queryNearby } from './asteroidGrid'; +import { TIERS } from './asteroidLayout'; +import type { DebrisBody } from '../scene/debrisRuntime'; + +const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); + +const _localPos = new Vector3(); +const ZERO_ACCEL = new Vector3(0, 0, 0); + +/** True if `debris` overlaps a live asteroid in the belt's spatial grid + * (converts world position into the belt-local frame the grid is indexed + * in, same convention as `resolveAsteroidCollision`). */ +function hitsAsteroid(debris: DebrisBody): boolean { + const grid = asteroidRuntime.grid; + if (!grid) return false; + rotateY(debris.pos, -asteroidRuntime.groupYaw, _localPos); + const candidates = queryNearby(grid, _localPos.x, _localPos.z, debris.radius + MAX_ASTEROID_RADIUS); + for (const idx of candidates) { + const s = asteroidRuntime.states[idx]; + if (!s || !s.alive) continue; + const minDist = s.radius + debris.radius; + if (_localPos.distanceToSquared(s.pos) < minDist * minDist) return true; + } + return false; +} + +/** + * Integrate + resolve every debris body one frame. Expired (impacted, + * timed-out, or culled-by-distance) fragments are 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 { + for (let i = list.length - 1; i >= 0; i--) { + const d = list[i]; + d.life += dt; + + let expired = d.life >= maxLifeSec || d.pos.distanceTo(shipPos) > cullDistance; + + if (!expired) { + integrate(d.pos, d.vel, ZERO_ACCEL, DEBRIS_DAMPING, dt); + // Any contact sticks-and-expires — no continued bouncing. + if (sphereVsPlanets(d.pos, d.vel, simTimeDays, d.radius, 0)) expired = true; + else if (hitsAsteroid(d)) expired = true; + else { + for (let j = 0; j < list.length; j++) { + if (j === i) continue; + const other = list[j]; + const minDist = d.radius + other.radius; + if (d.pos.distanceToSquared(other.pos) < minDist * minDist) { + expired = true; + break; + } + } + } + } + + if (expired) { + list[i] = list[list.length - 1]; + list.pop(); + } + } +} diff --git a/src/systems/quality.ts b/src/systems/quality.ts index 827ee43..7663894 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -40,6 +40,12 @@ export interface QualitySettings { 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; } export const QUALITY: Record = { @@ -67,6 +73,9 @@ export const QUALITY: Record = { voxelScatter: 120, voxelTrees: 60, shipTrailParticles: 0, + debrisMax: 0, + debrisLifetimeSec: 0, + debrisCullDistance: 0, }, medium: { planetSegments: 40, @@ -92,6 +101,9 @@ export const QUALITY: Record = { voxelScatter: 340, voxelTrees: 160, shipTrailParticles: 40, + debrisMax: 24, + debrisLifetimeSec: 12, + debrisCullDistance: 400, }, high: { planetSegments: 64, @@ -117,6 +129,9 @@ export const QUALITY: Record = { voxelScatter: 680, voxelTrees: 320, shipTrailParticles: 90, + debrisMax: 64, + debrisLifetimeSec: 18, + debrisCullDistance: 700, }, ultra: { planetSegments: 96, @@ -142,6 +157,9 @@ export const QUALITY: Record = { voxelScatter: 1100, voxelTrees: 550, shipTrailParticles: 160, + debrisMax: 128, + debrisLifetimeSec: 24, + debrisCullDistance: 1000, }, }; From 1c919d2d852565871e98a7dbaf651f04a8901b4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:11:30 +0000 Subject: [PATCH 06/22] Add space mining/weapon system with ore collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raycasts a fixed screen-center beam (mirroring the voxel mining crosshair convention) against grid-shortlisted asteroid candidates only, feeding sustained fire into the same applyAsteroidDamage pipeline collision damage uses — holding the beam on target naturally produces low/medium/high fracture outcomes depending on how long it stays on target. Firing binds to left mouse (while pointer-locked), Space, or gamepad RB, none of which conflict with existing flight controls. A fraction of any fracture's debris (mining- or collision-triggered) comes back flagged as ore with a resource type from the existing inventory system; ore chunks within range home toward the ship (a one-way pull on the small pickup object, not a force on the ship, so it doesn't reintroduce a gravity well) and are collected into the same backpack voxel mining uses. --- src/scene/SolarSystem.tsx | 2 + src/scene/SpaceMiningController.tsx | 91 +++++++++++++++++++ src/ship/spaceMining.test.ts | 114 +++++++++++++++++++++++ src/ship/spaceMining.ts | 136 ++++++++++++++++++++++++++++ src/systems/asteroidFracture.ts | 16 +++- 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 src/scene/SpaceMiningController.tsx create mode 100644 src/ship/spaceMining.test.ts create mode 100644 src/ship/spaceMining.ts diff --git a/src/scene/SolarSystem.tsx b/src/scene/SolarSystem.tsx index 46ef997..f2df303 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -16,6 +16,7 @@ import { SolarWind } from './SolarWind'; import { AsteroidBelt } from './AsteroidBelt'; import { AsteroidDebris } from './AsteroidDebris'; import { ShipTrail } from './ShipTrail'; +import { SpaceMiningController } from './SpaceMiningController'; import { SimClock } from './SimClock'; import { AudioReactor } from './AudioReactor'; import { LabelProjector } from './LabelProjector'; @@ -140,6 +141,7 @@ export function SolarSystem() { + )} {sceneMode.type === 'descending' && ( diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx new file mode 100644 index 0000000..00d4a9f --- /dev/null +++ b/src/scene/SpaceMiningController.tsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { Vector3 } from 'three'; +import { useStore } from '../store'; +import { shipTelemetry } from '../ship/shipTelemetry'; +import { + raycastAsteroids, + installMiningInput, + removeMiningInput, + isFiring, + MINING_RANGE, + MINING_DPS, + MINING_IMPACT_SPEED, +} from '../ship/spaceMining'; +import { applyAsteroidDamage } from '../systems/asteroidFracture'; +import { debrisRuntime } from './debrisRuntime'; +import { SHIP_COLLISION_RADIUS } from '../ship/shipPhysics'; + +/** 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 _origin = new Vector3(); +const _dir = new Vector3(); +const _impactVel = new Vector3(); +const _toShip = new Vector3(); + +/** + * Space mining/weapon system: aims a fixed screen-center ray (mirroring the + * voxel mining crosshair convention), damages the targeted asteroid via the + * same `applyAsteroidDamage` pipeline collision uses, and separately pulls + + * collects any ore-flagged debris that drifts near the ship. 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(); + }, []); + + useFrame((_, delta) => { + const store = useStore.getState(); + if (store.sceneMode.type !== 'piloting') return; + const dt = Math.min(delta, 0.05); + + if (isFiring()) { + _origin.copy(camera.position); + camera.getWorldDirection(_dir); + const hit = raycastAsteroids(_origin, _dir, MINING_RANGE); + if (hit) { + _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); + applyAsteroidDamage(hit.globalIdx, MINING_DPS * dt, hit.point, _impactVel); + } + } + + // 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/ship/spaceMining.test.ts b/src/ship/spaceMining.test.ts new file mode 100644 index 0000000..223481a --- /dev/null +++ b/src/ship/spaceMining.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { raycastAsteroids } 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, + radius, + health: 10, + maxHealth: 10, + seed: 0, + alive: true, + indestructible: false, + hitSeq: 0, + }; +} + +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); + }); +}); diff --git a/src/ship/spaceMining.ts b/src/ship/spaceMining.ts new file mode 100644 index 0000000..0900e33 --- /dev/null +++ b/src/ship/spaceMining.ts @@ -0,0 +1,136 @@ +// Raycast 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), broadphased +// through the belt's spatial grid so it never iterates the full asteroid +// population. Sustained fire feeds the same `applyAsteroidDamage` pipeline +// collision damage uses, naturally producing low/medium/high fracture +// outcomes depending on how long the beam stays on target. + +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) the mining beam can reach. */ +export const MINING_RANGE = 60; +/** Damage per second while the beam stays on target. */ +export const MINING_DPS = 14; +/** Pseudo-"impact speed" fed into the fracture debris-ejection direction — + * not a real projectile velocity, just biases fragments away from the beam. */ +export const MINING_IMPACT_SPEED = 30; + +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 installed = false; + +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; +} + +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(); +} diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index 553b5f9..f9ae5b1 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -11,6 +11,7 @@ import { Vector3 } from 'three'; import { cellHash } from '../voxel/noise'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { debrisRuntime, type DebrisSpawnSpec } from '../scene/debrisRuntime'; +import type { ResourceType } from '../voxel/voxelTypes'; export type FractureTier = 'none' | 'low' | 'medium' | 'high'; @@ -25,6 +26,13 @@ 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']; + const _dir = new Vector3(); const _jitter = new Vector3(); @@ -90,7 +98,13 @@ export function applyAsteroidDamage( const fragRadius = state.radius * fragFrac; const pos = impactPoint.clone().addScaledVector(_jitter, fragRadius * 0.5); - debrisSpawned.push({ pos, vel, radius: fragRadius }); + const oreHash = cellHash(globalIdx, stream, state.seed + 6004); + const isOre = oreHash < ORE_FRACTION; + const resourceType = isOre + ? ORE_TYPES[Math.floor(cellHash(globalIdx, stream, state.seed + 6005) * ORE_TYPES.length)] + : undefined; + + debrisSpawned.push({ pos, vel, radius: fragRadius, isOre, resourceType }); } for (const spec of debrisSpawned) debrisRuntime.spawn(spec); From d266569926ec71e5d704430652dcc5c7f1dd64c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:16:29 +0000 Subject: [PATCH 07/22] Add the space narrative/POI layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small, Act-1-scoped set of space points of interest — wreckage, a signal anomaly, a resource cluster, and a distant landmark — reusing the existing two-layer discovery architecture exactly as-is (recordDiscovery with planet: 'space', the existing 'signal' cross-body mystery) rather than a parallel narrative system. No new store field was needed: the existing `discovered` map already namespaces by "planet:id", so 'space' is just another valid planet string. Wreckage can anchor to a specific, stable belt asteroid (guaranteed to resolve to the same physical rock by the belt's determinism contract) and flags its host indestructible so it can't be blown apart by fracture testing. Discovery is proximity-triggered while piloting (fly close enough, no separate scan-button UI) — simpler than the voxel-surface manual-scan convention, appropriate since these are found while flying rather than on foot. --- src/scene/SolarSystem.tsx | 2 + src/scene/SpacePoiField.tsx | 107 ++++++++++++++++++++++++++ src/store.test.ts | 25 ++++++ src/systems/spacePoiProfiles.test.ts | 42 ++++++++++ src/systems/spacePoiProfiles.ts | 111 +++++++++++++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 src/scene/SpacePoiField.tsx create mode 100644 src/systems/spacePoiProfiles.test.ts create mode 100644 src/systems/spacePoiProfiles.ts diff --git a/src/scene/SolarSystem.tsx b/src/scene/SolarSystem.tsx index f2df303..1ce18c5 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -17,6 +17,7 @@ import { AsteroidBelt } from './AsteroidBelt'; import { AsteroidDebris } from './AsteroidDebris'; import { ShipTrail } from './ShipTrail'; import { SpaceMiningController } from './SpaceMiningController'; +import { SpacePoiField } from './SpacePoiField'; import { SimClock } from './SimClock'; import { AudioReactor } from './AudioReactor'; import { LabelProjector } from './LabelProjector'; @@ -125,6 +126,7 @@ export function SolarSystem() { + {PLANETS.map((p) => ( 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/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/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.', + }, + }, +]; From 070537c4dd35ca4c30c5796a9d153e51f9eeb4dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:18:34 +0000 Subject: [PATCH 08/22] Document the Act 1 space layer pass in ROADMAP.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Phase 11.8 covering ship collision, deterministic/structured asteroid placement, fracture/debris, space mining, and the space narrative/POI layer built on this branch, and notes the branch itself in the Branches & PRs section — unmerged, not yet manually playtested. --- ROADMAP.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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. From a387a6036ad68e2c13fa08b630a532b152612a11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 15:18:02 +0000 Subject: [PATCH 09/22] Add touch roll control and space mining visibility/feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two real gaps: touch/mobile had no roll control at all (the on-screen joystick only ever set yaw/pitch, roll was hardcoded to 0), and touch had no way to fire the mining beam whatsoever (isFiring() only checked mouse/keyboard/gamepad) — independent of any range or quality-tier questions, touch players simply could not shoot or mine. - New ↺/↻ roll buttons and a FIRE button in TouchControls.tsx, grouped as a bottom-right thumb-reach cluster mirroring the game's own on-foot action-button convention (VoxelTouchControls.tsx), wired through new setTouchRoll/setTouchFiring exports. - Space mining now has visible feedback for every input method: a crosshair that highlights on-target (raycasts every frame instead of only while firing, published via a new spaceMiningTelemetry singleton), a visual beam line while firing, and an audible beam tone distinct from the engine hum. - Documented the fire control and touch roll buttons in ControlHints.tsx (English + Swedish), which previously never mentioned firing at all. --- src/App.tsx | 2 + src/audio/AudioManager.ts | 30 +++++++++ src/scene/SpaceMiningController.tsx | 73 ++++++++++++++++++---- src/ship/shipInput.test.ts | 50 +++++++++++++++ src/ship/shipInput.ts | 10 ++- src/ship/spaceMining.test.ts | 30 ++++++++- src/ship/spaceMining.ts | 11 +++- src/ship/spaceMiningTelemetry.ts | 20 ++++++ src/styles.css | 95 +++++++++++++++++++++++++++++ src/ui/ControlHints.tsx | 6 ++ src/ui/SpaceCrosshair.tsx | 26 ++++++++ src/ui/TouchControls.tsx | 90 ++++++++++++++++++++++++++- 12 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 src/ship/shipInput.test.ts create mode 100644 src/ship/spaceMiningTelemetry.ts create mode 100644 src/ui/SpaceCrosshair.tsx diff --git a/src/App.tsx b/src/App.tsx index 5b6bbfe..16cacb8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -53,6 +54,7 @@ export default function App() { + diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 8d6c34e..4f0ceed 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -17,6 +17,7 @@ class AudioManager { private droneFilter?: BiquadFilterNode; private engineGain?: GainNode; private engineFilter?: BiquadFilterNode; + private miningGain?: GainNode; private started = false; // Surface ambience graph (built lazily on first landing, reused after). @@ -137,6 +138,25 @@ class AudioManager { this.engineGain = engineGain; this.engineFilter = engineFilter; + // --- Space mining beam: a resonant buzz that swells while firing and + // on-target, distinct in timbre from the engine hum so the two never + // read as the same sound. --- + const miningFilter = ctx.createBiquadFilter(); + miningFilter.type = 'bandpass'; + miningFilter.Q.value = 4; + miningFilter.frequency.value = 900; + const miningGain = ctx.createGain(); + miningGain.gain.value = 0; + miningFilter.connect(miningGain).connect(master); + const miningOsc = ctx.createOscillator(); + miningOsc.type = 'sawtooth'; + miningOsc.frequency.value = 220; + const miningOscGain = ctx.createGain(); + miningOscGain.gain.value = 0.5; + miningOsc.connect(miningOscGain).connect(miningFilter); + miningOsc.start(); + this.miningGain = miningGain; + this.started = true; } @@ -174,6 +194,16 @@ class AudioManager { this.engineFilter.frequency.setTargetAtTime(150 + throttle * 500, t, 0.15); } + /** Mining beam feedback: `firing` is whether the trigger is held, `onTarget` + * whether the ray currently hits a live asteroid — the tone only sounds + * while both are true, distinguishing "firing into empty space" from + * "firing and actually hitting something" audibly. */ + setMiningBeam(firing: boolean, onTarget: boolean) { + if (!this.miningGain || !this.ctx) return; + const t = this.ctx.currentTime; + this.miningGain.gain.setTargetAtTime(firing && onTarget ? 0.05 : 0, t, 0.05); + } + // --- Surface ambience -------------------------------------------------- /** Build the persistent surface graph once: wind (filtered noise) + rumble. */ diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index 00d4a9f..f007f07 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; -import { Vector3 } from 'three'; +import { Vector3, BufferGeometry, BufferAttribute, Line, LineBasicMaterial, AdditiveBlending } from 'three/webgpu'; import { useStore } from '../store'; import { shipTelemetry } from '../ship/shipTelemetry'; import { @@ -12,9 +12,11 @@ import { MINING_DPS, MINING_IMPACT_SPEED, } from '../ship/spaceMining'; +import { spaceMiningTelemetry } from '../ship/spaceMiningTelemetry'; import { applyAsteroidDamage } from '../systems/asteroidFracture'; 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 @@ -47,19 +49,66 @@ export function SpaceMiningController() { return () => removeMiningInput(); }, []); + // Visual beam — a single line updated in place each frame, only visible + // while firing. No pooling needed: this is one object, not a population. + const beam = useMemo(() => { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(6), 3)); + const material = new LineBasicMaterial({ + color: 0xff8850, + transparent: true, + opacity: 0.8, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + }); + const line = new Line(geometry, material); + line.frustumCulled = false; + line.visible = false; + return line; + }, []); + + useEffect(() => { + return () => { + beam.geometry.dispose(); + (beam.material as LineBasicMaterial).dispose(); + }; + }, [beam]); + useFrame((_, delta) => { const store = useStore.getState(); - if (store.sceneMode.type !== 'piloting') return; + if (store.sceneMode.type !== 'piloting') { + beam.visible = false; + audio.setMiningBeam(false, false); + return; + } const dt = Math.min(delta, 0.05); - if (isFiring()) { - _origin.copy(camera.position); - camera.getWorldDirection(_dir); - const hit = raycastAsteroids(_origin, _dir, MINING_RANGE); - if (hit) { - _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); - applyAsteroidDamage(hit.globalIdx, MINING_DPS * dt, hit.point, _impactVel); - } + // 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). + _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(); + audio.setMiningBeam(firing, hit !== null); + + if (firing && hit) { + _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); + applyAsteroidDamage(hit.globalIdx, MINING_DPS * dt, hit.point, _impactVel); + } + + beam.visible = firing; + if (firing) { + const endPoint = hit ? hit.point : _origin.clone().addScaledVector(_dir, MINING_RANGE); + const posAttr = beam.geometry.attributes.position as BufferAttribute; + posAttr.setXYZ(0, _origin.x, _origin.y, _origin.z); + posAttr.setXYZ(1, endPoint.x, endPoint.y, endPoint.z); + posAttr.needsUpdate = true; } // Ore magnetism + collection — backward swap-remove, safe regardless of @@ -87,5 +136,5 @@ export function SpaceMiningController() { } }); - return null; + return ; } 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/spaceMining.test.ts b/src/ship/spaceMining.test.ts index 223481a..715f3b8 100644 --- a/src/ship/spaceMining.test.ts +++ b/src/ship/spaceMining.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Vector3 } from 'three'; -import { raycastAsteroids } from './spaceMining'; +import { raycastAsteroids, isFiring, setTouchFiring, installMiningInput, removeMiningInput } from './spaceMining'; import { rotateY } from './shipCollision'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { buildAsteroidGrid } from '../systems/asteroidGrid'; @@ -112,3 +112,29 @@ describe('raycastAsteroids', () => { 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 index 0900e33..01196a2 100644 --- a/src/ship/spaceMining.ts +++ b/src/ship/spaceMining.ts @@ -79,8 +79,16 @@ export function raycastAsteroids(origin: Vector3, dir: Vector3, maxDistance: num 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; } @@ -96,6 +104,7 @@ function onMouseUp(e: MouseEvent) { function onBlur() { spaceKeyDown = false; mouseDown = false; + touchFiring = false; } export function installMiningInput(): void { @@ -132,5 +141,5 @@ function gamepadFiring(): boolean { * matching the mouse-flight convention). */ export function isFiring(): boolean { const mouseFiring = mouseDown && typeof document !== 'undefined' && !!document.pointerLockElement; - return spaceKeyDown || mouseFiring || gamepadFiring(); + 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/styles.css b/src/styles.css index 567099d..7aab2b8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1640,6 +1640,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; @@ -1823,6 +1851,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 +1909,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/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 ); From 730e70f7a1ecff859b767b8e55cc2dbda3e1672c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 15:36:05 +0000 Subject: [PATCH 10/22] Convert mining beam to discrete automatic shots with impact chips and knockback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firing is now a fixed-cadence stream of discrete hitscan shots while the trigger is held (6/sec, first shot immediate on trigger-pull) instead of a continuous damage-over-time beam — each shot flashes the tracer line briefly, plays a distinct laser blip (pitched differently for a hit vs. a miss), and, on a connecting hit, bursts a handful of short-lived impact-chip sparks at the point of impact regardless of whether the hit fractures the target. Sparks are a separate, cheap, quality-budgeted pool (miningSparkRuntime/MiningSparks.tsx) so they don't compete with the rarer, longer-lived fracture-debris budget. Asteroids also gain real impact physics: every hit (not just lethal ones) now applies a knockback impulse in the shot's direction of travel, giving asteroids a velocity they didn't have before. Tumbling- tier asteroids share their position Vector3 between the per-asteroid state and the render loop's rotation item, so integrating that velocity in AsteroidBelt's existing per-frame tumble pass is enough to visibly drift a hit rock — no separate update path needed. Dust-tier (non-rotating) asteroids don't redraw after their initial build, so knockback there is a no-op by design; they're too small to read knockback on anyway. --- src/audio/AudioManager.ts | 52 +++++++------- src/scene/AsteroidBelt.tsx | 37 ++++++++-- src/scene/MiningSparks.tsx | 100 +++++++++++++++++++++++++++ src/scene/SolarSystem.tsx | 2 + src/scene/SpaceMiningController.tsx | 78 ++++++++++++++++----- src/scene/miningSparkRuntime.test.ts | 32 +++++++++ src/scene/miningSparkRuntime.ts | 41 +++++++++++ src/ship/shipCollision.test.ts | 1 + src/ship/spaceMining.test.ts | 1 + src/ship/spaceMining.ts | 16 +++-- src/systems/asteroidFracture.test.ts | 21 ++++++ src/systems/asteroidFracture.ts | 21 ++++-- src/systems/asteroidGrid.test.ts | 1 + src/systems/asteroidState.ts | 9 ++- src/systems/debrisPhysics.test.ts | 1 + src/systems/quality.ts | 6 ++ 16 files changed, 354 insertions(+), 65 deletions(-) create mode 100644 src/scene/MiningSparks.tsx create mode 100644 src/scene/miningSparkRuntime.test.ts create mode 100644 src/scene/miningSparkRuntime.ts diff --git a/src/audio/AudioManager.ts b/src/audio/AudioManager.ts index 4f0ceed..58f466f 100644 --- a/src/audio/AudioManager.ts +++ b/src/audio/AudioManager.ts @@ -17,7 +17,6 @@ class AudioManager { private droneFilter?: BiquadFilterNode; private engineGain?: GainNode; private engineFilter?: BiquadFilterNode; - private miningGain?: GainNode; private started = false; // Surface ambience graph (built lazily on first landing, reused after). @@ -138,25 +137,6 @@ class AudioManager { this.engineGain = engineGain; this.engineFilter = engineFilter; - // --- Space mining beam: a resonant buzz that swells while firing and - // on-target, distinct in timbre from the engine hum so the two never - // read as the same sound. --- - const miningFilter = ctx.createBiquadFilter(); - miningFilter.type = 'bandpass'; - miningFilter.Q.value = 4; - miningFilter.frequency.value = 900; - const miningGain = ctx.createGain(); - miningGain.gain.value = 0; - miningFilter.connect(miningGain).connect(master); - const miningOsc = ctx.createOscillator(); - miningOsc.type = 'sawtooth'; - miningOsc.frequency.value = 220; - const miningOscGain = ctx.createGain(); - miningOscGain.gain.value = 0.5; - miningOsc.connect(miningOscGain).connect(miningFilter); - miningOsc.start(); - this.miningGain = miningGain; - this.started = true; } @@ -194,14 +174,30 @@ class AudioManager { this.engineFilter.frequency.setTargetAtTime(150 + throttle * 500, t, 0.15); } - /** Mining beam feedback: `firing` is whether the trigger is held, `onTarget` - * whether the ray currently hits a live asteroid — the tone only sounds - * while both are true, distinguishing "firing into empty space" from - * "firing and actually hitting something" audibly. */ - setMiningBeam(firing: boolean, onTarget: boolean) { - if (!this.miningGain || !this.ctx) return; - const t = this.ctx.currentTime; - this.miningGain.gain.setTargetAtTime(firing && onTarget ? 0.05 : 0, t, 0.05); + /** 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 -------------------------------------------------- diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 5860d0c..132f595 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -36,6 +36,12 @@ interface BeltMesh { 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; + /** * 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 @@ -66,8 +72,14 @@ export function AsteroidBelt() { // 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. + // 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) { @@ -81,11 +93,12 @@ export function AsteroidBelt() { // 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); - m.compose(placed.pos, placed.quat, placed.scale); + 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: placed.pos, + pos: statePos, // shared with AsteroidState — see note above scale: placed.scale, axis: placed.tumbleAxis, speed: placed.tumbleSpeed!, @@ -98,7 +111,6 @@ export function AsteroidBelt() { globalOffset += g.n; } - const states = buildAsteroidStates(count); const grid = buildAsteroidGrid(states); return { meshes, geometries, material, states, grid }; @@ -149,7 +161,7 @@ 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). Published // to the runtime so ship/debris code can transform world-space queries @@ -158,6 +170,7 @@ export function AsteroidBelt() { 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) { @@ -165,7 +178,19 @@ export function AsteroidBelt() { for (let i = 0; i < bm.items.length; i++) { const it = bm.items[i]; const globalIdx = bm.globalOffset + i; - if (!built.states[globalIdx]?.alive) continue; // fractured — stays zero-scaled + const s = built.states[globalIdx]; + if (!s?.alive) continue; // fractured — stays zero-scaled + + // Impact-knockback drift: `it.pos` and `s.pos` are the same Vector3 + // (see the build loop above), so integrating here is all rendering + // needs — no separate update path for hit asteroids. + 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 + } + q.setFromAxisAngle(it.axis, it.phase + t * it.speed); m.compose(it.pos, q, it.scale); bm.inst.setMatrixAt(i, m); 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/SolarSystem.tsx b/src/scene/SolarSystem.tsx index 1ce18c5..c01bec7 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -15,6 +15,7 @@ import { Sun } from './Sun'; import { SolarWind } from './SolarWind'; import { AsteroidBelt } from './AsteroidBelt'; import { AsteroidDebris } from './AsteroidDebris'; +import { MiningSparks } from './MiningSparks'; import { ShipTrail } from './ShipTrail'; import { SpaceMiningController } from './SpaceMiningController'; import { SpacePoiField } from './SpacePoiField'; @@ -126,6 +127,7 @@ export function SolarSystem() { + {PLANETS.map((p) => ( diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index f007f07..014701a 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { Vector3, BufferGeometry, BufferAttribute, Line, LineBasicMaterial, AdditiveBlending } from 'three/webgpu'; import { useStore } from '../store'; @@ -9,12 +9,14 @@ import { removeMiningInput, isFiring, MINING_RANGE, - MINING_DPS, + FIRE_RATE, + SHOT_DAMAGE, MINING_IMPACT_SPEED, } from '../ship/spaceMining'; import { spaceMiningTelemetry } from '../ship/spaceMiningTelemetry'; import { applyAsteroidDamage } from '../systems/asteroidFracture'; import { debrisRuntime } from './debrisRuntime'; +import { miningSparkRuntime } from './miningSparkRuntime'; import { SHIP_COLLISION_RADIUS } from '../ship/shipPhysics'; import { audio } from '../audio/AudioManager'; @@ -27,6 +29,12 @@ const MAGNET_ACCEL = 40; const COLLECT_RANGE = SHIP_COLLISION_RADIUS + 0.5; const ORE_YIELD = 1; +/** How long the beam flash + chip burst stay visible per shot — short enough + * to read as a rapid string of discrete shots rather than a sustained beam. */ +const FLASH_DURATION = 0.06; +const SPARKS_PER_SHOT = 4; +const FIRE_INTERVAL = 1 / FIRE_RATE; + const _origin = new Vector3(); const _dir = new Vector3(); const _impactVel = new Vector3(); @@ -34,12 +42,16 @@ const _toShip = new Vector3(); /** * Space mining/weapon system: aims a fixed screen-center ray (mirroring the - * voxel mining crosshair convention), damages the targeted asteroid via the - * same `applyAsteroidDamage` pipeline collision uses, and separately pulls + - * collects any ore-flagged debris that drifts near the ship. 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. + * voxel mining crosshair convention) and fires discrete, automatic shots at + * a fixed cadence while the trigger is held (not a continuous beam) — + * each shot is hitscan but shows as a brief flash/tracer plus an impact-chip + * spark burst, and damages the targeted asteroid via the same + * `applyAsteroidDamage` pipeline collision uses (which also applies a + * knockback impulse to the asteroid regardless of whether the hit + * fractures it). Separately pulls + collects any ore-flagged debris that + * drifts near the ship. 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); @@ -49,15 +61,16 @@ export function SpaceMiningController() { return () => removeMiningInput(); }, []); - // Visual beam — a single line updated in place each frame, only visible - // while firing. No pooling needed: this is one object, not a population. + // Visual beam — a single line updated in place each frame, flashed on for + // FLASH_DURATION per shot rather than held continuously visible. No + // pooling needed: this is one object, not a population. const beam = useMemo(() => { const geometry = new BufferGeometry(); geometry.setAttribute('position', new BufferAttribute(new Float32Array(6), 3)); const material = new LineBasicMaterial({ color: 0xff8850, transparent: true, - opacity: 0.8, + opacity: 0.9, blending: AdditiveBlending, depthWrite: false, toneMapped: false, @@ -75,11 +88,29 @@ export function SpaceMiningController() { }; }, [beam]); + // Fire-rate accumulator + flash timer, 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 flashTimer = useRef(0); + + const fireShot = (hit: ReturnType) => { + audio.playMiningShot(hit !== null); + flashTimer.current = FLASH_DURATION; + if (hit) { + _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); + applyAsteroidDamage(hit.globalIdx, SHOT_DAMAGE, hit.point, _impactVel); + miningSparkRuntime.spawn(hit.point, SPARKS_PER_SHOT); + } + }; + useFrame((_, delta) => { const store = useStore.getState(); if (store.sceneMode.type !== 'piloting') { beam.visible = false; - audio.setMiningBeam(false, false); + wasFiring.current = false; + fireAcc.current = 0; return; } const dt = Math.min(delta, 0.05); @@ -95,15 +126,24 @@ export function SpaceMiningController() { spaceMiningTelemetry.hitPoint = hit ? hit.point : null; const firing = isFiring(); - audio.setMiningBeam(firing, hit !== null); - - if (firing && hit) { - _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); - applyAsteroidDamage(hit.globalIdx, MINING_DPS * dt, hit.point, _impactVel); + 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; } - beam.visible = firing; - if (firing) { + flashTimer.current = Math.max(0, flashTimer.current - dt); + beam.visible = flashTimer.current > 0; + if (beam.visible) { const endPoint = hit ? hit.point : _origin.clone().addScaledVector(_dir, MINING_RANGE); const posAttr = beam.geometry.attributes.position as BufferAttribute; posAttr.setXYZ(0, _origin.x, _origin.y, _origin.z); 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/ship/shipCollision.test.ts b/src/ship/shipCollision.test.ts index a4a8dd4..4530d6b 100644 --- a/src/ship/shipCollision.test.ts +++ b/src/ship/shipCollision.test.ts @@ -72,6 +72,7 @@ function mkState(x: number, z: number): AsteroidState { variantIdx: 0, instIdx: 0, pos: new Vector3(x, 0, z), + vel: new Vector3(), radius: 2, health: 10, maxHealth: 10, diff --git a/src/ship/spaceMining.test.ts b/src/ship/spaceMining.test.ts index 715f3b8..67088ee 100644 --- a/src/ship/spaceMining.test.ts +++ b/src/ship/spaceMining.test.ts @@ -20,6 +20,7 @@ function mkState(pos: Vector3, radius = 2): AsteroidState { variantIdx: 0, instIdx: 0, pos, + vel: new Vector3(), radius, health: 10, maxHealth: 10, diff --git a/src/ship/spaceMining.ts b/src/ship/spaceMining.ts index 01196a2..7207820 100644 --- a/src/ship/spaceMining.ts +++ b/src/ship/spaceMining.ts @@ -2,9 +2,11 @@ // combat, no NPCs; this repo has no combat system at all today). Aiming // mirrors the voxel mining convention (a fixed screen-center ray), broadphased // through the belt's spatial grid so it never iterates the full asteroid -// population. Sustained fire feeds the same `applyAsteroidDamage` pipeline -// collision damage uses, naturally producing low/medium/high fracture -// outcomes depending on how long the beam stays on target. +// population. Fire is discrete, automatic shots at a fixed cadence while held +// (not a continuous beam) — each shot is hitscan (instant), but visible as a +// brief flash/tracer and an impact spark burst, feeding 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'; @@ -14,10 +16,12 @@ import { rotateY } from './shipCollision'; /** Max range (world units) the mining beam can reach. */ export const MINING_RANGE = 60; -/** Damage per second while the beam stays on target. */ -export const MINING_DPS = 14; +/** 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; /** Pseudo-"impact speed" fed into the fracture debris-ejection direction — - * not a real projectile velocity, just biases fragments away from the beam. */ + * not a real projectile velocity, just biases fragments away from the shot. */ export const MINING_IMPACT_SPEED = 30; const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts index 8913e7a..bcd478a 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -11,6 +11,7 @@ function mkState(overrides: Partial = {}): AsteroidState { variantIdx: 0, instIdx: 0, pos: new Vector3(0, 0, 0), + vel: new Vector3(), radius: 2, health: 20, maxHealth: 20, @@ -34,6 +35,26 @@ describe('applyAsteroidDamage', () => { 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) spawns no debris and does not kill', () => { const state = mkState({ health: 20, maxHealth: 20 }); asteroidRuntime.states = [state]; diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index f9ae5b1..e012a3c 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -33,6 +33,12 @@ const HIGH_DEBRIS_RANGE: [number, number] = [4, 8]; 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; + const _dir = new Vector3(); const _jitter = new Vector3(); @@ -55,6 +61,16 @@ export function applyAsteroidDamage( return { tier: 'none', debrisSpawned: [] }; } + _dir.copy(impactVelocity); + const speed = _dir.length(); + if (speed < 1e-6) _dir.set(0, 0, -1); + else _dir.normalize(); + + // 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(_dir, amount * KNOCKBACK_PER_DAMAGE); + state.health -= amount; if (state.health > 0) { return { tier: 'none', debrisSpawned: [] }; // low impact: crater only, no debris @@ -72,11 +88,6 @@ export function applyAsteroidDamage( const countHash = cellHash(globalIdx, state.hitSeq, state.seed + 6000); const count = minN + Math.floor(countHash * (maxN - minN + 1)); - _dir.copy(impactVelocity); - if (_dir.lengthSq() < 1e-6) _dir.set(0, 0, -1); - else _dir.normalize(); - const speed = impactVelocity.length(); - const debrisSpawned: DebrisSpawnSpec[] = []; for (let k = 0; k < count; k++) { const stream = state.hitSeq * 100 + k; diff --git a/src/systems/asteroidGrid.test.ts b/src/systems/asteroidGrid.test.ts index 397fd6d..f9655ec 100644 --- a/src/systems/asteroidGrid.test.ts +++ b/src/systems/asteroidGrid.test.ts @@ -10,6 +10,7 @@ function mkState(x: number, z: number, alive = true): AsteroidState { variantIdx: 0, instIdx: 0, pos: new Vector3(x, 0, z), + vel: new Vector3(), radius: 1, health: 10, maxHealth: 10, diff --git a/src/systems/asteroidState.ts b/src/systems/asteroidState.ts index c0de99c..8c10f6a 100644 --- a/src/systems/asteroidState.ts +++ b/src/systems/asteroidState.ts @@ -6,7 +6,7 @@ // Mirrors the `Debris[]` convention already used for voxel mining debris // (`src/voxel/ChunkManager.tsx`), not a Map or class hierarchy. -import type { Vector3 } from 'three'; +import { Vector3 } from 'three'; import { placeAsteroid, computeTierVariantCounts, BELT_SEED } from './asteroidLayout'; export interface AsteroidState { @@ -15,6 +15,12 @@ export interface AsteroidState { /** 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; @@ -52,6 +58,7 @@ export function buildAsteroidStates(count: number, seed: number = BELT_SEED): As variantIdx: g.variantIdx, instIdx: i, pos: placed.pos.clone(), + vel: new Vector3(), radius: placed.radius, health: maxHealth, maxHealth, diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts index 41d24dd..eeaf13f 100644 --- a/src/systems/debrisPhysics.test.ts +++ b/src/systems/debrisPhysics.test.ts @@ -65,6 +65,7 @@ describe('updateDebrisBodies', () => { variantIdx: 0, instIdx: 0, pos: new Vector3(50, 0, 0), + vel: new Vector3(), radius: 2, health: 10, maxHealth: 10, diff --git a/src/systems/quality.ts b/src/systems/quality.ts index 7663894..f063590 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -46,6 +46,8 @@ export interface QualitySettings { 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; } export const QUALITY: Record = { @@ -76,6 +78,7 @@ export const QUALITY: Record = { debrisMax: 0, debrisLifetimeSec: 0, debrisCullDistance: 0, + miningVfxBudget: 0, }, medium: { planetSegments: 40, @@ -104,6 +107,7 @@ export const QUALITY: Record = { debrisMax: 24, debrisLifetimeSec: 12, debrisCullDistance: 400, + miningVfxBudget: 16, }, high: { planetSegments: 64, @@ -132,6 +136,7 @@ export const QUALITY: Record = { debrisMax: 64, debrisLifetimeSec: 18, debrisCullDistance: 700, + miningVfxBudget: 32, }, ultra: { planetSegments: 96, @@ -160,6 +165,7 @@ export const QUALITY: Record = { debrisMax: 128, debrisLifetimeSec: 24, debrisCullDistance: 1000, + miningVfxBudget: 48, }, }; From c237f88c4145d6247f84b4ae7e4180d78ff178e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:09:54 +0000 Subject: [PATCH 11/22] Add real local asteroid damage: promotion + per-vertex dents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces silent health-only sub-lethal hits with genuine local structural damage. On the first damaging hit, a tumbling-tier asteroid (budget/quality permitting) is "promoted" out of its shared InstancedMesh into a standalone Mesh with its own cloned, individually mutable geometry — dust-tier rocks are excluded (78% of the population, never redraw after initial build, too small to matter visually). Every subsequent non-lethal hit locally dents that geometry at the actual world-space impact point (reusing rockGeometry.ts's own per-vertex-mutation recipe, just scoped by distance falloff instead of applied uniformly), with a hard radial floor so repeated hits can't collapse a vertex through the core. Promoted asteroids also switch from a stateless clock-based tumble formula to a real integrated spin, needed so a later momentum-based fragment-velocity formula has an actual angular velocity to read. Falls back gracefully to today's health-only behavior when the promotion budget (new promotedAsteroidMax quality tier) is full or the asteroid is dust-tier — never blocks or crashes, just skips the visual upgrade for that hit. This is step 1 of a larger rework (see the approved plan) replacing "delete + spawn generic debris" with real composite-body physics; lethal fracture still uses the old jitter-based debris spawn for now — that becomes pattern-based chunk extraction with real momentum in the next step. --- src/scene/AsteroidBelt.tsx | 147 +++++++++++++++++++++++---- src/scene/asteroidRuntime.ts | 20 ++++ src/ship/shipCollision.test.ts | 1 + src/ship/spaceMining.test.ts | 1 + src/systems/asteroidDent.test.ts | 97 ++++++++++++++++++ src/systems/asteroidDent.ts | 44 ++++++++ src/systems/asteroidFracture.test.ts | 62 +++++++++++ src/systems/asteroidFracture.ts | 15 ++- src/systems/asteroidGrid.test.ts | 1 + src/systems/asteroidState.ts | 6 ++ src/systems/debrisPhysics.test.ts | 1 + src/systems/quality.ts | 16 +++ 12 files changed, 391 insertions(+), 20 deletions(-) create mode 100644 src/systems/asteroidDent.test.ts create mode 100644 src/systems/asteroidDent.ts diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 132f595..e20baa8 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import { InstancedMesh, + Mesh, MeshStandardNodeMaterial, Matrix4, Quaternion, @@ -17,6 +18,7 @@ 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; @@ -34,6 +36,22 @@ interface BeltMesh { 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 @@ -42,6 +60,11 @@ const _zeroScale = new Matrix4().makeScale(0, 0, 0); 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 @@ -51,10 +74,15 @@ const KNOCKBACK_MIN_VEL_SQ = 1e-4; * 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. + * 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(() => { @@ -66,7 +94,7 @@ export function AsteroidBelt() { material.metalnessNode = float(0); const meshes: BeltMesh[] = []; - const geometries: BufferGeometry[] = []; + const geometryByKey = new Map(); const m = new Matrix4(); // Single source of truth for which (tier, variant) groups exist and how @@ -85,7 +113,7 @@ export function AsteroidBelt() { for (const g of groups) { const tier = TIERS[g.tierIdx]; const geom = rockGeometry(tier.detail, g.variantIdx * 7 + tier.detail * 13 + 1); - geometries.push(geom); + 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[] = []; @@ -112,18 +140,34 @@ export function AsteroidBelt() { } const grid = buildAsteroidGrid(states); + const promoted = new Map(); - return { meshes, geometries, material, states, grid }; + 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 fracture/mining code uses to - // zero a dead asteroid's render instance and drop it from the grid. + // 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; @@ -131,29 +175,78 @@ export function AsteroidBelt() { asteroidRuntime.killAsteroid = (globalIdx: number) => { const state = built.states[globalIdx]; if (!state || !state.alive) 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; - } + 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); + }, + getAngularVelocity: (globalIdx: number): Vector3 | null => { + return built.promoted.get(globalIdx)?.angVel ?? null; + }, }; return () => { asteroidRuntime.states = []; asteroidRuntime.grid = null; asteroidRuntime.killAsteroid = null; + asteroidRuntime.promotion = null; }; - }, [built]); + // 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]); @@ -181,9 +274,9 @@ export function AsteroidBelt() { const s = built.states[globalIdx]; if (!s?.alive) continue; // fractured — stays zero-scaled - // Impact-knockback drift: `it.pos` and `s.pos` are the same Vector3 - // (see the build loop above), so integrating here is all rendering - // needs — no separate update path for hit asteroids. + // 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)); @@ -191,12 +284,28 @@ export function AsteroidBelt() { 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/asteroidRuntime.ts b/src/scene/asteroidRuntime.ts index 10aab51..f282ea3 100644 --- a/src/scene/asteroidRuntime.ts +++ b/src/scene/asteroidRuntime.ts @@ -4,9 +4,27 @@ // 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 { Vector3 } from 'three'; import type { AsteroidState } from '../systems/asteroidState'; import type { AsteroidGrid } from '../systems/asteroidGrid'; +/** 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; + /** The promoted asteroid's angular velocity (rad/s, world-space axis*rate) + * for the momentum formula in fracture — null if not promoted. */ + getAngularVelocity: (globalIdx: number) => Vector3 | null; +} + export const asteroidRuntime: { states: AsteroidState[]; grid: AsteroidGrid | null; @@ -18,9 +36,11 @@ export const asteroidRuntime: { * 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/ship/shipCollision.test.ts b/src/ship/shipCollision.test.ts index 4530d6b..d9f9a58 100644 --- a/src/ship/shipCollision.test.ts +++ b/src/ship/shipCollision.test.ts @@ -80,6 +80,7 @@ function mkState(x: number, z: number): AsteroidState { alive: true, indestructible: false, hitSeq: 0, + promoted: false, }; } diff --git a/src/ship/spaceMining.test.ts b/src/ship/spaceMining.test.ts index 67088ee..55a34fd 100644 --- a/src/ship/spaceMining.test.ts +++ b/src/ship/spaceMining.test.ts @@ -28,6 +28,7 @@ function mkState(pos: Vector3, radius = 2): AsteroidState { alive: true, indestructible: false, hitSeq: 0, + promoted: false, }; } 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 index bcd478a..23652e6 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -19,6 +19,7 @@ function mkState(overrides: Partial = {}): AsteroidState { alive: true, indestructible: false, hitSeq: 0, + promoted: false, ...overrides, }; } @@ -31,6 +32,7 @@ describe('applyAsteroidDamage', () => { asteroidRuntime.states = []; asteroidRuntime.grid = null; asteroidRuntime.killAsteroid = null; + asteroidRuntime.promotion = null; debrisRuntime.list = []; debrisRuntime.maxCount = 1000; }); @@ -122,6 +124,66 @@ describe('applyAsteroidDamage', () => { 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: () => {}, + getAngularVelocity: () => 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 (not debris) 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); + }, + getAngularVelocity: () => null, + }; + + const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); + expect(dentCalls).toBe(1); + expect(result.tier).toBe('none'); + expect(result.debrisSpawned).toHaveLength(0); + }); + + 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; + }, + getAngularVelocity: () => 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]; diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index e012a3c..fd453d7 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -71,9 +71,22 @@ export function applyAsteroidDamage( // not just a destruction effect. Integrated/damped in AsteroidBelt.tsx. state.vel.addScaledVector(_dir, 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; if (state.health > 0) { - return { tier: 'none', debrisSpawned: [] }; // low impact: crater only, no debris + // Low impact: real local damage — a persistent crater at the impact + // point — instead of the asteroid just silently losing health. No-op if + // this rock wasn't promoted (dust tier, or the promotion budget was full). + if (state.promoted) asteroidRuntime.promotion?.applyDent(globalIdx, impactPoint, amount); + return { tier: 'none', debrisSpawned: [] }; } const overkillFrac = -state.health / state.maxHealth; diff --git a/src/systems/asteroidGrid.test.ts b/src/systems/asteroidGrid.test.ts index f9655ec..e03d32d 100644 --- a/src/systems/asteroidGrid.test.ts +++ b/src/systems/asteroidGrid.test.ts @@ -18,6 +18,7 @@ function mkState(x: number, z: number, alive = true): AsteroidState { alive, indestructible: false, hitSeq: 0, + promoted: false, }; } diff --git a/src/systems/asteroidState.ts b/src/systems/asteroidState.ts index 8c10f6a..20d1d3b 100644 --- a/src/systems/asteroidState.ts +++ b/src/systems/asteroidState.ts @@ -33,6 +33,11 @@ export interface AsteroidState { * 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. */ @@ -66,6 +71,7 @@ export function buildAsteroidStates(count: number, seed: number = BELT_SEED): As alive: true, indestructible: false, hitSeq: 0, + promoted: false, }); } } diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts index eeaf13f..17caf00 100644 --- a/src/systems/debrisPhysics.test.ts +++ b/src/systems/debrisPhysics.test.ts @@ -73,6 +73,7 @@ describe('updateDebrisBodies', () => { alive: true, indestructible: false, hitSeq: 0, + promoted: false, }; asteroidRuntime.states = [state]; asteroidRuntime.grid = buildAsteroidGrid([state]); diff --git a/src/systems/quality.ts b/src/systems/quality.ts index f063590..2febbc1 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -48,6 +48,14 @@ export interface QualitySettings { debrisCullDistance: number; /** Max simultaneous mining impact-chip spark particles (cosmetic only). */ miningVfxBudget: 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 debris can bounce (vs. stick-and-expire) and cascade into + * secondary chip fragments on a hard-enough collision. */ + cascadeFractureEnabled: boolean; } export const QUALITY: Record = { @@ -79,6 +87,8 @@ export const QUALITY: Record = { debrisLifetimeSec: 0, debrisCullDistance: 0, miningVfxBudget: 0, + promotedAsteroidMax: 0, + cascadeFractureEnabled: false, }, medium: { planetSegments: 40, @@ -108,6 +118,8 @@ export const QUALITY: Record = { debrisLifetimeSec: 12, debrisCullDistance: 400, miningVfxBudget: 16, + promotedAsteroidMax: 8, + cascadeFractureEnabled: true, }, high: { planetSegments: 64, @@ -137,6 +149,8 @@ export const QUALITY: Record = { debrisLifetimeSec: 18, debrisCullDistance: 700, miningVfxBudget: 32, + promotedAsteroidMax: 16, + cascadeFractureEnabled: true, }, ultra: { planetSegments: 96, @@ -166,6 +180,8 @@ export const QUALITY: Record = { debrisLifetimeSec: 24, debrisCullDistance: 1000, miningVfxBudget: 48, + promotedAsteroidMax: 32, + cascadeFractureEnabled: true, }, }; From 2ef44ac8186b99acbb782a0565322922e87480f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:18:47 +0000 Subject: [PATCH 12/22] Add pattern-based fracture chunks and real fragment momentum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lethal hits no longer spawn generic jitter-only debris — they extract real clusters of the asteroid's own geometry (precomputed once per base shape via nearest-seed-point clustering of face centroids, cached, then extracted fresh from the current, possibly-already-dented source buffer at fracture time) so fragments visually read as pieces of the specific rock that broke, not stock rubble. Fragment count is clamped to the chosen pattern's chunk count so no two fragments share a shape. Fragment velocity is now real rigid-body momentum instead of jitter alone: parent linear velocity + parent angular velocity at the fragment's offset-from-center, plus the existing impact-direction/ force bias. Fragments also carry their own spin (quaternion + angular velocity, deterministically hashed, faster for smaller/faster ejecta) and debris rendering now composes that rotation instead of ignoring it entirely, which it did before this change. Falls back to the previous jitter-only spawn when no promotion API is registered (defensive; in practice always available once a belt is mounted) — keeps existing debris-count/ore-fraction test coverage valid unchanged. --- src/scene/AsteroidBelt.tsx | 22 ++- src/scene/AsteroidDebris.tsx | 7 +- src/scene/asteroidRuntime.ts | 24 ++- src/scene/debrisRuntime.ts | 16 +- src/systems/asteroidFracture.test.ts | 132 +++++++++++++- src/systems/asteroidFracture.ts | 168 +++++++++++++---- src/systems/asteroidFracturePatterns.test.ts | 131 ++++++++++++++ src/systems/asteroidFracturePatterns.ts | 181 +++++++++++++++++++ src/systems/debrisPhysics.test.ts | 4 +- src/systems/debrisPhysics.ts | 9 +- 10 files changed, 639 insertions(+), 55 deletions(-) create mode 100644 src/systems/asteroidFracturePatterns.test.ts create mode 100644 src/systems/asteroidFracturePatterns.ts diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index e20baa8..9191931 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -227,8 +227,26 @@ export function AsteroidBelt() { entry.mesh.worldToLocal(_dentLocalPoint); applyDentToGeometry(entry.geometry, _dentLocalPoint, amount); }, - getAngularVelocity: (globalIdx: number): Vector3 | null => { - return built.promoted.get(globalIdx)?.angVel ?? null; + 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; }, }; return () => { diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx index 30a4ed0..d48ddcc 100644 --- a/src/scene/AsteroidDebris.tsx +++ b/src/scene/AsteroidDebris.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; import { useFrame } from '@react-three/fiber'; -import { InstancedMesh, MeshStandardMaterial, Matrix4, Color, type Material } from 'three/webgpu'; +import { InstancedMesh, MeshStandardMaterial, Matrix4, Vector3, Color, type Material } from 'three/webgpu'; import { useStore } from '../store'; import { QUALITY } from '../systems/quality'; import { updateDebrisBodies } from '../systems/debrisPhysics'; @@ -9,6 +9,7 @@ import { shipTelemetry } from '../ship/shipTelemetry'; import { rockGeometry } from './rockGeometry'; const _m = new Matrix4(); +const _scale = new Vector3(); const _zero = new Matrix4().makeScale(0, 0, 0); const _rockColor = new Color(0.4, 0.37, 0.33); const _oreColor = new Color(0.75, 0.62, 0.25); @@ -73,8 +74,8 @@ export function AsteroidDebris() { for (let i = 0; i < q.debrisMax; i++) { const d = list[i]; if (d) { - _m.makeScale(d.radius, d.radius, d.radius); - _m.setPosition(d.pos.x, d.pos.y, d.pos.z); + _scale.set(d.radius, d.radius, d.radius); + _m.compose(d.pos, d.quat, _scale); inst.setMatrixAt(i, _m); inst.setColorAt(i, d.isOre ? _oreColor : _rockColor); } else { diff --git a/src/scene/asteroidRuntime.ts b/src/scene/asteroidRuntime.ts index f282ea3..5127f13 100644 --- a/src/scene/asteroidRuntime.ts +++ b/src/scene/asteroidRuntime.ts @@ -4,10 +4,21 @@ // 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 { Vector3 } from 'three'; +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`, @@ -20,9 +31,14 @@ export interface AsteroidPromotionApi { /** 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; - /** The promoted asteroid's angular velocity (rad/s, world-space axis*rate) - * for the momentum formula in fracture — null if not promoted. */ - getAngularVelocity: (globalIdx: number) => Vector3 | null; + /** 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; } export const asteroidRuntime: { diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts index 9d38062..a05a502 100644 --- a/src/scene/debrisRuntime.ts +++ b/src/scene/debrisRuntime.ts @@ -5,16 +5,23 @@ // this list every frame; `asteroidFracture.ts` and (eventually) the mining // system are the only writers via `spawn()`. -import { Vector3 } from 'three/webgpu'; +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; } export interface DebrisSpawnSpec { @@ -23,6 +30,10 @@ export interface DebrisSpawnSpec { radius: number; isOre?: boolean; resourceType?: ResourceType; + /** Defaults to identity/zero if omitted (e.g. cosmetic-only callers). */ + quat?: Quaternion; + angVel?: Vector3; + cascadeDepth?: number; } export const debrisRuntime: { @@ -44,10 +55,13 @@ export const debrisRuntime: { 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: 0, isOre: spec.isOre ?? false, resourceType: spec.resourceType, + cascadeDepth: spec.cascadeDepth ?? 0, }); }, }; diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts index 23652e6..eaf38c6 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { Vector3 } from 'three'; +import { IcosahedronGeometry, Quaternion, Vector3 } from 'three'; import { applyAsteroidDamage } from './asteroidFracture'; -import { asteroidRuntime } from '../scene/asteroidRuntime'; +import { getFracturePatterns, pickPattern } from './asteroidFracturePatterns'; +import { asteroidRuntime, type AsteroidMomentumInputs } from '../scene/asteroidRuntime'; import { debrisRuntime } from '../scene/debrisRuntime'; import type { AsteroidState } from './asteroidState'; @@ -134,7 +135,8 @@ describe('applyAsteroidDamage', () => { return true; }, applyDent: () => {}, - getAngularVelocity: () => null, + getMomentumInputs: () => null, + getSourceGeometry: () => null, }; applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -158,7 +160,8 @@ describe('applyAsteroidDamage', () => { expect(point).toBe(IMPACT_POINT); expect(amount).toBe(2); }, - getAngularVelocity: () => null, + getMomentumInputs: () => null, + getSourceGeometry: () => null, }; const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -176,7 +179,8 @@ describe('applyAsteroidDamage', () => { applyDent: () => { dentCalls += 1; }, - getAngularVelocity: () => null, + getMomentumInputs: () => null, + getSourceGeometry: () => null, }; applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -195,3 +199,121 @@ describe('applyAsteroidDamage', () => { 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.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, + }; + + 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, + }; + + 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); + } + }); + + 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, + }; + 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, + }; + 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('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, + }; + + 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 index fd453d7..56eb92a 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -1,16 +1,21 @@ // 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 (no debris), a hit that finishes it off spawns a handful of -// independent debris fragments, and a hit that finishes it off with a lot of -// spare force spawns more, larger fragments. Debris never recursively -// fractures (hard budget stop) — this is the one damage pipeline both +// 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 { Vector3 } from 'three'; +import { Quaternion, Vector3 } from 'three'; import { cellHash } from '../voxel/noise'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { debrisRuntime, type DebrisSpawnSpec } from '../scene/debrisRuntime'; +import { getFracturePatterns, pickPattern, pickClusterIndices, computeClusterCentroid } from './asteroidFracturePatterns'; import type { ResourceType } from '../voxel/voxelTypes'; export type FractureTier = 'none' | 'low' | 'medium' | 'high'; @@ -38,9 +43,69 @@ const ORE_TYPES: ResourceType[] = ['iron', 'silicon', 'titanite', 'hematite', 'l * 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; +/** 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 _jitter = new Vector3(); +const _centroid = new Vector3(); +const _worldOffset = new Vector3(); +const _angVelCross = new Vector3(); +const _fragVel = new Vector3(); +const _fragQuat = new Quaternion(); + +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` @@ -93,42 +158,71 @@ export function applyAsteroidDamage( const tier: FractureTier = overkillFrac >= HIGH_OVERKILL_FRAC ? 'high' : 'medium'; const [minN, maxN] = tier === 'high' ? HIGH_DEBRIS_RANGE : MEDIUM_DEBRIS_RANGE; - // Deterministic fragment count/directions from a hash of this specific - // hit — a per-asteroid hit counter (not Math.random()) so repeated hits on - // the same rock don't collide on identical hash inputs while staying - // reproducible within the session. state.hitSeq += 1; 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[] = []; - for (let k = 0; k < count; k++) { - const stream = state.hitSeq * 100 + k; - const theta = cellHash(globalIdx, stream, state.seed + 6001) * Math.PI * 2; - const phi = Math.acos(cellHash(globalIdx, stream, state.seed + 6002) * 2 - 1); - _jitter.set(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi)); - - // Outward "explosion" component scaled by damage, biased toward the - // impact direction — narratively plausible, not rigorously simulated. - const outwardSpeed = 2 + speed * 0.4; - const vel = _jitter - .clone() - .multiplyScalar(outwardSpeed) - .addScaledVector(_dir, speed * 0.3); - - // 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, state.seed + 6003) * 0.35; - const fragRadius = state.radius * fragFrac; - const pos = impactPoint.clone().addScaledVector(_jitter, fragRadius * 0.5); - - const oreHash = cellHash(globalIdx, stream, state.seed + 6004); - const isOre = oreHash < ORE_FRACTION; - const resourceType = isOre - ? ORE_TYPES[Math.floor(cellHash(globalIdx, stream, state.seed + 6005) * ORE_TYPES.length)] - : undefined; - - debrisSpawned.push({ pos, vel, radius: fragRadius, isOre, resourceType }); + + 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 pattern = pickPattern(patterns, globalIdx, state.hitSeq, state.seed); + const clusterIndices = pickClusterIndices(pattern, count, globalIdx, state.hitSeq, state.seed); + + for (let k = 0; k < clusterIndices.length; k++) { + const stream = state.hitSeq * 100 + k; + const shared = computeSharedFragmentProps(globalIdx, stream, state.seed, speed, state.radius); + + computeClusterCentroid(geometry, pattern, clusterIndices[k], _centroid); + _worldOffset.copy(_centroid).multiply(momentum.scale).applyQuaternion(momentum.quat); + + _fragVel + .copy(state.vel) + .add(_angVelCross.copy(momentum.angVel).cross(_worldOffset)) + .addScaledVector(shared.jitter, shared.outwardSpeed) + .addScaledVector(_dir, speed * 0.3); + + const pos = state.pos.clone().add(_worldOffset); + _fragQuat.copy(momentum.quat); // fragment starts oriented like the parent it broke from + + debrisSpawned.push({ + pos, + vel: _fragVel.clone(), + radius: shared.fragRadius, + isOre: shared.isOre, + resourceType: shared.resourceType, + angVel: shared.angVel, + quat: _fragQuat.clone(), + cascadeDepth: 0, + }); + } + } 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, speed, state.radius); + const vel = shared.jitter.clone().multiplyScalar(shared.outwardSpeed).addScaledVector(_dir, speed * 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); 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..afaf4a5 --- /dev/null +++ b/src/systems/asteroidFracturePatterns.ts @@ -0,0 +1,181 @@ +// 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 selection for a specific hit — matches the existing + * `cellHash`-based hashing convention used throughout the fracture system. */ +export function pickPattern(patterns: FracturePattern[], globalIdx: number, hitSeq: number, seed: number): FracturePattern { + const idx = cellHash(globalIdx, hitSeq, seed + 7000) < 0.5 ? 0 : 1; + return patterns[Math.min(idx, patterns.length - 1)]; +} + +/** 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/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts index 17caf00..225cec0 100644 --- a/src/systems/debrisPhysics.test.ts +++ b/src/systems/debrisPhysics.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { Vector3 } from 'three'; +import { Quaternion, Vector3 } from 'three'; import { updateDebrisBodies } from './debrisPhysics'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { buildAsteroidGrid } from './asteroidGrid'; @@ -18,7 +18,7 @@ function mercuryCenter(): Vector3 { } function mkDebris(pos: Vector3, vel = new Vector3(), radius = 0.3): DebrisBody { - return { pos, vel, radius, life: 0, isOre: false }; + return { pos, vel, radius, life: 0, isOre: false, quat: new Quaternion(), angVel: new Vector3(), cascadeDepth: 0 }; } describe('updateDebrisBodies', () => { diff --git a/src/systems/debrisPhysics.ts b/src/systems/debrisPhysics.ts index b81ab84..d300692 100644 --- a/src/systems/debrisPhysics.ts +++ b/src/systems/debrisPhysics.ts @@ -12,7 +12,7 @@ // debris-vs-debris pass is cheap, while debris-vs-asteroid reuses the belt's // existing spatial grid (population there can be thousands). -import { Vector3 } from 'three'; +import { Quaternion, Vector3 } from 'three'; import { integrate, DEBRIS_DAMPING } from '../ship/shipPhysics'; import { sphereVsPlanets, rotateY } from '../ship/shipCollision'; import { asteroidRuntime } from '../scene/asteroidRuntime'; @@ -24,6 +24,8 @@ const MAX_ASTEROID_RADIUS = Math.max(...TIERS.map((t) => t.max)); const _localPos = new Vector3(); const ZERO_ACCEL = new Vector3(0, 0, 0); +const _spinAxis = new Vector3(); +const _spinDeltaQ = new Quaternion(); /** True if `debris` overlaps a live asteroid in the belt's spatial grid * (converts world position into the belt-local frame the grid is indexed @@ -64,6 +66,11 @@ export function updateDebrisBodies( 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); + } // Any contact sticks-and-expires — no continued bouncing. if (sphereVsPlanets(d.pos, d.vel, simTimeDays, d.radius, 0)) expired = true; else if (hitsAsteroid(d)) expired = true; From 16d712a5395b234a7da3dc068e1b0c631fc44ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:17:50 +0000 Subject: [PATCH 13/22] Replace debris stick-and-expire collision with real bounce + cascade chips Debris now reflects off planets/asteroids/other debris (reflectSphereContact, a real bounce with restitution + tangential energy bleed) instead of disappearing on first contact. A hard-enough hit chips 1-2 small secondary fragments off a cascadeDepth:0 fragment, capped to exactly one extra level so population growth stays bounded. --- src/ship/shipCollision.ts | 78 ++++++++++++ src/systems/debrisPhysics.test.ts | 51 ++++++-- src/systems/debrisPhysics.ts | 192 ++++++++++++++++++++++++------ 3 files changed, 276 insertions(+), 45 deletions(-) diff --git a/src/ship/shipCollision.ts b/src/ship/shipCollision.ts index 75ca6d6..4c5adac 100644 --- a/src/ship/shipCollision.ts +++ b/src/ship/shipCollision.ts @@ -97,6 +97,84 @@ export function resolvePlanetCollision( 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. */ diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts index 225cec0..7522eeb 100644 --- a/src/systems/debrisPhysics.test.ts +++ b/src/systems/debrisPhysics.test.ts @@ -3,7 +3,7 @@ import { Quaternion, Vector3 } from 'three'; import { updateDebrisBodies } from './debrisPhysics'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { buildAsteroidGrid } from './asteroidGrid'; -import type { DebrisBody } from '../scene/debrisRuntime'; +import { debrisRuntime, type DebrisBody } from '../scene/debrisRuntime'; import type { AsteroidState } from './asteroidState'; import { PLANETS } from './bodies'; import { positionAtTime } from './ephemeris'; @@ -26,6 +26,8 @@ describe('updateDebrisBodies', () => { asteroidRuntime.states = []; asteroidRuntime.grid = null; asteroidRuntime.groupYaw = 0; + debrisRuntime.list = []; + debrisRuntime.maxCount = 0; }); it('integrates a free-drifting fragment forward each frame', () => { @@ -51,15 +53,16 @@ describe('updateDebrisBodies', () => { expect(list).toHaveLength(0); }); - it('sticks and expires on planet contact rather than bouncing', () => { + 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(0); + expect(list).toHaveLength(1); + expect(d.vel.x).toBeGreaterThan(0); // reflected away from the surface }); - it('sticks and expires on asteroid contact', () => { + it('bounces off an asteroid instead of sticking', () => { const state: AsteroidState = { tierIdx: 0, variantIdx: 0, @@ -79,17 +82,45 @@ describe('updateDebrisBodies', () => { asteroidRuntime.grid = buildAsteroidGrid([state]); asteroidRuntime.groupYaw = 0; - const d = mkDebris(new Vector3(52, 0, 0)); // overlapping the asteroid (radius 2 + debris 0.3) + 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(0); + expect(list).toHaveLength(1); + expect(d.vel.x).toBeGreaterThan(0); // reflected away from the surface }); - it('sticks and expires on debris-vs-debris contact', () => { - const a = mkDebris(new Vector3(0, 0, 0), new Vector3(), 1); - const b = mkDebris(new Vector3(1, 0, 0), new Vector3(), 1); // overlapping (radii sum to 2 > distance 1) + 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.length).toBeLessThan(2); + 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 }); }); diff --git a/src/systems/debrisPhysics.ts b/src/systems/debrisPhysics.ts index d300692..de7457c 100644 --- a/src/systems/debrisPhysics.ts +++ b/src/systems/debrisPhysics.ts @@ -4,51 +4,162 @@ // ship uses (reused, not duplicated), with a debris-specific damping and no // thrust/rotation input. // -// On any contact (planet, asteroid, other debris) a fragment sticks and -// expires rather than continuing to bounce — bounded and simple, per the -// brief's "avoid overcomplicating physics" — so this module never needs a -// second broadphase for the general case: 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). +// 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 { sphereVsPlanets, rotateY } from '../ship/shipCollision'; +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'; -import type { DebrisBody } from '../scene/debrisRuntime'; 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`). */ +let _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)); -/** True if `debris` overlaps a live asteroid in the belt's spatial grid - * (converts world position into the belt-local frame the grid is indexed - * in, same convention as `resolveAsteroidCollision`). */ -function hitsAsteroid(debris: DebrisBody): boolean { + _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 false; - rotateY(debris.pos, -asteroidRuntime.groupYaw, _localPos); + 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 minDist = s.radius + debris.radius; - if (_localPos.distanceToSquared(s.pos) < minDist * minDist) return true; + const speed = reflectSphereContact( + _localPos, + _localVel, + s.pos, + s.radius, + debris.radius, + DEBRIS_RESTITUTION, + DEBRIS_TANGENTIAL_FRICTION, + ); + if (speed !== null) maxClosing = Math.max(maxClosing, speed); } - return false; + + 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. Expired (impacted, - * timed-out, or culled-by-distance) fragments are 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). + * 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[], @@ -58,11 +169,14 @@ export function updateDebrisBodies( 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; - let expired = d.life >= maxLifeSec || d.pos.distanceTo(shipPos) > cullDistance; + const expired = d.life >= maxLifeSec || d.pos.distanceTo(shipPos) > cullDistance; if (!expired) { integrate(d.pos, d.vel, ZERO_ACCEL, DEBRIS_DAMPING, dt); @@ -71,20 +185,28 @@ export function updateDebrisBodies( _spinDeltaQ.setFromAxisAngle(_spinAxis, d.angVel.length() * dt); d.quat.multiply(_spinDeltaQ); } - // Any contact sticks-and-expires — no continued bouncing. - if (sphereVsPlanets(d.pos, d.vel, simTimeDays, d.radius, 0)) expired = true; - else if (hitsAsteroid(d)) expired = true; - else { - for (let j = 0; j < list.length; j++) { - if (j === i) continue; - const other = list[j]; - const minDist = d.radius + other.radius; - if (d.pos.distanceToSquared(other.pos) < minDist * minDist) { - expired = true; - break; - } + + 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 (d.cascadeDepth === 0 && closing > SECONDARY_FRACTURE_SPEED) { + spawnCascadeChips(d); + } } if (expired) { From e25b922143f297d3d0b979353c39b23a20aafea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:24:14 +0000 Subject: [PATCH 14/22] Render debris fragments in per-shape buckets instead of one generic rock Pattern-based fragments now carry a shapeKey (tier/variant/pattern/cluster) threaded through from asteroidFracture.ts; AsteroidDebris.tsx renders each distinct shape in its own lazily-built InstancedMesh bucket (extracted from the belt's shared base geometry) so a fragment visually reads as the actual chunk it broke off, falling back to a generic rock bucket for jitter-only fragments and cascade chips. --- src/scene/AsteroidBelt.tsx | 1 + src/scene/AsteroidDebris.tsx | 138 ++++++++++++++++++------ src/scene/asteroidRuntime.ts | 5 + src/scene/debrisRuntime.ts | 15 +++ src/systems/asteroidFracture.test.ts | 15 +++ src/systems/asteroidFracture.ts | 11 +- src/systems/asteroidFracturePatterns.ts | 15 ++- 7 files changed, 159 insertions(+), 41 deletions(-) diff --git a/src/scene/AsteroidBelt.tsx b/src/scene/AsteroidBelt.tsx index 9191931..ada18c3 100644 --- a/src/scene/AsteroidBelt.tsx +++ b/src/scene/AsteroidBelt.tsx @@ -248,6 +248,7 @@ export function AsteroidBelt() { 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 = []; diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx index d48ddcc..f68a24b 100644 --- a/src/scene/AsteroidDebris.tsx +++ b/src/scene/AsteroidDebris.tsx @@ -1,38 +1,83 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; -import { InstancedMesh, MeshStandardMaterial, Matrix4, Vector3, Color, type Material } from 'three/webgpu'; +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 } from './debrisRuntime'; +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 _zero = new Matrix4().makeScale(0, 0, 0); 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 shared InstancedMesh, hard-capped by - * `QUALITY[...].debrisMax`, following the exact swap-remove + scale-to-zero - * pooling pattern already used for voxel mining debris - * (`ChunkManager.tsx`'s `Debris[]`/`updateDebris`) — reuses the belt's - * cheapest (tier-0) rock geometry rather than building new GPU resources. + * 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 built = useMemo(() => { + const material = useMemo(() => { if (q.debrisMax === 0) return null; - const geometry = rockGeometry(0, 999); - const material = new MeshStandardMaterial({ color: 0xffffff, roughness: 1, metalness: 0 }); - const inst = new InstancedMesh(geometry, material, q.debrisMax); - inst.frustumCulled = false; - return { inst, geometry, material }; + return new MeshStandardMaterial({ color: 0xffffff, roughness: 1, metalness: 0 }); }, [q.debrisMax]); // Publish current quality budgets to the runtime singleton and drop any @@ -46,17 +91,22 @@ export function AsteroidDebris() { } }, [q.debrisMax, q.debrisLifetimeSec, q.debrisCullDistance]); + // 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(() => { return () => { - if (!built) return; - built.inst.dispose(); - built.geometry.dispose(); - (built.material as Material).dispose(); + bucketsRef.current.forEach((b) => { + group.current?.remove(b.inst); + b.inst.dispose(); + b.geometry.dispose(); + }); + bucketsRef.current.clear(); + material?.dispose(); }; - }, [built]); + }, [material]); useFrame((_, delta) => { - if (!built) return; + if (!material || !group.current) return; if (sceneModeType !== 'piloting') return; const dt = Math.min(delta, 0.05); const store = useStore.getState(); @@ -69,23 +119,41 @@ export function AsteroidDebris() { debrisRuntime.cullDistance, ); + const buckets = bucketsRef.current; + buckets.forEach((b) => { + b.frameCount = 0; + }); + const list = debrisRuntime.list; - const inst = built.inst; - for (let i = 0; i < q.debrisMax; i++) { + for (let i = 0; i < list.length; i++) { const d = list[i]; - if (d) { - _scale.set(d.radius, d.radius, d.radius); - _m.compose(d.pos, d.quat, _scale); - inst.setMatrixAt(i, _m); - inst.setColorAt(i, d.isOre ? _oreColor : _rockColor); - } else { - inst.setMatrixAt(i, _zero); + 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); } - inst.instanceMatrix.needsUpdate = true; - if (inst.instanceColor) inst.instanceColor.needsUpdate = true; + + buckets.forEach((b) => { + b.inst.count = b.frameCount; + b.inst.instanceMatrix.needsUpdate = true; + if (b.inst.instanceColor) b.inst.instanceColor.needsUpdate = true; + }); }); - if (!built) return null; - return ; + if (!material) return null; + return ; } diff --git a/src/scene/asteroidRuntime.ts b/src/scene/asteroidRuntime.ts index 5127f13..75d5b1a 100644 --- a/src/scene/asteroidRuntime.ts +++ b/src/scene/asteroidRuntime.ts @@ -39,6 +39,11 @@ export interface AsteroidPromotionApi { * 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: { diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts index a05a502..1126e6c 100644 --- a/src/scene/debrisRuntime.ts +++ b/src/scene/debrisRuntime.ts @@ -22,6 +22,19 @@ export interface DebrisBody { * 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 { @@ -34,6 +47,7 @@ export interface DebrisSpawnSpec { quat?: Quaternion; angVel?: Vector3; cascadeDepth?: number; + shapeKey?: DebrisShapeKey; } export const debrisRuntime: { @@ -62,6 +76,7 @@ export const debrisRuntime: { isOre: spec.isOre ?? false, resourceType: spec.resourceType, cascadeDepth: spec.cascadeDepth ?? 0, + shapeKey: spec.shapeKey, }); }, }; diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts index eaf38c6..82442e9 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -137,6 +137,7 @@ describe('applyAsteroidDamage', () => { applyDent: () => {}, getMomentumInputs: () => null, getSourceGeometry: () => null, + getBaseGeometry: () => null, }; applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -162,6 +163,7 @@ describe('applyAsteroidDamage', () => { }, getMomentumInputs: () => null, getSourceGeometry: () => null, + getBaseGeometry: () => null, }; const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -181,6 +183,7 @@ describe('applyAsteroidDamage', () => { }, getMomentumInputs: () => null, getSourceGeometry: () => null, + getBaseGeometry: () => null, }; applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); @@ -228,6 +231,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { applyDent: () => {}, getMomentumInputs: () => mkMomentum(), getSourceGeometry: () => geom, + getBaseGeometry: () => null, }; const patterns = getFracturePatterns(state.tierIdx, state.variantIdx, geom); @@ -249,6 +253,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { applyDent: () => {}, getMomentumInputs: () => mkMomentum(), getSourceGeometry: () => geom, + getBaseGeometry: () => null, }; const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); @@ -256,6 +261,13 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { 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), + }); } }); @@ -270,6 +282,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { applyDent: () => {}, getMomentumInputs: () => mkMomentum({ angVel: new Vector3(0, 0, 0) }), getSourceGeometry: () => geom, + getBaseGeometry: () => null, }; const resultNoSpin = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); @@ -280,6 +293,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { 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); @@ -304,6 +318,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { applyDent: () => {}, getMomentumInputs: () => mkMomentum({ scale: new Vector3(5, 5, 5) }), // large parent getSourceGeometry: () => geom, + getBaseGeometry: () => null, }; const result = applyAsteroidDamage(0, 35, IMPACT_POINT, IMPACT_VEL); diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index 56eb92a..76c299f 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -15,7 +15,12 @@ import { Quaternion, Vector3 } from 'three'; import { cellHash } from '../voxel/noise'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { debrisRuntime, type DebrisSpawnSpec } from '../scene/debrisRuntime'; -import { getFracturePatterns, pickPattern, pickClusterIndices, computeClusterCentroid } from './asteroidFracturePatterns'; +import { + getFracturePatterns, + pickPatternIndex, + pickClusterIndices, + computeClusterCentroid, +} from './asteroidFracturePatterns'; import type { ResourceType } from '../voxel/voxelTypes'; export type FractureTier = 'none' | 'low' | 'medium' | 'high'; @@ -174,7 +179,8 @@ export function applyAsteroidDamage( // velocity at the fragment's offset-from-center, plus the existing // impact-driven ejection terms. const patterns = getFracturePatterns(state.tierIdx, state.variantIdx, geometry); - const pattern = pickPattern(patterns, globalIdx, state.hitSeq, state.seed); + const patternIdx = pickPatternIndex(patterns, globalIdx, state.hitSeq, state.seed); + const pattern = patterns[patternIdx]; const clusterIndices = pickClusterIndices(pattern, count, globalIdx, state.hitSeq, state.seed); for (let k = 0; k < clusterIndices.length; k++) { @@ -202,6 +208,7 @@ export function applyAsteroidDamage( angVel: shared.angVel, quat: _fragQuat.clone(), cascadeDepth: 0, + shapeKey: { tierIdx: state.tierIdx, variantIdx: state.variantIdx, patternIdx, clusterIdx: clusterIndices[k] }, }); } } else { diff --git a/src/systems/asteroidFracturePatterns.ts b/src/systems/asteroidFracturePatterns.ts index afaf4a5..6e4c77a 100644 --- a/src/systems/asteroidFracturePatterns.ts +++ b/src/systems/asteroidFracturePatterns.ts @@ -97,11 +97,18 @@ export function getFracturePatterns(tierIdx: number, variantIdx: number, geometr return patterns; } -/** Deterministic pattern selection for a specific hit — matches the existing - * `cellHash`-based hashing convention used throughout the fracture system. */ -export function pickPattern(patterns: FracturePattern[], globalIdx: number, hitSeq: number, seed: number): FracturePattern { +/** 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 patterns[Math.min(idx, patterns.length - 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 From b57339c145bf370212dc67a2b6f1cfbf22878593 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:26:23 +0000 Subject: [PATCH 15/22] Fix lint findings from the cascade/bucketing pass const over let for a never-reassigned array, and copy ref.current into a local before using it inside an effect's cleanup closure. --- src/scene/AsteroidDebris.tsx | 8 +++++--- src/systems/debrisPhysics.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx index f68a24b..e93b2e9 100644 --- a/src/scene/AsteroidDebris.tsx +++ b/src/scene/AsteroidDebris.tsx @@ -94,13 +94,15 @@ export function AsteroidDebris() { // 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 () => { - bucketsRef.current.forEach((b) => { - group.current?.remove(b.inst); + buckets.forEach((b) => { + g?.remove(b.inst); b.inst.dispose(); b.geometry.dispose(); }); - bucketsRef.current.clear(); + buckets.clear(); material?.dispose(); }; }, [material]); diff --git a/src/systems/debrisPhysics.ts b/src/systems/debrisPhysics.ts index de7457c..66561ad 100644 --- a/src/systems/debrisPhysics.ts +++ b/src/systems/debrisPhysics.ts @@ -56,7 +56,7 @@ 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`). */ -let _pairClosing: number[] = []; +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 From 100725a951dcfcd04e92cee6f53882c7d8510819 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:57:43 +0000 Subject: [PATCH 16/22] Fix mining/debris invisible on low quality: zero-budget tier disabled it entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUALITY.low had asteroids/debrisMax/miningVfxBudget all at 0, so on any device that auto-detects 'low' (mobile, <=4 cores/4GB, or no WebGPU) the entire mining/asteroid system was silently absent — no asteroids to shoot, no sparks, no debris, matching the reported "no visible shots and no debris." Gave low a modest nonzero budget for all three so mining stays a functional gameplay loop on every tier, and bumped the shot-flash duration slightly (0.06s -> 0.1s) for perceptibility. Also wired up cascadeFractureEnabled, which was defined and documented in quality.ts but never actually read anywhere — a dead config field from the earlier cascade-bounce work. debrisRuntime now carries a cascadeEnabled flag that gates secondary-fracture chip spawning (debris still always bounces; this only caps the extra population growth on low-end tiers). --- src/scene/AsteroidDebris.tsx | 3 ++- src/scene/SpaceMiningController.tsx | 2 +- src/scene/debrisRuntime.ts | 7 +++++++ src/systems/debrisPhysics.test.ts | 15 +++++++++++++++ src/systems/debrisPhysics.ts | 2 +- src/systems/quality.ts | 15 ++++++++------- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/scene/AsteroidDebris.tsx b/src/scene/AsteroidDebris.tsx index e93b2e9..4ec3551 100644 --- a/src/scene/AsteroidDebris.tsx +++ b/src/scene/AsteroidDebris.tsx @@ -86,10 +86,11 @@ export function AsteroidDebris() { 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.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. diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index 014701a..188c319 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -31,7 +31,7 @@ const ORE_YIELD = 1; /** How long the beam flash + chip burst stay visible per shot — short enough * to read as a rapid string of discrete shots rather than a sustained beam. */ -const FLASH_DURATION = 0.06; +const FLASH_DURATION = 0.1; const SPARKS_PER_SHOT = 4; const FIRE_INTERVAL = 1 / FIRE_RATE; diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts index 1126e6c..13e2e23 100644 --- a/src/scene/debrisRuntime.ts +++ b/src/scene/debrisRuntime.ts @@ -58,12 +58,19 @@ export const debrisRuntime: { 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({ diff --git a/src/systems/debrisPhysics.test.ts b/src/systems/debrisPhysics.test.ts index 7522eeb..77c8893 100644 --- a/src/systems/debrisPhysics.test.ts +++ b/src/systems/debrisPhysics.test.ts @@ -28,6 +28,7 @@ describe('updateDebrisBodies', () => { asteroidRuntime.groupYaw = 0; debrisRuntime.list = []; debrisRuntime.maxCount = 0; + debrisRuntime.cascadeEnabled = true; }); it('integrates a free-drifting fragment forward each frame', () => { @@ -123,4 +124,18 @@ describe('updateDebrisBodies', () => { 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 index 66561ad..0bb7a94 100644 --- a/src/systems/debrisPhysics.ts +++ b/src/systems/debrisPhysics.ts @@ -204,7 +204,7 @@ export function updateDebrisBodies( } } - if (d.cascadeDepth === 0 && closing > SECONDARY_FRACTURE_SPEED) { + if (debrisRuntime.cascadeEnabled && d.cascadeDepth === 0 && closing > SECONDARY_FRACTURE_SPEED) { spawnCascadeChips(d); } } diff --git a/src/systems/quality.ts b/src/systems/quality.ts index 2febbc1..eaa81f2 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -53,8 +53,9 @@ export interface QualitySettings { * damage. Small regardless of total asteroid count: each is a real draw * call plus potential per-vertex dent work. */ promotedAsteroidMax: number; - /** Whether debris can bounce (vs. stick-and-expire) and cascade into - * secondary chip fragments on a hard-enough collision. */ + /** 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; } @@ -66,7 +67,7 @@ export const QUALITY: Record = { atmosphereSegments: 24, stars: 600, solarWind: 300, - asteroids: 0, + asteroids: 500, dprMax: 1, bloomStrength: 0, bloomRadius: 0.4, @@ -83,10 +84,10 @@ export const QUALITY: Record = { voxelScatter: 120, voxelTrees: 60, shipTrailParticles: 0, - debrisMax: 0, - debrisLifetimeSec: 0, - debrisCullDistance: 0, - miningVfxBudget: 0, + debrisMax: 12, + debrisLifetimeSec: 8, + debrisCullDistance: 250, + miningVfxBudget: 8, promotedAsteroidMax: 0, cascadeFractureEnabled: false, }, From 803ea1cf33e2be2b9b6fc96c8f67dd3d44e4d017 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:53:10 +0000 Subject: [PATCH 17/22] Lower asteroid health so kills land reliably instead of only ever denting Verified via live-instrumented testing against the running app (not just code reading) that the damage -> fracture -> debris -> render pipeline itself works correctly end-to-end: a lethal hit properly kills the asteroid and spawns real, well-formed fragments that get mounted for rendering with no errors. The reported "asteroid changes shape but no debris ever breaks off" is explained by health tuning, not a rendering bug: the old curve (8 + r*18) made bigger, more visibly-denting rocks take up to ~5.6s of perfectly concentrated fire to kill. In a dense belt it's easy for aim to drift onto a neighboring rock between shots, spreading damage thin across many asteroids (visible dents) without ever finishing one off (no debris). Retuned to 5 + r*10 so a realistic couple-second burst reliably kills even the largest tier-2 rocks. --- src/systems/asteroidState.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/systems/asteroidState.ts b/src/systems/asteroidState.ts index 20d1d3b..9d73ce3 100644 --- a/src/systems/asteroidState.ts +++ b/src/systems/asteroidState.ts @@ -40,9 +40,15 @@ export interface AsteroidState { promoted: boolean; } -/** Bigger rocks take more hits to fracture — scales with bounding radius. */ +/** 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 8 + radius * 18; + return 5 + radius * 10; } /** From 149915a218a0cc25b9c207274c0c9265d8a9f332 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:12:48 +0000 Subject: [PATCH 18/22] Replace hairline Line beam flash with a real-width cylinder mesh Verified live (headless-browser instrumentation against the running dev build) that the beam's fire/flash logic was already 100% correct - visible ~60% of frames at the intended duty cycle, always attached to the scene - but a three.js Line is a 1px hairline regardless of the `linewidth` material property in WebGL/WebGPU, which reads as imperceptible at a 0.1s flash against a starfield, especially on a phone. Swapped it for a thin CylinderGeometry mesh (oriented via quaternion, scaled to the shot's length) so the tracer has real, guaranteed on-screen width. --- src/scene/SpaceMiningController.tsx | 43 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index 188c319..9915305 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; -import { Vector3, BufferGeometry, BufferAttribute, Line, LineBasicMaterial, AdditiveBlending } from 'three/webgpu'; +import { Vector3, Quaternion, CylinderGeometry, Mesh, MeshBasicMaterial, AdditiveBlending } from 'three/webgpu'; import { useStore } from '../store'; import { shipTelemetry } from '../ship/shipTelemetry'; import { @@ -34,11 +34,19 @@ const ORE_YIELD = 1; const FLASH_DURATION = 0.1; const SPARKS_PER_SHOT = 4; const FIRE_INTERVAL = 1 / FIRE_RATE; +/** Tracer bolt radius (world units) — a `Line` is a hairline in WebGL/WebGPU + * (browsers don't honor `linewidth` beyond 1px), which reads as invisible + * at a 0.1s flash against a starfield. A real cylinder mesh guarantees + * actual on-screen width regardless of backend. */ +const BEAM_RADIUS = 0.08; const _origin = new Vector3(); const _dir = new Vector3(); const _impactVel = new Vector3(); const _toShip = new Vector3(); +const _mid = new Vector3(); +const _beamQuat = new Quaternion(); +const _beamUp = new Vector3(0, 1, 0); /** * Space mining/weapon system: aims a fixed screen-center ray (mirroring the @@ -61,13 +69,14 @@ export function SpaceMiningController() { return () => removeMiningInput(); }, []); - // Visual beam — a single line updated in place each frame, flashed on for - // FLASH_DURATION per shot rather than held continuously visible. No - // pooling needed: this is one object, not a population. + // Visual beam — a single thin cylinder mesh (not a `Line`, which is a + // hairline in WebGL/WebGPU regardless of `linewidth`) repositioned/rescaled + // in place each frame, flashed on for FLASH_DURATION per shot rather than + // held continuously visible. No pooling needed: this is one object, not a + // population. const beam = useMemo(() => { - const geometry = new BufferGeometry(); - geometry.setAttribute('position', new BufferAttribute(new Float32Array(6), 3)); - const material = new LineBasicMaterial({ + const geometry = new CylinderGeometry(BEAM_RADIUS, BEAM_RADIUS, 1, 6, 1, true); + const material = new MeshBasicMaterial({ color: 0xff8850, transparent: true, opacity: 0.9, @@ -75,16 +84,16 @@ export function SpaceMiningController() { depthWrite: false, toneMapped: false, }); - const line = new Line(geometry, material); - line.frustumCulled = false; - line.visible = false; - return line; + const mesh = new Mesh(geometry, material); + mesh.frustumCulled = false; + mesh.visible = false; + return mesh; }, []); useEffect(() => { return () => { beam.geometry.dispose(); - (beam.material as LineBasicMaterial).dispose(); + (beam.material as MeshBasicMaterial).dispose(); }; }, [beam]); @@ -145,10 +154,12 @@ export function SpaceMiningController() { beam.visible = flashTimer.current > 0; if (beam.visible) { const endPoint = hit ? hit.point : _origin.clone().addScaledVector(_dir, MINING_RANGE); - const posAttr = beam.geometry.attributes.position as BufferAttribute; - posAttr.setXYZ(0, _origin.x, _origin.y, _origin.z); - posAttr.setXYZ(1, endPoint.x, endPoint.y, endPoint.z); - posAttr.needsUpdate = true; + const length = _origin.distanceTo(endPoint); + _mid.copy(_origin).add(endPoint).multiplyScalar(0.5); + beam.position.copy(_mid); + beam.scale.set(1, length, 1); + _beamQuat.setFromUnitVectors(_beamUp, _dir); + beam.quaternion.copy(_beamQuat); } // Ore magnetism + collection — backward swap-remove, safe regardless of From 41a203aeffdc4f863f71aa7920ccf57367688c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:27:05 +0000 Subject: [PATCH 19/22] Replace instant hitscan mining with a real traveling projectile weapon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shots now physically originate from a weapon hardpoint on the ship (not the camera), travel through space at a finite speed (220 u/s + the ship's own velocity — real momentum transfer), and are resolved by a per-frame swept-segment collision test against asteroids/planets, so a fast bolt can't tunnel through a small target between frames. Damage, knockback, and debris still route through the exact same applyAsteroidDamage pipeline as before, now triggered at the projectile's true point of contact instead of an instant camera raycast. New: src/scene/projectileRuntime.ts (pooled runtime, same convention as debrisRuntime.ts), src/systems/projectilePhysics.ts (integrate + swept collision, unit tested including a tunneling-prevention case), src/scene/Projectiles.tsx (InstancedMesh render, budget-capped by the new QUALITY[...].projectileMax tier). Added raySphereHit/raycastPlanets as pure (non-mutating) primitives in shipCollision.ts for the projectile's planet-collision check, factored out of the existing sphere-contact math. The crosshair's continuous "is something targetable" aim-assist raycast is unchanged — only the weapon's actual hit resolution moved from instant camera-based to projectile-travel-based. SpaceMiningController.tsx no longer renders anything itself (the flash-beam mesh is gone, superseded by the traveling bolt); it now only owns fire cadence, hardpoint placement, launch audio, aim telemetry, and ore magnetism/collection. --- src/scene/Projectiles.tsx | 90 +++++++++++++++++++ src/scene/SolarSystem.tsx | 2 + src/scene/SpaceMiningController.tsx | 124 +++++++++---------------- src/scene/projectileRuntime.ts | 39 ++++++++ src/ship/shipCollision.test.ts | 53 ++++++++++- src/ship/shipCollision.ts | 50 +++++++++++ src/ship/spaceMining.ts | 35 +++++--- src/systems/projectilePhysics.test.ts | 125 ++++++++++++++++++++++++++ src/systems/projectilePhysics.ts | 79 ++++++++++++++++ src/systems/quality.ts | 8 ++ 10 files changed, 510 insertions(+), 95 deletions(-) create mode 100644 src/scene/Projectiles.tsx create mode 100644 src/scene/projectileRuntime.ts create mode 100644 src/systems/projectilePhysics.test.ts create mode 100644 src/systems/projectilePhysics.ts 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/SolarSystem.tsx b/src/scene/SolarSystem.tsx index c01bec7..e8a4f30 100644 --- a/src/scene/SolarSystem.tsx +++ b/src/scene/SolarSystem.tsx @@ -16,6 +16,7 @@ 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'; @@ -128,6 +129,7 @@ export function SolarSystem() { + {PLANETS.map((p) => ( diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index 9915305..e009303 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; -import { Vector3, Quaternion, CylinderGeometry, Mesh, MeshBasicMaterial, AdditiveBlending } from 'three/webgpu'; +import { Vector3 } from 'three/webgpu'; import { useStore } from '../store'; import { shipTelemetry } from '../ship/shipTelemetry'; import { @@ -10,13 +10,11 @@ import { isFiring, MINING_RANGE, FIRE_RATE, - SHOT_DAMAGE, - MINING_IMPACT_SPEED, + PROJECTILE_SPEED, } from '../ship/spaceMining'; import { spaceMiningTelemetry } from '../ship/spaceMiningTelemetry'; -import { applyAsteroidDamage } from '../systems/asteroidFracture'; +import { projectileRuntime } from './projectileRuntime'; import { debrisRuntime } from './debrisRuntime'; -import { miningSparkRuntime } from './miningSparkRuntime'; import { SHIP_COLLISION_RADIUS } from '../ship/shipPhysics'; import { audio } from '../audio/AudioManager'; @@ -29,35 +27,33 @@ const MAGNET_ACCEL = 40; const COLLECT_RANGE = SHIP_COLLISION_RADIUS + 0.5; const ORE_YIELD = 1; -/** How long the beam flash + chip burst stay visible per shot — short enough - * to read as a rapid string of discrete shots rather than a sustained beam. */ -const FLASH_DURATION = 0.1; -const SPARKS_PER_SHOT = 4; const FIRE_INTERVAL = 1 / FIRE_RATE; -/** Tracer bolt radius (world units) — a `Line` is a hairline in WebGL/WebGPU - * (browsers don't honor `linewidth` beyond 1px), which reads as invisible - * at a 0.1s flash against a starfield. A real cylinder mesh guarantees - * actual on-screen width regardless of backend. */ -const BEAM_RADIUS = 0.08; +/** 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 _impactVel = new Vector3(); +const _hardpointLocal = new Vector3(); +const _hardpointWorld = new Vector3(); +const _shotVel = new Vector3(); const _toShip = new Vector3(); -const _mid = new Vector3(); -const _beamQuat = new Quaternion(); -const _beamUp = new Vector3(0, 1, 0); /** * Space mining/weapon system: aims a fixed screen-center ray (mirroring the - * voxel mining crosshair convention) and fires discrete, automatic shots at - * a fixed cadence while the trigger is held (not a continuous beam) — - * each shot is hitscan but shows as a brief flash/tracer plus an impact-chip - * spark burst, and damages the targeted asteroid via the same - * `applyAsteroidDamage` pipeline collision uses (which also applies a - * knockback impulse to the asteroid regardless of whether the hit - * fractures it). Separately pulls + collects any ore-flagged debris that - * drifts near the ship. Runs its own `useFrame` slot, kept separate from + * 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. */ @@ -69,55 +65,27 @@ export function SpaceMiningController() { return () => removeMiningInput(); }, []); - // Visual beam — a single thin cylinder mesh (not a `Line`, which is a - // hairline in WebGL/WebGPU regardless of `linewidth`) repositioned/rescaled - // in place each frame, flashed on for FLASH_DURATION per shot rather than - // held continuously visible. No pooling needed: this is one object, not a - // population. - const beam = useMemo(() => { - const geometry = new CylinderGeometry(BEAM_RADIUS, BEAM_RADIUS, 1, 6, 1, true); - const material = new MeshBasicMaterial({ - color: 0xff8850, - transparent: true, - opacity: 0.9, - blending: AdditiveBlending, - depthWrite: false, - toneMapped: false, - }); - const mesh = new Mesh(geometry, material); - mesh.frustumCulled = false; - mesh.visible = false; - return mesh; - }, []); - - useEffect(() => { - return () => { - beam.geometry.dispose(); - (beam.material as MeshBasicMaterial).dispose(); - }; - }, [beam]); - - // Fire-rate accumulator + flash timer, plus edge detection so the first - // shot fires the instant the trigger is pulled rather than waiting a full - // interval (standard automatic-weapon feel). + // 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 flashTimer = useRef(0); - const fireShot = (hit: ReturnType) => { - audio.playMiningShot(hit !== null); - flashTimer.current = FLASH_DURATION; - if (hit) { - _impactVel.copy(_dir).multiplyScalar(MINING_IMPACT_SPEED); - applyAsteroidDamage(hit.globalIdx, SHOT_DAMAGE, hit.point, _impactVel); - miningSparkRuntime.spawn(hit.point, SPARKS_PER_SHOT); - } + const fireShot = () => { + // 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); + _shotVel.copy(shipTelemetry.velocity).addScaledVector(_dir, PROJECTILE_SPEED); + projectileRuntime.spawn({ pos: _hardpointWorld, vel: _shotVel }); }; useFrame((_, delta) => { const store = useStore.getState(); if (store.sceneMode.type !== 'piloting') { - beam.visible = false; wasFiring.current = false; fireAcc.current = 0; return; @@ -127,7 +95,9 @@ export function SpaceMiningController() { // 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). + // 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); @@ -143,25 +113,13 @@ export function SpaceMiningController() { fireAcc.current += dt; while (fireAcc.current >= FIRE_INTERVAL) { fireAcc.current -= FIRE_INTERVAL; - fireShot(hit); + fireShot(); } } else { wasFiring.current = false; fireAcc.current = 0; } - flashTimer.current = Math.max(0, flashTimer.current - dt); - beam.visible = flashTimer.current > 0; - if (beam.visible) { - const endPoint = hit ? hit.point : _origin.clone().addScaledVector(_dir, MINING_RANGE); - const length = _origin.distanceTo(endPoint); - _mid.copy(_origin).add(endPoint).multiplyScalar(0.5); - beam.position.copy(_mid); - beam.scale.set(1, length, 1); - _beamQuat.setFromUnitVectors(_beamUp, _dir); - beam.quaternion.copy(_beamQuat); - } - // 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 @@ -187,5 +145,5 @@ export function SpaceMiningController() { } }); - return ; + return null; } 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/ship/shipCollision.test.ts b/src/ship/shipCollision.test.ts index d9f9a58..312d474 100644 --- a/src/ship/shipCollision.test.ts +++ b/src/ship/shipCollision.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { Vector3 } from 'three'; -import { resolvePlanetCollision, resolveAsteroidCollision, TANGENTIAL_RETAIN } from './shipCollision'; +import { resolvePlanetCollision, resolveAsteroidCollision, raySphereHit, raycastPlanets, TANGENTIAL_RETAIN } from './shipCollision'; import { PLANETS } from '../systems/bodies'; import { positionAtTime } from '../systems/ephemeris'; import { asteroidRuntime } from '../scene/asteroidRuntime'; @@ -66,6 +66,57 @@ describe('resolvePlanetCollision', () => { }); }); +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, diff --git a/src/ship/shipCollision.ts b/src/ship/shipCollision.ts index 4c5adac..6ccbd08 100644 --- a/src/ship/shipCollision.ts +++ b/src/ship/shipCollision.ts @@ -51,6 +51,56 @@ export function sphereVsPlanets( 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" diff --git a/src/ship/spaceMining.ts b/src/ship/spaceMining.ts index 7207820..820380f 100644 --- a/src/ship/spaceMining.ts +++ b/src/ship/spaceMining.ts @@ -1,11 +1,16 @@ -// Raycast mining/weapon system for space (asteroids only — no ship-to-ship +// 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), 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 shot is hitscan (instant), but visible as a -// brief flash/tracer and an impact spark burst, feeding the same -// `applyAsteroidDamage` pipeline collision damage uses so sustained fire +// 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'; @@ -14,15 +19,23 @@ import { TIERS } from '../systems/asteroidLayout'; import { asteroidRuntime } from '../scene/asteroidRuntime'; import { rotateY } from './shipCollision'; -/** Max range (world units) the mining beam can reach. */ +/** 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; -/** Pseudo-"impact speed" fed into the fracture debris-ejection direction — - * not a real projectile velocity, just biases fragments away from the shot. */ -export const MINING_IMPACT_SPEED = 30; +/** 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)); 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 eaa81f2..f003afd 100644 --- a/src/systems/quality.ts +++ b/src/systems/quality.ts @@ -48,6 +48,10 @@ export interface QualitySettings { 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 @@ -88,6 +92,7 @@ export const QUALITY: Record = { debrisLifetimeSec: 8, debrisCullDistance: 250, miningVfxBudget: 8, + projectileMax: 6, promotedAsteroidMax: 0, cascadeFractureEnabled: false, }, @@ -119,6 +124,7 @@ export const QUALITY: Record = { debrisLifetimeSec: 12, debrisCullDistance: 400, miningVfxBudget: 16, + projectileMax: 10, promotedAsteroidMax: 8, cascadeFractureEnabled: true, }, @@ -150,6 +156,7 @@ export const QUALITY: Record = { debrisLifetimeSec: 18, debrisCullDistance: 700, miningVfxBudget: 32, + projectileMax: 16, promotedAsteroidMax: 16, cascadeFractureEnabled: true, }, @@ -181,6 +188,7 @@ export const QUALITY: Record = { debrisLifetimeSec: 24, debrisCullDistance: 1000, miningVfxBudget: 48, + projectileMax: 24, promotedAsteroidMax: 32, cascadeFractureEnabled: true, }, From fb4d54b581b0f0a0f9a8ae0c154ef04af8fcd6a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:33:26 +0000 Subject: [PATCH 20/22] Fix projectiles missing their target and debris vanishing on spawn Two bugs found by instrumenting the live app, both confirmed fixed by re-running the same end-to-end scenario (hold fire aimed at a real belt asteroid: 16 auto-shots over a 2.5s hold, health 30 -> killed, knockback velocity imparted, debris fragments present in the world afterward): 1. Converged aim. Projectiles spawned at the ship's hardpoint but flew parallel to the CAMERA ray. The chase camera sits a couple of units behind/above the ship, so the bolt's flight line was laterally offset from the crosshair line by more than a small asteroid's radius - shots consistently missed exactly what the reticle said was targetable, which is why hits (and therefore knockback and debris) never happened. Shots now fly from the hardpoint toward the crosshair's actual world target point. 2. Belt-frame/world-frame mismatch in fracture spawns. state.pos/vel are belt-LOCAL (the whole belt group rotates by groupYaw), but debris simulates in WORLD space. Fragments spawned at the local position - measured live at 3452 units away from the same asteroid's world position (groupYaw was 8.7 rad) - and were instantly removed by the 400-unit distance cull, so even a landed kill produced zero visible debris. The knockback impulse had the mirror bug (world-space direction added to local-frame velocity, pushing rocks ~140 degrees off the shot line). Fragment kinematics are now computed in the belt frame and rotated to world for the spawn; knockback direction is rotated into the belt frame. Two yaw regression tests added. --- src/scene/SpaceMiningController.tsx | 22 ++++++++++++-- src/systems/asteroidFracture.test.ts | 38 ++++++++++++++++++++++++ src/systems/asteroidFracture.ts | 44 ++++++++++++++++++++++------ 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/scene/SpaceMiningController.tsx b/src/scene/SpaceMiningController.tsx index e009303..6cfccbe 100644 --- a/src/scene/SpaceMiningController.tsx +++ b/src/scene/SpaceMiningController.tsx @@ -39,6 +39,8 @@ 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(); @@ -71,7 +73,7 @@ export function SpaceMiningController() { const fireAcc = useRef(0); const wasFiring = useRef(false); - const fireShot = () => { + 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. @@ -79,7 +81,21 @@ export function SpaceMiningController() { _hardpointLocal.set(0, -HARDPOINT_DOWN, -HARDPOINT_FORWARD); _hardpointWorld.copy(_hardpointLocal).applyQuaternion(shipTelemetry.rotation).add(shipTelemetry.position); - _shotVel.copy(shipTelemetry.velocity).addScaledVector(_dir, PROJECTILE_SPEED); + + // 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 }); }; @@ -113,7 +129,7 @@ export function SpaceMiningController() { fireAcc.current += dt; while (fireAcc.current >= FIRE_INTERVAL) { fireAcc.current -= FIRE_INTERVAL; - fireShot(); + fireShot(hit); } } else { wasFiring.current = false; diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts index 82442e9..8c1f608 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -32,6 +32,7 @@ describe('applyAsteroidDamage', () => { beforeEach(() => { asteroidRuntime.states = []; asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; asteroidRuntime.killAsteroid = null; asteroidRuntime.promotion = null; debrisRuntime.list = []; @@ -218,6 +219,7 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { beforeEach(() => { asteroidRuntime.states = []; asteroidRuntime.grid = null; + asteroidRuntime.groupYaw = 0; asteroidRuntime.killAsteroid = () => {}; debrisRuntime.list = []; debrisRuntime.maxCount = 1000; @@ -310,6 +312,42 @@ describe('applyAsteroidDamage — pattern-based fracture with momentum', () => { 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]; diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index 76c299f..2298a49 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -15,6 +15,7 @@ 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, @@ -53,11 +54,17 @@ const KNOCKBACK_PER_DAMAGE = 0.35; const MAX_SPIN_RATE = 6; const _dir = new Vector3(); +const _dirLocal = new Vector3(); const _centroid = new Vector3(); -const _worldOffset = 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; @@ -136,10 +143,20 @@ export function applyAsteroidDamage( 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); + // 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(_dir, amount * KNOCKBACK_PER_DAMAGE); + 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, @@ -183,25 +200,34 @@ export function applyAsteroidDamage( 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, speed, state.radius); computeClusterCentroid(geometry, pattern, clusterIndices[k], _centroid); - _worldOffset.copy(_centroid).multiply(momentum.scale).applyQuaternion(momentum.quat); + _parentOffset.copy(_centroid).multiply(momentum.scale).applyQuaternion(momentum.quat); _fragVel .copy(state.vel) - .add(_angVelCross.copy(momentum.angVel).cross(_worldOffset)) + .add(_angVelCross.copy(momentum.angVel).cross(_parentOffset)) .addScaledVector(shared.jitter, shared.outwardSpeed) - .addScaledVector(_dir, speed * 0.3); + .addScaledVector(_dirLocal, speed * 0.3); - const pos = state.pos.clone().add(_worldOffset); - _fragQuat.copy(momentum.quat); // fragment starts oriented like the parent it broke from + _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, - vel: _fragVel.clone(), + pos: _worldPos.clone(), + vel: _worldVel.clone(), radius: shared.fragRadius, isOre: shared.isOre, resourceType: shared.resourceType, From d7bd82241c2458e8c3345db38b9a395fcf3fe190 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:25:47 +0000 Subject: [PATCH 21/22] Chip debris on every connecting hit, and cap runaway ejection speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding causes of "still no debris from the shots," found by sampling debris state live across a burst-fire sequence against a real belt asteroid: 1. Non-lethal hits (the vast majority of shots — an asteroid takes several hits to kill) produced zero debris at all, only a health decrement + dent. Every connecting hit now also chips off one small, short-lived fragment at the exact impact point, giving instant visual feedback on every shot, not just the eventual kill. Chips are pre-aged (spawned with `life` already advanced) so sustained fire can't fill the shared debris pool and starve real fracture fragments of budget. 2. Fragment ejection speed was derived directly from the projectile's own flight speed (~220 u/s, after the earlier projectile-weapon rework), uncapped — fracture fragments launched at up to ~90-150 u/s, fast enough to streak out of the debris-cull radius within a fraction of a second. Technically "spawned" (confirmed in the prior round's live check) but functionally invisible in normal play. Ejection energy is now capped at a physically-motivated fraction of the impact speed (MAX_EJECTION_SPEED = 30), verified live to keep fragment speeds in a watchable ~6-7 u/s range against a real hit sequence. Existing "low-impact -> no debris" tests updated to the new "low tier -> one chip" contract; two new assertions cover the chip's short lifetime and cascade-depth-1 (never re-fractures further). --- src/scene/debrisRuntime.ts | 7 +++- src/systems/asteroidFracture.test.ts | 23 ++++++++---- src/systems/asteroidFracture.ts | 54 +++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/scene/debrisRuntime.ts b/src/scene/debrisRuntime.ts index 13e2e23..26b7098 100644 --- a/src/scene/debrisRuntime.ts +++ b/src/scene/debrisRuntime.ts @@ -48,6 +48,11 @@ export interface DebrisSpawnSpec { 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: { @@ -79,7 +84,7 @@ export const debrisRuntime: { quat: spec.quat ? spec.quat.clone() : new Quaternion(), angVel: spec.angVel ? spec.angVel.clone() : new Vector3(), radius: spec.radius, - life: 0, + life: spec.life ?? 0, isOre: spec.isOre ?? false, resourceType: spec.resourceType, cascadeDepth: spec.cascadeDepth ?? 0, diff --git a/src/systems/asteroidFracture.test.ts b/src/systems/asteroidFracture.test.ts index 8c1f608..d089bb2 100644 --- a/src/systems/asteroidFracture.test.ts +++ b/src/systems/asteroidFracture.test.ts @@ -59,7 +59,7 @@ describe('applyAsteroidDamage', () => { expect(state.vel.length()).toBeGreaterThan(0); }); - it('low-impact damage (health remains) spawns no debris and does not kill', () => { + 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; @@ -68,11 +68,20 @@ describe('applyAsteroidDamage', () => { }; const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); - expect(result.tier).toBe('none'); - expect(result.debrisSpawned).toHaveLength(0); + expect(result.tier).toBe('low'); + expect(result.debrisSpawned).toHaveLength(1); expect(state.health).toBe(18); expect(killed).toBe(false); - expect(debrisRuntime.list).toHaveLength(0); + 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', () => { @@ -150,7 +159,7 @@ describe('applyAsteroidDamage', () => { expect(promoteCalls).toBe(1); }); - it('applies a dent (not debris) on a non-lethal hit to a promoted asteroid', () => { + 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; @@ -169,8 +178,8 @@ describe('applyAsteroidDamage', () => { const result = applyAsteroidDamage(0, 2, IMPACT_POINT, IMPACT_VEL); expect(dentCalls).toBe(1); - expect(result.tier).toBe('none'); - expect(result.debrisSpawned).toHaveLength(0); + expect(result.tier).toBe('low'); + expect(result.debrisSpawned).toHaveLength(1); }); it('does not attempt promotion or dent when promotion returns false (over budget / ineligible)', () => { diff --git a/src/systems/asteroidFracture.ts b/src/systems/asteroidFracture.ts index 2298a49..6554104 100644 --- a/src/systems/asteroidFracture.ts +++ b/src/systems/asteroidFracture.ts @@ -49,6 +49,21 @@ const ORE_TYPES: ResourceType[] = ['iron', 'silicon', 'titanite', 'hematite', 'l * 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; @@ -152,6 +167,7 @@ export function applyAsteroidDamage( // 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, @@ -168,19 +184,39 @@ export function applyAsteroidDamage( } state.health -= amount; + state.hitSeq += 1; + if (state.health > 0) { // Low impact: real local damage — a persistent crater at the impact - // point — instead of the asteroid just silently losing health. No-op if - // this rock wasn't promoted (dust tier, or the promotion budget was full). + // 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); - return { tier: 'none', debrisSpawned: [] }; + + 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; - - state.hitSeq += 1; const countHash = cellHash(globalIdx, state.hitSeq, state.seed + 6000); const count = minN + Math.floor(countHash * (maxN - minN + 1)); @@ -207,7 +243,7 @@ export function applyAsteroidDamage( for (let k = 0; k < clusterIndices.length; k++) { const stream = state.hitSeq * 100 + k; - const shared = computeSharedFragmentProps(globalIdx, stream, state.seed, speed, state.radius); + 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); @@ -216,7 +252,7 @@ export function applyAsteroidDamage( .copy(state.vel) .add(_angVelCross.copy(momentum.angVel).cross(_parentOffset)) .addScaledVector(shared.jitter, shared.outwardSpeed) - .addScaledVector(_dirLocal, speed * 0.3); + .addScaledVector(_dirLocal, ejectionSpeed * 0.3); _localPos.copy(state.pos).add(_parentOffset); rotateY(_localPos, yaw, _worldPos); @@ -242,8 +278,8 @@ export function applyAsteroidDamage( // 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, speed, state.radius); - const vel = shared.jitter.clone().multiplyScalar(shared.outwardSpeed).addScaledVector(_dir, speed * 0.3); + 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({ From aca4b3cf10143ee24fde472c68e98aca5541a273 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 06:55:28 +0000 Subject: [PATCH 22/22] Add an item wheel for on-foot gameplay: pickaxe, gun, flashlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold-to-open radial tool selector (Q on desktop, a new touch button) — drag to a wedge, release to select. Deliberately not modal like the existing Backpack/Craft/Silo sheets: pointer-lock stays engaged and the game keeps simulating while it's open, since opening it just redirects raw look-deltas into the wheel's own drag vector instead of the camera. Pickaxe: unchanged mining behavior, now gated to only work when it's the equipped tool (previously mining was unconditional). Gun: a second, longer-range mining tool (10 vs the pickaxe's 6), automatic fire at 4 shots/sec, reusing the same aim-raycast/crack-progress/break pipeline as the pickaxe. Reduced resource yield and can't chop trees, so the pickaxe stays meaningful rather than being strictly superseded. Flashlight: a real SpotLight, toggled via the existing primary-click input (repurposed only while equipped, since mining is a no-op for this tool). Requires a genuine darkness system, added alongside it: ambient light now dims with cave depth (an existing depth signal that previously only fed audio) and, independently, on bodies whose biome archetype is already naturally dark (airless/moon-type biomes already have low authored ambient values — no new per-body data needed). Both factors compound, so a dark body's caves are the darkest of all. Found and fixed a real bug along the way: the WebGPU renderer only registers AmbientLight/PointLight/DirectionalLight with its node library (the same class of bug already fixed for DirectionalLight earlier in this project) — a SpotLight would have silently never lit anything without also registering SpotLightNode. New pure/testable modules: voxelControls.ts's resolveWheelSlice (wheel drag-vector -> selected tool) and darkness.ts's ambientDarknessFactor (cave + dark-body dimming), both unit tested. Verified live against the running app (headless browser): tool equip via the wheel gesture, the darkness formula's actual output, and that toggling the flashlight produces no WebGPU light-node warnings. --- src/App.tsx | 8 +- src/i18n.ts | 6 ++ src/scene/SolarSystem.tsx | 4 +- src/store.ts | 23 +++++- src/styles.css | 72 +++++++++++++++++ src/ui/ToolWheel.tsx | 81 +++++++++++++++++++ src/ui/VoxelHUD.tsx | 7 ++ src/ui/VoxelTouchControls.tsx | 42 ++++++++++ src/voxel/ChunkManager.tsx | 86 +++++++++++++++++++- src/voxel/PlayerController.tsx | 135 +++++++++++++++++++++++++++----- src/voxel/VoxelScene.tsx | 19 ++++- src/voxel/darkness.test.ts | 38 +++++++++ src/voxel/darkness.ts | 34 ++++++++ src/voxel/persistence.ts | 36 ++++++++- src/voxel/player.ts | 5 ++ src/voxel/voxelControls.test.ts | 46 +++++++++++ src/voxel/voxelControls.ts | 74 ++++++++++++++++- 17 files changed, 681 insertions(+), 35 deletions(-) create mode 100644 src/ui/ToolWheel.tsx create mode 100644 src/voxel/darkness.test.ts create mode 100644 src/voxel/darkness.ts create mode 100644 src/voxel/voxelControls.test.ts diff --git a/src/App.tsx b/src/App.tsx index 16cacb8..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'; @@ -44,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 ( 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/SolarSystem.tsx b/src/scene/SolarSystem.tsx index e8a4f30..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,6 +9,7 @@ import { AmbientLightNode, PointLightNode, DirectionalLightNode, + SpotLightNode, } from 'three/webgpu'; import { Starfield } from './Starfield'; import { Sun } from './Sun'; @@ -82,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; 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 7aab2b8..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; @@ -1760,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; diff --git a/src/ui/ToolWheel.tsx b/src/ui/ToolWheel.tsx new file mode 100644 index 0000000..4287e09 --- /dev/null +++ b/src/ui/ToolWheel.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '../store'; +import { useT } from '../i18n'; +import { voxelWheel, resolveWheelSlice, consumeWheelSelection, type ToolSlice } from '../voxel/voxelControls'; + +const SLICES: ToolSlice[] = ['pickaxe', 'gun', 'flashlight']; +/** Wedge center angles (degrees, 0 = up, clockwise) — must match + * `resolveWheelSlice`'s wedge boundaries in voxelControls.ts. */ +const SLICE_ANGLE_DEG: Record = { pickaxe: 0, gun: 120, flashlight: 240 }; +const SLICE_COLOR: Record = { + pickaxe: '#9a9a9a', + gun: '#ff8850', + flashlight: '#ffd65a', +}; +const LABEL_RADIUS = 78; + +/** + * Passive renderer for the hold-to-open radial tool selector — all the actual + * input handling (desktop KeyQ hold, touch WheelButton drag) lives in + * voxelControls.ts/VoxelTouchControls.tsx and just writes to the shared + * `voxelWheel` object; this component polls it via rAF (same non-reactive + * pattern as the rest of the on-foot HUD) and turns a resolved selection into + * the actual `setActiveTool` store call — the one place in this feature that + * needs both the input state and the store. + */ +export function ToolWheel() { + const sceneMode = useStore((s) => s.sceneMode.type); + const setActiveTool = useStore((s) => s.setActiveTool); + const activeTool = useStore((s) => s.activeTool); + const { t } = useT(); + const [open, setOpen] = useState(false); + const [hovered, setHovered] = useState(null); + const raf = useRef(0); + + useEffect(() => { + if (sceneMode !== 'voxel') return; + let running = true; + const tick = () => { + if (!running) return; + setOpen(voxelWheel.open); + setHovered(voxelWheel.open ? resolveWheelSlice(voxelWheel.dx, voxelWheel.dy) : null); + const selected = consumeWheelSelection(); + if (selected) setActiveTool(selected); + raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => { + running = false; + cancelAnimationFrame(raf.current); + }; + }, [sceneMode, setActiveTool]); + + if (sceneMode !== 'voxel' || !open) return null; + + return ( +