449 lines
18 KiB
JavaScript
449 lines
18 KiB
JavaScript
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).
|
||
*/
|
||
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.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 });
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|