import { config } from '../config/Config.js'; import { MiningBeam } from './MiningBeam.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; /** * The ship's mining state — the machine behind the beam. The SHIP'S * state gates it (js/entities/Ship.js): the sequence runs only while * the ship is in its 'mining' state — the scene puts the ship there * when the arm engages and takes it back to 'normal' when the sequence * ends. Moving the ship (a world click, the autopilot) or any other * state change drops it out of 'mining', which ends the sequence: * the scene's onStateChange handler does the teardown, and update() * below enforces the gate. * * idle ──begin()──► extending ──(armExtendMs)──► mining ──stop()──► retracting * ▲ │ ▲ │ │ * │ └──┘ (stop() cuts the extension) └────beam out────┘ * └──────────────────────────────────────────────────────────────────────┘ * * extending the arm is reaching for the rock — the ship holds * station (GameScene stops it) and sits in 'mining'; * mining the beam is live (MiningBeam) — the ship stays at its * rim, still 'mining'; retargeting (begin on another rock) * cuts the current beam and re-extends; * retracting the beam is pulling back rock → ship; the ship is already * BACK TO 'normal' (FREE) — the player can fly as it dies. * * THE ORE (data/asteroids.json → mining.economy): the rock IS its * minerals — its size (width = height, px) at the START of mining is the * minerals it holds (recorded in this.rock). While the beam is steady: * - each second extracts `ratePerSec × ship.stats.miningSpeed` minerals: * the rock SHRINKS by that many px (width and height — the beam reads * the live member, so the impact point stays latched to the rim) and * the same amount lands in the ship's hold (ship.addMinerals, capped * at stats.mineralStorage — a full hold ends the run); * - once the rock has lost ≥ splitLostFraction of its ORIGINAL size it * BREAKS (sfx 'mining_split', the mining-02 crack): * ≤ smallSizeMaxPx → it shatters into `smallPieces` of * floor(size × pieceFraction) px each and ALL * the pieces are sucked into the ship (their * minerals land on impact, capped); * > smallSizeMaxPx → it splits in HALF (floor(size/2) each): the * latched rock becomes one half (fresh * original size — it starts its own ledger) and * a sibling rock appears beside it; the beam * stays on the latched half and keeps mining. * * This class owns only the sequence + the beam's lifecycle + the ore. * Scene concerns (ship stop, toasts, sfx) ride the onPhase / onEvent * seams — the same pattern as TetherField's onChange. */ export class Mining { /** * @param {Phaser.Scene} scene * @param {object} [o] * @param {Function} [o.onPhase] — (phase) => void, phase ∈ 'extending' | 'mining' | 'stopped' * @param {Function} [o.onEvent] — (name, data) => void: * 'split' { kind: 'absorb' | 'divide', pieces, pieceSize } * 'absorbed' { gained, pieces } — the shattered pieces hit the hull * 'storageFull' — the hold is full; the run ended */ constructor(scene, o = {}) { this.scene = scene; this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null; this.onEvent = typeof o.onEvent === 'function' ? o.onEvent : null; this.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting' this.cluster = null; this.member = null; this.beam = null; this.stateT0 = 0; this.armExtendMs = config.get('asteroids.mining.armExtendMs', 1500); // The ore ledger (see the class note): the rock's size at the start // of mining IS its minerals; 'original' fixes the break threshold. this.rock = null; // { original, size } this.oreAcc = 0; // fractional minerals extracted (< 1) this.suck = []; // fragments in flight to the hull (outlive the beam) this.batchSeq = 0; // one per shatter — 'absorbed' fires when a batch is DONE this.batchState = {}; // batch → { landed, pieces, total } (per-batch totals) // Tuning (data/asteroids.json → mining.economy). const eco = config.section('asteroids.mining.economy', {}); this.mineralsPerSec = eco.ratePerSec ?? 1; this.splitLostFraction = eco.splitLostFraction ?? 0.33; // ~1/3 of the original this.smallSizeMaxPx = eco.smallSizeMaxPx ?? 64; this.smallPieces = Math.max(1, Math.floor(eco.smallPieces ?? 4)); this.pieceFraction = eco.pieceFraction ?? 0.25; this.suckSpeed = eco.suckSpeedPxPerSec ?? 460; // px/s, fragments → hull this.suckStaggerMs = eco.suckStaggerMs ?? 70; this.rockFrameSize = Math.max(1, config.get('asteroids.frameWidth', 128)); } /** The arm's sequence is live: mid-extension OR beam live. */ get isActive() { return this.state === 'extending' || this.state === 'mining'; } /** * Start mining `cluster`'s rock `member` (from the pop-up's * "Mine Asteroids"). Cuts any live/retracting beam first (retarget). * Returns false if already extending — the arm is committed. */ begin(cluster, member) { if (this.state === 'extending') return false; if (this.state === 'mining' || this.state === 'retracting') { this.beam?.destroy(); this.beam = null; } this.cluster = cluster; this.member = member; // Fresh ledger for this rock: its size (width = height, px) NOW is // the minerals it holds. 'original' anchors the break threshold — // the rock breaks once it has lost ~1/3 of THIS size. this.rock = { original: member.radius * 2, size: member.radius * 2 }; this.oreAcc = 0; this.state = 'extending'; this.stateT0 = this.scene.time.now; this._phase('extending'); return true; } /** * Stop mining (the pop-up's "Stop Mining", a world click, or ESC): * mid-extension → abort; beam live → it retracts (retracting). */ stop() { if (this.state === 'extending') { this.state = 'idle'; this._phase('stopped'); return; } if (this.state === 'mining' && this.beam && !this.beam.outRunning) { this.state = 'retracting'; this.beam.beginOut(this.scene.time.now); } } /** * Per-frame (GameScene.update, after the clusters have refreshed their * rock positions — the beam tracks them). Drives the extending → * mining hand-off, reaps a finished retract, extracts ore while the * beam is steady, and flies any fragments home. */ update(time, delta) { // The ship's state is the gate: the sequence (and its visuals) run // only while the ship is in its 'mining' state. It left that state — // the player moved the ship, or a state change — so end the sequence // right here (belt & braces over the scene's onStateChange handler). const ship = this.scene && this.scene.ship; if ( ship && ship.state !== 'mining' && (this.state === 'extending' || this.state === 'mining') ) { this.stop(); return; } if (this.state === 'extending' && time >= this.stateT0 + this.armExtendMs) { this.state = 'mining'; this.beam = new MiningBeam(this.scene, this); this._phase('mining'); } if (this.beam) { this.beam.update(time, delta); if (this.beam.finished) { this.beam.destroy(); this.beam = null; if (this.state === 'retracting') { this.state = 'idle'; this._phase('stopped'); } } } // The beam is latched and steady: ore comes off the rock (shrink it, // load the hold, break it when it has lost ~1/3 of its original). if ( this.state === 'mining' && this.beam && this.beam.state === 'steady' && this.rock && this.rock.size > 0 ) { this._extract(delta); } // Fragments in flight (they outlive the beam — the player is free // to move the ship while they ride home). if (this.suck.length > 0) this._suck(time, delta); } // ------------------------------------------------------------------ // The ore — extraction, breaks, fragments // ------------------------------------------------------------------ /** * One tick of extraction (beam steady only). Minerals per second = * ratePerSec × the ship's stats.miningSpeed; each mineral shrinks the * rock by 1 px and lands in the hold. A full hold ends the run — the * rock is not worth grinding into waste. */ _extract(delta) { const dt = Math.min(delta, 64) / 1000; const rate = this.mineralsPerSec * (this.scene.ship?.stats?.miningSpeed ?? 1); if (rate <= 0) return; this.oreAcc += rate * dt; const n = Math.floor(this.oreAcc + 1e-9); // epsilon: float sums can land just under an integer if (n <= 0) return; this.oreAcc -= n; const ship = this.scene.ship; const room = ship.storageRoom(); const take = Math.min(n, room); if (take > 0) { this.rock.size -= take; // the rock shrinks (width = height, px) ship.addMinerals(take); } if (room < n) { // The hold is full: mining would only grind the rock away. this._event('storageFull'); this.stop(); // the rock is still there — the beam retracts properly return; } this._maybeBreak(); } /** * The rock has lost ≥ splitLostFraction of its ORIGINAL size → it * breaks. Small (≤ smallSizeMaxPx): shatters into pieces that are * sucked into the ship. Bigger: splits in half and the beam keeps * mining one of the halves (fresh ledger). */ _maybeBreak() { if (!this.rock) return; const { original, size } = this.rock; if (size > Math.floor(original * (1 - this.splitLostFraction))) return; if (!this.cluster || !this.member) return; if (size <= this.smallSizeMaxPx) { this._shatter(this.cluster, this.member, size); } else { this._halve(this.cluster, this.member, size); } } /** * The small break: the rock shatters into `smallPieces` of * floor(size × pieceFraction) px each, and ALL of them are sucked * into the ship — their minerals (each piece carries its size) land * on impact (capped at the hold). The beam is cut (its rock is gone) * and the sequence ends; the fragments fly on their own. */ _shatter(cluster, member, size) { const s = this.scene; const pieceSize = Math.max(1, Math.floor(size * this.pieceFraction)); const now = s.time.now; const tint = cluster.tint ?? 0xffffff; const batch = ++this.batchSeq; this.batchState[batch] = { landed: 0, pieces: 0, total: this.smallPieces }; for (let i = 0; i < this.smallPieces; i++) { const a = (i / this.smallPieces) * Math.PI * 2 + Math.PI / 4; const r = Math.max(10, size * 0.55); // just outside the old rim const img = s.add .image(member.wx + Math.cos(a) * r, member.wy + Math.sin(a) * r, AsteroidCluster.TEXTURE_KEY, member.frame) .setDepth(9); if (tint) img.setTint(tint); img.setScale(pieceSize / this.rockFrameSize); this.suck.push({ img, size: pieceSize, // the minerals this piece carries born: now + i * this.suckStaggerMs, // staggered release side: i % 2 === 0 ? 1 : -1, // a little curl, alternating sides batch, }); } cluster.removeMember(member); this._event('split', { kind: 'absorb', pieces: this.smallPieces, pieceSize }); // The rock is gone — the beam can't retract from nothing: cut it and // end the run ('stopped' frees the ship; the fragments finish alone). if (this.beam) { this.beam.destroy(); this.beam = null; } this.state = 'idle'; this.cluster = null; this.member = null; this.rock = null; this.oreAcc = 0; this._phase('stopped'); } /** * The big break: the rock splits in HALF — two rocks of floor(size/2), * each starting FRESH (its own original size = half, so its break * threshold is 1/3 of that). The latched rock becomes one of the halves * IN PLACE (the beam stays latched — no jump, mining continues on it); * the sibling appears beside it, offset perpendicular to the beam so * the two read as two rocks. */ _halve(cluster, member, size) { const s = this.scene; const half = Math.max(1, Math.floor(size / 2)); // Offset the sibling perpendicular to the beam (the beam itself is // never blocked), far enough that the two don't interpenetrate. const ship = s.ship; const dx = member.wx - ship.x; const dy = member.wy - ship.y; const d = Math.hypot(dx, dy) || 1; const px = -dy / d; const py = dx / d; const gap = half * 0.55 + 8; const local = cluster.worldToLocalDelta(px * gap, py * gap, s.time.now); const sibling = cluster.addRock( member.frame, member.lx + local.x, member.ly + local.y, half, member.spin, // the piece tumbles like its parent Math.random() * Math.PI * 2, ); cluster.setSize(member, half); // the latched rock becomes one half // Fresh ledgers: both halves start at their own original size. this.rock = { original: half, size: half }; this.oreAcc = 0; this._event('split', { kind: 'divide', pieces: 2, pieceSize: half, sibling }); // The beam keeps mining `member` — it is still the same member, now // one of the two halves (its radius changed live; the beam tracks it). } /** * Fragments riding the beam home to the hull. They TRACK the ship * (the player is free while they fly), curl a little, shrink and dim * as they near, and land on impact — their minerals load the hold * (capped). A batch (one shatter) fires ONE 'absorbed' event when its * LAST piece lands, so the scene gets one toast per shatter, not four. */ _suck(time, delta) { const dt = Math.min(delta, 64) / 1000; const s = this.scene; const ship = s.ship; const landed = []; for (const p of this.suck) { if (time < p.born) continue; // staggered release const dx = ship.x - p.img.x; const dy = ship.y - p.img.y; const d = Math.hypot(dx, dy) || 1; const step = this.suckSpeed * dt; if (d <= step + 12) { p.img.destroy(); landed.push(p); continue; } // A little curl (perpendicular sway, decaying) as it rides in. const age = time - p.born; const amp = 18 * Math.exp(-age / 700); const wob = Math.sin(age * 0.011) * amp * p.side; p.img.x += (dx / d) * step + (-dy / d) * wob * dt * 4; p.img.y += (dy / d) * step + (dx / d) * wob * dt * 4; // Shrink + dim as it nears the hull (absorbed by the ship). const near = Math.max(0.22, Math.min(1, d / 180)); p.img.setScale((p.size / this.rockFrameSize) * near).setAlpha(Math.max(0.35, Math.min(1, d / 260))); } if (landed.length === 0) return; this.suck = this.suck.filter((p) => !landed.includes(p)); // Each piece carries its size in minerals (capped at the hold); the // batch's running total lands in its 'absorbed' event when done. const finished = []; for (const p of landed) { const st = this.batchState[p.batch]; if (!st) { ship.addMinerals(p.size); // no batch (defensive) — still load them continue; } st.landed += ship.addMinerals(p.size); st.pieces += 1; if (st.pieces >= st.total) { finished.push({ gained: st.landed, pieces: st.pieces }); delete this.batchState[p.batch]; } } this._hullRing(); if (finished.length === 1) this._event('absorbed', finished[0]); else if (finished.length > 1) this._event('absorbed', { gained: finished.reduce((a, b) => a + b.gained, 0), pieces: finished.reduce((a, b) => a + b.pieces, 0) }); } /** The hull flash when a fragment lands (a quick ring at the ship). */ _hullRing() { const s = this.scene; const ring = s.add .circle(s.ship.x, s.ship.y, 12, 0xffffff, 0) .setStrokeStyle(2, 0xbfefff, 0.85) .setDepth(12); s.tweens.add({ targets: ring, scale: 2.4, alpha: 0, duration: 420, ease: 'Sine.easeOut', onComplete: () => ring.destroy(), }); } // ------------------------------------------------------------------ _phase(phase) { try { this.onPhase?.(phase); } catch (err) { console.error('[mining] onPhase handler failed', err); } } _event(name, data) { try { this.onEvent?.(name, data); } catch (err) { console.error('[mining] onEvent handler failed', err); } } destroy() { this.beam?.destroy(); this.beam = null; for (const p of this.suck) p.img?.destroy(); this.suck.length = 0; this.batchState = {}; this.state = 'idle'; this.cluster = null; this.member = null; this.rock = null; this.oreAcc = 0; this.onPhase = null; this.onEvent = null; } }