Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 520cba85f6 Add mining economy: rock shrink, split/shatter, and mineral hold
- Extract minerals from the latched asteroid at ratePerSec × miningSpeed; each mineral shrinks the rock by 1 px so the beam stays latched to its rim.
- Break rocks that lose ~1/3 of their original size: large ones halve in place (beam keeps mining one half), small ones shatter into pieces that fly home and load the hold.
- Add ship.minerals hold with addMinerals/setMinerals capped at stats.mineralStorage; a full hold ends the run instead of grinding the rock away.
- Persist the hold in save/restore (captureState writes ship.minerals, applyRestore restores it) and keep legacy saves working when the field is absent.
- Play new mining_split sfx on each break and surface split/absorbed/storageFull events as console toasts.
- Expose AsteroidCluster rock surgery (setSize/addRock/removeMember) for runtime splits, and add headless Phaser stub + loader plus a mining test suite covering extraction, halving, shattering, full-hold stop, and the hold API.
2026-09-05 11:18:35 -06:00
Brian Fertig fdd5f8a506 Small adjustments to prepare for mining. 2026-09-05 10:29:02 -06:00
12 changed files with 744 additions and 23 deletions

View File

@ -9,7 +9,7 @@
"compassColor": "#c8d2e0", "compassColor": "#c8d2e0",
"shipClearance": 50, "shipClearance": 50,
"mining": { "mining": {
"_comment": "MINING (v0.4) — the ship's energy arm. armExtendMs = the 'Extending Mining Arm...' reach before the beam fires (the ship is locked while it extends). beam = the arm's line: inMs/outMs = the beam extends (ship→rock) / retracts (rock→ship); glowWidth/midWidth/mainWidth/coreWidth = the white core in its blue glow (px, additive under the core); ghostOffset = the console RGB fringe split off the beam sides. ore = the mineral stream: motes ride the beam rock→ship (speedPxPerSec along the beam), each with a travelling wobble + a tail, absorbed at the hull. impact = the molten bite point: glowScale × rock radius (glowMinPx floor), the hot core, shock rings, and chipping sparks. surge = the big ore packets riding the beam home (the arm brightens while one is in flight). popup = the context menu a rock click opens (world-anchored at the click).", "_comment": "MINING (v0.4) — the ship's energy arm. armExtendMs = the 'Extending Mining Arm...' reach before the beam fires (the ship is locked while it extends). beam = the arm's line: inMs/outMs = the beam extends (ship→rock) / retracts (rock→ship); glowWidth/midWidth/mainWidth/coreWidth = the white core in its blue glow (px, additive under the core); ghostOffset = the console RGB fringe split off the beam sides. ore = the mineral stream: motes ride the beam rock→ship (speedPxPerSec along the beam), each with a travelling wobble + a tail, absorbed at the hull. impact = the molten bite point: glowScale × rock radius (glowMinPx floor), the hot core, shock rings, and chipping sparks. surge = the big ore packets riding the beam home (the arm brightens while one is in flight). popup = the context menu a rock click opens (world-anchored at the click). economy = the rock IS its minerals: its size (width = height, px) at the start of mining is the minerals it holds; ratePerSec = minerals extracted per second (× the ship's stats.miningSpeed multiplier) — each mineral shrinks the rock by 1 px (width AND height; the beam tracks the live rock, so the impact point stays latched as it shrinks) and loads the ship's hold (capped at stats.mineralStorage — a full hold ends the run). splitLostFraction = once the rock has lost this fraction of its ORIGINAL size it breaks: ≤ smallSizeMaxPx it shatters into smallPieces of floor(size × pieceFraction) px each and ALL pieces are sucked into the ship (their minerals land on impact, capped); > smallSizeMaxPx it splits in HALF (floor(size/2) each, both starting fresh with their own original size) and the beam re-latches onto one half to keep mining. suckSpeedPxPerSec / suckStaggerMs = the shattered pieces' ride home. Every break plays sfx mining_split.",
"armExtendMs": 1500, "armExtendMs": 1500,
"beam": { "beam": {
"inMs": 420, "inMs": 420,
@ -59,6 +59,15 @@
"gap": 8, "gap": 8,
"headerHeight": 20, "headerHeight": 20,
"lift": 14 "lift": 14
},
"economy": {
"ratePerSec": 1,
"splitLostFraction": 0.33,
"smallSizeMaxPx": 64,
"smallPieces": 4,
"pieceFraction": 0.25,
"suckSpeedPxPerSec": 460,
"suckStaggerMs": 70
} }
}, },
"cluster": { "cluster": {

View File

@ -1,12 +1,13 @@
{ {
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = text typing in (the dossier, menus decoding in); deconstruct = text UNDECODING (the dossier collapsing) — reserved for the text only, never for closing UI; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); mining_loop = the hum that LOOPS while the mining beam is live (starts when the arm finishes extending, stops when the sequence ends — stopping mining itself plays no sound); scan = the DEEP SCAN pulse going out (the command deck's SCAN button); ui_hover = the tick on HOVER of a clickable item (menu/deck buttons, compass name tags, panel buttons, save slots); ui_click = the tick on CLICK of a clickable item (the SCAN slot plays its own sonar ping instead of ui_click); ui_window = the whoosh when a window OPENS (the mining pop-up, the comms/landing panel); ui_close = the tick when a UI element is CANCELLED/CLOSED (the menu sub-bar, the save vault, the confirm dialog). Convention: the key here IS the play name — playSfx('<key>') plays the asset queued as 'sfx_<key>'.", "_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = text typing in (the dossier, menus decoding in); deconstruct = text UNDECODING (the dossier collapsing) — reserved for the text only, never for closing UI; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach); mining_loop = the hum that LOOPS while the mining beam is live (starts when the arm finishes extending, stops when the sequence ends — stopping mining itself plays no sound); scan = the DEEP SCAN pulse going out (the command deck's SCAN button); ui_hover = the tick on HOVER of a clickable item (menu/deck buttons, compass name tags, panel buttons, save slots); ui_click = the tick on CLICK of a clickable item (the SCAN slot plays its own sonar ping instead of ui_click); ui_window = the whoosh when a window OPENS (the mining pop-up, the comms/landing panel); ui_close = the tick when a UI element is CANCELLED/CLOSED (the menu sub-bar, the save vault, the confirm dialog). Convention: the key here IS the play name — playSfx('<key>') plays the asset queued as 'sfx_<key>'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break.",
"enabled": true, "enabled": true,
"volume": 0.55, "volume": 1,
"construct": "assets/fx/type-construct.mp3", "construct": "assets/fx/type-construct.mp3",
"deconstruct": "assets/fx/type-deconstruct.mp3", "deconstruct": "assets/fx/type-deconstruct.mp3",
"discovery": "assets/fx/discovery.mp3", "discovery": "assets/fx/discovery.mp3",
"mining": "assets/fx/system-scan.mp3", "mining": "assets/fx/system-scan.mp3",
"mining_loop": "assets/fx/mining-01.mp3", "mining_loop": "assets/fx/mining-01.mp3",
"mining_split": "assets/fx/mining-02.mp3",
"scan": "assets/fx/scan-01.mp3", "scan": "assets/fx/scan-01.mp3",
"ui_hover": "assets/fx/ui-hover.mp3", "ui_hover": "assets/fx/ui-hover.mp3",
"ui_click": "assets/fx/ui-click.mp3", "ui_click": "assets/fx/ui-click.mp3",

View File

@ -17,10 +17,11 @@
"arriveRadius": 8, "arriveRadius": 8,
"arriveSpeed": 50, "arriveSpeed": 50,
"stats": { "stats": {
"_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = goods capacity; mineralStorage = minerals capacity. Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.", "_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = goods capacity; mineralStorage = minerals capacity (the hold's max load — the player's current minerals live in ship.minerals, loaded by the mining sequence); miningSpeed = multiplier on mining rate (1 = baseline). Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.",
"hullIntegrity": 100, "hullIntegrity": 100,
"shields": 0, "shields": 0,
"cargoHold": 100, "cargoHold": 100,
"mineralStorage": 250 "mineralStorage": 250,
"miningSpeed": 1
} }
} }

213
dev/mining.test.mjs Normal file
View File

@ -0,0 +1,213 @@
// Headless test for the mining ore logic (js/mining/Mining.js):
// extraction (1 px/sec of rock → 1 mineral, capped at the hold),
// the break threshold (1/3 of the ORIGINAL size lost),
// halving (> 64 px) and shattering (≤ 64 px, pieces sucked to the hull),
// the full-hold stop.
// Run: node --import ./dev/phaser-loader.mjs dev/mining.test.mjs
import { readdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const { config } = await import('../js/config/Config.js');
const data = {};
for (const f of readdirSync(new URL('../data', import.meta.url))) {
if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
}
config.init(data);
const { GameObject } = await import('./phaser-stub.mjs').then((m) => ({ GameObject: m.default.GameObjects.Container }));
const { Ship } = await import('../js/entities/Ship.js');
const { AsteroidCluster } = await import('../js/entities/AsteroidCluster.js');
const { Mining } = await import('../js/mining/Mining.js');
function makeImage(x, y, key) {
const g = new GameObject(null, x, y);
g.key = key;
return g;
}
function makeScene() {
return {
time: { now: 0, delayedCall: () => ({ destroy() {} }) },
add: {
existing: (o) => o,
image: (x, y, key, frame) => makeImage(x, y, key),
graphics: () => ({ clear() {}, destroy() {} }),
circle: (x, y, r, fill, fa) => makeImage(x, y, 'circle'),
text: (x, y, s, st) => makeImage(x, y, 'text'),
},
make: { graphics: () => ({ clear() {}, destroy() {} }) },
tweens: {
add: (o) => {
if (o && typeof o.onComplete === 'function') o.onComplete();
return { destroy() {} };
},
killTweensOf: () => {},
},
textures: { exists: () => true },
physics: {
add: {
existing: (o) => {
o.body = {
x: 0, y: 0,
velocity: { x: 0, y: 0, set() {}, length: () => 0, scale() {} },
acceleration: { set() {}, x: 0, y: 0 },
};
return o;
},
},
},
ship: null,
};
}
function fakeBeam() {
return {
state: 'steady',
outRunning: false,
finished: false,
update() {},
beginOut() { this.state = 'out'; this.outRunning = true; },
destroy() { this.destroyed = true; },
};
}
/** A cluster with ONE rock of `size` px at (1000, 0), ship parked at (300, 0). */
function rig(size) {
const scene = makeScene();
const ship = new Ship(scene, 300, 0);
scene.ship = ship;
const cluster = new AsteroidCluster(scene, {
id: `c-${size}`,
name: 'Test Field',
x: 1000,
y: 0,
bound: size / 2 + 10,
tint: null,
groupSpin: 0.1,
groupPhase: 0,
debrisPhase: 0,
debrisSpin: 0,
debris: [],
asteroids: [{ frame: 2, x: 0, y: 0, size, spin: 0.05, phase: 0.3 }],
}, {});
const member = cluster.members[0];
const events = [];
const phases = [];
const mining = new Mining(scene, {
onPhase: (p) => {
phases.push(p);
if (p === 'stopped') ship.setState('normal', 'mining-ended'); // the scene's share
},
onEvent: (n, d) => events.push([n, d]),
});
ship.onStateChange = (next, prev) => {
if (prev === 'mining' && next !== 'mining') mining.stop();
};
const pump = (secs) => {
// Steps at the 64 ms extraction cap so scene time and ore time agree
// (Mining caps delta at 64 ms — a backgrounded-tab fast-forward guard).
const steps = Math.ceil((secs * 1000) / 64);
for (let i = 0; i < steps; i++) {
scene.time.now += 64;
mining.update(scene.time.now, 64);
}
};
const start = () => {
mining.begin(cluster, member);
ship.setState('mining', 'mining'); // the scene's 'extending' share
mining.state = 'mining'; // fast-forward the arm reach
mining.beam = fakeBeam();
};
return { scene, ship, cluster, member, events, phases, mining, pump, start };
}
// ---------------------------------------------------------------------------
// Scenario A — the 128 px rock: mines to 85, halves to 42, mines to 28,
// shatters into 4 × 7 px pieces that ride home (43 + 14 + 28 = 85 minerals).
// ---------------------------------------------------------------------------
{
const r = rig(128);
r.start();
assert.equal(r.mining.rock.original, 128);
assert.equal(r.mining.rock.size, 128);
r.pump(43); // 43 s → 43 px off → 85 left (the 1/3-lost threshold for 128)
assert.equal(r.member.radius * 2, 42, 'latched rock halved to 42');
assert.equal(r.cluster.members.length, 2, 'sibling rock added');
assert.equal(r.ship.minerals, 43, 'minerals mined before the halving');
assert.deepEqual(r.events.at(-1), ['split', { kind: 'divide', pieces: 2, pieceSize: 42, sibling: r.cluster.members[1] }]);
assert.equal(r.mining.rock.original, 42, 'fresh ledger: original reset');
assert.equal(r.mining.beam.destroyed ?? false, false, 'beam survives the halving (still latched)');
assert.equal(r.mining.state, 'mining');
const memberImg = r.member.img;
const beam = r.mining.beam;
r.pump(14); // 14 s → 14 px off → 28 left (the 1/3-lost threshold for 42)
assert.equal(r.cluster.members.length, 1, 'mined rock consumed at the shatter');
assert.equal(memberImg.destroyed, true, 'the rock image is gone');
assert.equal(beam.destroyed, true, 'beam cut — its rock is gone');
assert.equal(r.mining.state, 'idle');
assert.equal(r.ship.state, 'normal', 'the ship is free again');
assert.deepEqual(r.events.at(-1), ['split', { kind: 'absorb', pieces: 4, pieceSize: 7 }]);
r.pump(3); // the four 7 px pieces (~700 px out) ride the 460 px/s beam home
assert.equal(r.ship.minerals, 43 + 14 + 28, 'shattered pieces landed as minerals (capped hold)');
assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 28, pieces: 4 }]);
assert.equal(r.events.filter(([n]) => n === 'split').length, 2, 'one crack per break (halving + shatter)');
console.log('A: 128 px rock — halve → shatter → suck in: OK');
}
// ---------------------------------------------------------------------------
// Scenario B — the 64 px rock: below the small threshold at its break point
// (42 ≤ 64) → straight to the shatter: 22 mined + 4 × 10 px = 62 minerals.
// ---------------------------------------------------------------------------
{
const r = rig(64);
r.start();
r.pump(22); // 22 s → 42 left → shatters (42 ≤ 64)
assert.equal(r.ship.minerals, 22);
assert.deepEqual(r.events.at(-1), ['split', { kind: 'absorb', pieces: 4, pieceSize: 10 }]);
assert.equal(r.mining.state, 'idle');
r.pump(3);
assert.equal(r.ship.minerals, 22 + 40, '4 × 10 px pieces landed');
assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 40, pieces: 4 }]);
assert.equal(r.events.filter(([n]) => n === 'split').length, 1, 'one crack for the shatter');
console.log('B: 64 px rock — direct shatter: OK');
}
// ---------------------------------------------------------------------------
// Scenario C — full hold: mining stops (the rock is not ground into waste),
// the hold stays at capacity, the rock is untouched.
// ---------------------------------------------------------------------------
{
const r = rig(64);
r.ship.minerals = r.ship.stats.mineralStorage; // full hold
r.start();
r.pump(2);
assert.equal(r.ship.minerals, r.ship.stats.mineralStorage, 'hold capped');
assert.equal(r.member.radius * 2, 64, 'rock untouched — nothing could be loaded');
assert.equal(r.mining.state, 'retracting', 'the run ended (beam retracts)');
assert.equal(r.events.at(-1)[0], 'storageFull', 'the scene is told the hold is full');
console.log('C: full hold — run ends, rock preserved: OK');
}
// ---------------------------------------------------------------------------
// Scenario D — the hold's load/restore API (the save/load seam):
// setMinerals clamps to the capacity, addMinerals never overfills.
// ---------------------------------------------------------------------------
{
const r = rig(64);
const cap = r.ship.stats.mineralStorage;
r.ship.setMinerals(37);
assert.equal(r.ship.minerals, 37);
r.ship.setMinerals(99999); // an overfull record can't break the hold
assert.equal(r.ship.minerals, cap, 'clamped to the capacity');
r.ship.setMinerals(-5); // a corrupt record can't go negative
assert.equal(r.ship.minerals, 0, 'clamped at zero');
r.ship.setMinerals(cap - 10);
assert.equal(r.ship.addMinerals(50), 10, 'addMinerals still only fills the room left');
assert.equal(r.ship.minerals, cap);
console.log('D: hold restore API — clamped at both ends: OK');
}
console.log('\nAll mining ore scenarios passed.');

12
dev/phaser-loader.mjs Normal file
View File

@ -0,0 +1,12 @@
// Resolves the vendored Phaser shim to a headless stub for node tests.
import { registerHooks } from 'node:module';
registerHooks({
resolve(specifier, context, nextResolve) {
const r = nextResolve(specifier, context);
if (r.url.includes('/vendor/phaser.js')) {
return { ...r, url: new URL('./phaser-stub.mjs', import.meta.url).href, shortCircuit: true };
}
return r;
},
});

48
dev/phaser-stub.mjs Normal file
View File

@ -0,0 +1,48 @@
// Headless harness: stubs Phaser just enough to load the real game modules
// (Mining, AsteroidCluster, Ship, Config, Color) and exercise the ore logic.
class GameObject {
constructor(scene, x = 0, y = 0) {
this.scene = scene;
this.x = x;
this.y = y;
this.rotation = 0;
this.alpha = 1;
this.scale = 1;
this.active = true;
this.children = [];
}
add(obj) { this.children.push(obj); return this; }
remove(obj) { this.children = this.children.filter((c) => c !== obj); return this; }
setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; }
setScale(v) { this.scale = v; return this; }
setTint(t) { this.tint = t; return this; }
setBlendMode() { return this; }
setPosition(x, y) { this.x = x; this.y = y; return this; }
setStrokeStyle() { return this; }
destroy() { this.active = false; this.destroyed = true; }
}
const Container = class extends GameObject {};
const Sprite = class extends GameObject {
constructor(scene, x, y, key, frame) { super(scene, x, y); this.key = key; this.frame = frame; this.body = null; }
};
function hexToRgbInt(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}
export default {
GameObjects: { Container, Sprite },
Physics: { Arcade: { Sprite } },
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
Math: {
Clamp: (v, a, b) => Math.min(b, Math.max(a, v)),
Linear: (a, b, t) => a + (b - a) * t,
Angle: {
Wrap: (a) => a,
RotateTo: (c, w, s) => (Math.abs(w - c) <= s ? w : c + Math.sign(w - c) * s),
},
},
BlendModes: { ADD: 'ADD' },
};

View File

@ -57,7 +57,7 @@ const fakeScene = () => ({
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
galaxy, galaxy,
systemRecord: { name: system.name }, systemRecord: { name: system.name },
ship: { x: 123.5, y: -77, rotation: 0.72 }, ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 },
discovery: new Discovery(540), discovery: new Discovery(540),
tetherField: { tetherField: {
tethers: [ tethers: [
@ -175,6 +175,7 @@ const makeStorage = (fail = false) => {
check('capture: seed carried', rec.seed === SEED); check('capture: seed carried', rec.seed === SEED);
check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name); check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name);
check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72); check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72);
check('capture: the hold is carried (ship.minerals)', rec.ship.minerals === 37);
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0')); check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost'); check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
check('capture: playtime carried', rec.playTimeMs === 123456); check('capture: playtime carried', rec.playTimeMs === 123456);
@ -193,6 +194,14 @@ const makeStorage = (fail = false) => {
reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false); reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false);
check('prepare: the live state is staged under the pending key', check('prepare: the live state is staged under the pending key',
staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456); staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456);
check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37);
// A LEGACY record (pre-minerals) still loads — no field, the restore
// side's `typeof === 'number'` guard keeps the ship at 0.
const regLegacy = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals
check('prepare: a legacy record (no minerals) still loads, field absent',
regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined);
// consumeRestore: exactly once. // consumeRestore: exactly once.
const first = consumeRestore(reg); const first = consumeRestore(reg);

View File

@ -72,6 +72,11 @@ export class AsteroidCluster extends Phaser.GameObjects.Container {
this.debrisPhase = record.debrisPhase ?? 0; this.debrisPhase = record.debrisPhase ?? 0;
this.debrisSpin = record.debrisSpin ?? -this.groupSpin * 0.5; // rad/s, signed this.debrisSpin = record.debrisSpin ?? -this.groupSpin * 0.5; // rad/s, signed
// Reused by runtime rock surgery (splits add rocks of a new size —
// the mining sequence, js/mining/Mining.js).
this.frameSize = Math.max(1, config.get('asteroids.frameWidth', 128));
this.tint = record.tint;
// --- Starlight halo (soft, behind the group) ------------------------- // --- Starlight halo (soft, behind the group) -------------------------
const haloCfg = config.get('asteroids.cluster.halo', {}); const haloCfg = config.get('asteroids.cluster.halo', {});
if (haloCfg.enabled !== false) { if (haloCfg.enabled !== false) {
@ -84,15 +89,9 @@ export class AsteroidCluster extends Phaser.GameObjects.Container {
} }
// --- The loose group: a container that drifts, holding each rock ---- // --- The loose group: a container that drifts, holding each rock ----
const frameSize = Math.max(1, config.get('asteroids.frameWidth', 128));
const tint = record.tint;
this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0); this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0);
for (const m of this.members) { for (const m of this.members) {
const img = scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame); this.groupBody.add(this._rockImage(m));
img.setScale((m.radius * 2) / frameSize); // 128 px rock at 1.0, 64 px at 0.5
if (tint) img.setTint(tint);
m.img = img;
this.groupBody.add(img);
} }
this.add(this.groupBody); this.add(this.groupBody);
@ -117,7 +116,7 @@ export class AsteroidCluster extends Phaser.GameObjects.Container {
*/ */
update(time) { update(time) {
const t = time / 1000; const t = time / 1000;
const rot = this.groupPhase + this.groupSpin * t; const rot = this.groupRotation(time);
this.groupBody.rotation = rot; this.groupBody.rotation = rot;
const cos = Math.cos(rot); const cos = Math.cos(rot);
const sin = Math.sin(rot); const sin = Math.sin(rot);
@ -135,6 +134,73 @@ export class AsteroidCluster extends Phaser.GameObjects.Container {
} }
} }
/** The group's drift rotation at `time` (rad) — update() uses this. */
groupRotation(time) {
return this.groupPhase + this.groupSpin * (time / 1000);
}
/** A WORLD-space delta as LOCAL group coords (the current drift undone). */
worldToLocalDelta(dx, dy, time) {
const rot = this.groupRotation(time);
const c = Math.cos(-rot);
const s = Math.sin(-rot);
return { x: dx * c - dy * s, y: dx * s + dy * c };
}
// ------------------------------------------------------------------
// Runtime rock surgery (the mining sequence splits and consumes rocks)
// ------------------------------------------------------------------
/**
* Rescale a rock in place: `sizePx` = its width = height (diameter px).
* The radius drives collision + the beam's impact point (both read the
* live member), the image scale drives the render so everything stays
* locked to the rock as it shrinks.
*/
setSize(m, sizePx) {
m.radius = Math.max(1, sizePx) / 2;
if (m.img) m.img.setScale((m.radius * 2) / this.frameSize);
}
/**
* Add a rock to the group (a split piece): same sheet frame, LOCAL px
* (group drift applies to it like every other member), its own slow
* spin. Returns the member record (the beam/scene read it live).
*/
addRock(frame, lx, ly, sizePx, spin = 0, phase = 0) {
const m = {
frame,
lx,
ly,
radius: Math.max(1, sizePx) / 2,
spin,
phase,
img: null,
wx: this.x,
wy: this.y,
};
this.groupBody.add(this._rockImage(m));
this.members.push(m);
return m;
}
/** Remove a rock (mined out): splice it from the members, kill its image. */
removeMember(m) {
const i = this.members.indexOf(m);
if (i !== -1) this.members.splice(i, 1);
m.img?.destroy();
m.img = null;
}
/** The render image for a member record (constructor + addRock share it). */
_rockImage(m) {
const img = this.scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame);
img.setScale((m.radius * 2) / this.frameSize); // 128 px rock at 1.0, 64 px at 0.5
if (this.tint) img.setTint(this.tint);
m.img = img;
return img;
}
/** /**
* Keep a ship out of every rock: the same keep-out circle rule as a * Keep a ship out of every rock: the same keep-out circle rule as a
* planet (ship may reach `clearance` edge-to-edge, never closer), * planet (ship may reach `clearance` edge-to-edge, never closer),

View File

@ -26,6 +26,10 @@ import { toColor } from '../utils/Color.js';
* - shields damage absorbed in front of the hull * - shields damage absorbed in front of the hull
* - cargoHold goods capacity * - cargoHold goods capacity
* - mineralStorage minerals capacity * - mineralStorage minerals capacity
* - miningSpeed multiplier on mining rate (1 = baseline)
*
* The hold (this.minerals / storageRoom / addMinerals): the minerals
* hauled aboard the mining beam loads it, capped at stats.mineralStorage.
*/ */
export class Ship extends Phaser.Physics.Arcade.Sprite { export class Ship extends Phaser.Physics.Arcade.Sprite {
static TEXTURE_KEY = '__ship'; static TEXTURE_KEY = '__ship';
@ -100,8 +104,14 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
shields: config.get('ship.stats.shields', 0), shields: config.get('ship.stats.shields', 0),
cargoHold: config.get('ship.stats.cargoHold', 100), cargoHold: config.get('ship.stats.cargoHold', 100),
mineralStorage: config.get('ship.stats.mineralStorage', 250), mineralStorage: config.get('ship.stats.mineralStorage', 250),
miningSpeed: config.get('ship.stats.miningSpeed', 1),
}; };
// The hold: minerals aboard right now (0 … stats.mineralStorage).
// The mining sequence (js/mining/Mining.js) loads it as the beam
// strips the rock; the cap is the base stat above.
this.minerals = 0;
// World size = size × scale. Sheet frames are frameWidth px wide; the // World size = size × scale. Sheet frames are frameWidth px wide; the
// dart is drawn at `size` px — the sprite scale maps either one onto // dart is drawn at `size` px — the sprite scale maps either one onto
// the same world size, so both look identical. // the same world size, so both look identical.
@ -151,6 +161,31 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
return true; return true;
} }
/** Room left in the hold (0 when full). */
storageRoom() {
return Math.max(0, this.stats.mineralStorage - this.minerals);
}
/**
* Load up to `n` minerals into the hold (capped at
* stats.mineralStorage). Returns the amount actually added callers
* see the excess as wasted when the hold was full.
*/
addMinerals(n) {
const take = Math.min(Math.max(0, Math.floor(n)), this.storageRoom());
if (take > 0) this.minerals += take;
return take;
}
/**
* Set the hold directly (the SAVE/LOAD path js/save/SaveData.js
* captureState writes it, GameScene.applyRestore restores it). Clamped
* to [0, stats.mineralStorage] a record can never overfill the hold.
*/
setMinerals(n) {
this.minerals = Math.max(0, Math.min(this.stats.mineralStorage, Math.round(Number(n) || 0)));
}
/** Set the destination to fly to (world coordinates). */ /** Set the destination to fly to (world coordinates). */
setTarget(x, y) { setTarget(x, y) {
this.target = { x, y }; this.target = { x, y };

View File

@ -1,5 +1,6 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { MiningBeam } from './MiningBeam.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 * The ship's mining state — the machine behind the beam. The SHIP'S
@ -24,25 +25,69 @@ import { MiningBeam } from './MiningBeam.js';
* retracting the beam is pulling back rock ship; the ship is already * retracting the beam is pulling back rock ship; the ship is already
* BACK TO 'normal' (FREE) the player can fly as it dies. * BACK TO 'normal' (FREE) the player can fly as it dies.
* *
* This class owns only the sequence + the beam's lifecycle. Scene * THE ORE (data/asteroids.json mining.economy): the rock IS its
* concerns (ship stop, toasts, sfx) ride the onPhase seam the same * minerals its size (width = height, px) at the START of mining is the
* pattern as TetherField's onChange. * 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 { export class Mining {
/** /**
* @param {Phaser.Scene} scene * @param {Phaser.Scene} scene
* @param {object} [o] * @param {object} [o]
* @param {Function} [o.onPhase] (phase) => void, phase 'extending' | 'mining' | 'stopped' * @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 = {}) { constructor(scene, o = {}) {
this.scene = scene; this.scene = scene;
this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null; 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.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting'
this.cluster = null; this.cluster = null;
this.member = null; this.member = null;
this.beam = null; this.beam = null;
this.stateT0 = 0; this.stateT0 = 0;
this.armExtendMs = config.get('asteroids.mining.armExtendMs', 1500); 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. */ /** The arm's sequence is live: mid-extension OR beam live. */
@ -63,6 +108,11 @@ export class Mining {
} }
this.cluster = cluster; this.cluster = cluster;
this.member = member; 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.state = 'extending';
this.stateT0 = this.scene.time.now; this.stateT0 = this.scene.time.now;
this._phase('extending'); this._phase('extending');
@ -88,7 +138,8 @@ export class Mining {
/** /**
* Per-frame (GameScene.update, after the clusters have refreshed their * Per-frame (GameScene.update, after the clusters have refreshed their
* rock positions the beam tracks them). Drives the extending * rock positions the beam tracks them). Drives the extending
* mining hand-off and reaps a finished retract. * mining hand-off, reaps a finished retract, extracts ore while the
* beam is steady, and flies any fragments home.
*/ */
update(time, delta) { update(time, delta) {
// The ship's state is the gate: the sequence (and its visuals) run // The ship's state is the gate: the sequence (and its visuals) run
@ -120,7 +171,233 @@ export class Mining {
} }
} }
} }
// 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) { _phase(phase) {
try { try {
@ -130,12 +407,26 @@ export class Mining {
} }
} }
_event(name, data) {
try {
this.onEvent?.(name, data);
} catch (err) {
console.error('[mining] onEvent handler failed', err);
}
}
destroy() { destroy() {
this.beam?.destroy(); this.beam?.destroy();
this.beam = null; this.beam = null;
for (const p of this.suck) p.img?.destroy();
this.suck.length = 0;
this.batchState = {};
this.state = 'idle'; this.state = 'idle';
this.cluster = null; this.cluster = null;
this.member = null; this.member = null;
this.rock = null;
this.oreAcc = 0;
this.onPhase = null; this.onPhase = null;
this.onEvent = null;
} }
} }

View File

@ -7,7 +7,7 @@
* *
* { app, format, savedAt, * { app, format, savedAt,
* seed, galaxyName, currentSystemId, systemName, * seed, galaxyName, currentSystemId, systemName,
* ship: { x, y, heading }, * ship: { x, y, heading, minerals },
* discovery: Discovery.toJSON(), * discovery: Discovery.toJSON(),
* reputation: Reputation.toJSON(), * reputation: Reputation.toJSON(),
* tethers: [{ id, x, y, level, label }], * tethers: [{ id, x, y, level, label }],
@ -56,6 +56,9 @@ export function captureState(scene) {
x: Number(scene.ship.x), x: Number(scene.ship.x),
y: Number(scene.ship.y), y: Number(scene.ship.y),
heading: Number(scene.ship.rotation), heading: Number(scene.ship.rotation),
// The hold (minerals aboard) — saves that predate it just lack the
// field; the restore treats a missing number as 0.
minerals: Math.max(0, Math.round(Number(scene.ship.minerals) || 0)),
}, },
discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} }, discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} },
reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(), reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(),

View File

@ -122,6 +122,7 @@ export class GameScene extends Phaser.Scene {
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3')); this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3')); this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3')); this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3')); this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3'));
this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3')); this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3'));
@ -394,6 +395,7 @@ export class GameScene extends Phaser.Scene {
// clicked. // clicked.
this.mining = new Mining(this, { this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p), onPhase: (p) => this.onMiningPhase(p),
onEvent: (name, data) => this.onMiningEvent(name, data),
}); });
this.miningPopup = new MiningPopup(this, { this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock), onAction: (id, rock) => this.miningAction(id, rock),
@ -1425,6 +1427,33 @@ export class GameScene extends Phaser.Scene {
} }
} }
/**
* Mining events (Mining onEvent): the ore's scene share the crack
* sfx (the new mining-02.mp3, once per break) and the console calls.
* 'split' the rock broke (data: kind 'absorb' = shattered into
* pieces that fly to the ship, 'divide' = split in half)
* 'absorbed' the shattered pieces hit the hull (data.gained minerals)
* 'storageFull' the hold is full the run ended (mining stops so the
* rock isn't ground away for nothing)
*/
onMiningEvent(name, data = {}) {
const neon = toCss(themeColor('neon', 0x00e5ff));
const magenta = toCss(themeColor('neon2', 0xff2d6f));
if (name === 'split') {
this.playSfx('mining_split'); // the crack — assets/fx/mining-02.mp3, once per break
this.consoleToast(
data.kind === 'absorb'
? `SHATTERED — ${data.pieces} × ${data.pieceSize} px PIECES`
: `SPLIT — ${data.pieces} × ${data.pieceSize} px ROCKS`,
{ glyph: '\u25c6', glyphColor: neon },
);
} else if (name === 'absorbed') {
this.consoleToast(`+${data.gained} MINERALS ABOARD`, { glyph: '\u2295', glyphColor: neon });
} else if (name === 'storageFull') {
this.consoleToast('MINERAL STORAGE FULL', { glyph: '!', glyphColor: magenta });
}
}
/** /**
* The mining hum (data/sfx.json mining_loop, assets/fx/mining-01.mp3): * The mining hum (data/sfx.json mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and * a loop that starts when the beam goes live (phase 'mining') and
@ -1751,15 +1780,19 @@ export class GameScene extends Phaser.Scene {
/** /**
* Apply a staged restore (js/save/SaveData.js prepareLoad parked it): * Apply a staged restore (js/save/SaveData.js prepareLoad parked it):
* the ship back where it was, the saved tether field (the level-1 home * the ship back where it was and with its hold full or empty as saved
* tether is just a saved entry the field is rebuilt from the record), * (ship.minerals) the saved tether field (the level-1 home tether is
* and the saved session time. Discovery + galaxy are already restored * just a saved entry the field is rebuilt from the record), and the
* in the registry (this scene's create() read them). * saved session time. Discovery + galaxy are already restored in the
* registry (this scene's create() read them).
*/ */
applyRestore(r) { applyRestore(r) {
if (r.ship) { if (r.ship) {
this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0); this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0);
if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading; if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading;
// The hold — saves that predate minerals have no field (stay at 0);
// setMinerals clamps to the ship's capacity.
if (typeof r.ship.minerals === 'number') this.ship.setMinerals(r.ship.minerals);
} }
if (Array.isArray(r.tethers) && r.tethers.length > 0) { if (Array.isArray(r.tethers) && r.tethers.length > 0) {
for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id); for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);