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.
This commit is contained in:
parent
fdd5f8a506
commit
520cba85f6
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_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": 1,
|
"volume": 1,
|
||||||
"construct": "assets/fx/type-construct.mp3",
|
"construct": "assets/fx/type-construct.mp3",
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"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; miningSpeed = multiplier on mining rate (1 = baseline). 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,
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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' },
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import { toColor } from '../utils/Color.js';
|
||||||
* - cargoHold → goods capacity
|
* - cargoHold → goods capacity
|
||||||
* - mineralStorage → minerals capacity
|
* - mineralStorage → minerals capacity
|
||||||
* - miningSpeed → multiplier on mining rate (1 = baseline)
|
* - 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';
|
||||||
|
|
@ -104,6 +107,11 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
|
||||||
miningSpeed: config.get('ship.stats.miningSpeed', 1),
|
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.
|
||||||
|
|
@ -153,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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue