orbit/js/mining/Mining.js

470 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
* @param {Function} [o.onOre] — (gained, hold) => void: minerals just landed
* in the hold — fires LIVE, per extraction tick (beam steady) and per
* fragment landing, so a UI can show the hold filling as the rock
* shrinks (not just at the shatter's 'absorbed' event).
* @param {Function} [o.onDepleted] — (cluster) => void: the cluster's LAST
* rock was consumed (its members are now all gone) — the scene drops the
* field (world + map) and records it. Fires once, from the shatter that
* emptied the cluster, while the beam is still latched (the teardown
* follows); the shattered pieces are scene images, not cluster children,
* so they ride home even after the cluster is destroyed.
*/
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.onOre = typeof o.onOre === 'function' ? o.onOre : null;
this.onDepleted = typeof o.onDepleted === 'function' ? o.onDepleted : 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)
this._ore(ship.addMinerals(take), ship); // load the hold (capped) + notify the UI live
}
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 });
// Last rock consumed? The field is DEPLETED — the scene drops it
// (world + map) now, before the teardown below. A sibling rock
// remaining (the halved break) does NOT deplete — the field lives on.
if (cluster.members.length === 0) this._depleted(cluster);
// 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) {
this._ore(ship.addMinerals(p.size), ship); // no batch (defensive) — still load them
continue;
}
const added = ship.addMinerals(p.size);
st.landed += added;
st.pieces += 1;
this._ore(added, ship); // the HUD ticks up per landing, not just at the batch event
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);
}
}
_ore(gained, ship) {
try {
this.onOre?.(gained, ship?.minerals ?? 0);
} catch (err) {
console.error('[mining] onOre handler failed', err);
}
}
_event(name, data) {
try {
this.onEvent?.(name, data);
} catch (err) {
console.error('[mining] onEvent handler failed', err);
}
}
/** The cluster's last rock is gone — the scene drops the field. */
_depleted(cluster) {
try {
this.onDepleted?.(cluster);
} catch (err) {
console.error('[mining] onDepleted 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;
this.onOre = null;
this.onDepleted = null;
}
}