diff --git a/data/asteroids.json b/data/asteroids.json index caf83bc..910f6a5 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -9,7 +9,7 @@ "compassColor": "#c8d2e0", "shipClearance": 50, "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, "beam": { "inMs": 420, @@ -59,6 +59,15 @@ "gap": 8, "headerHeight": 20, "lift": 14 + }, + "economy": { + "ratePerSec": 1, + "splitLostFraction": 0.33, + "smallSizeMaxPx": 64, + "smallPieces": 4, + "pieceFraction": 0.25, + "suckSpeedPxPerSec": 460, + "suckStaggerMs": 70 } }, "cluster": { diff --git a/data/sfx.json b/data/sfx.json index b6bb8b9..26bc217 100644 --- a/data/sfx.json +++ b/data/sfx.json @@ -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('') plays the asset queued as 'sfx_'.", + "_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('') plays the asset queued as 'sfx_'. mining_split = the asteroid CRACKING when it breaks (shatters into pieces, or splits in half) — once per break.", "enabled": true, "volume": 1, "construct": "assets/fx/type-construct.mp3", @@ -7,6 +7,7 @@ "discovery": "assets/fx/discovery.mp3", "mining": "assets/fx/system-scan.mp3", "mining_loop": "assets/fx/mining-01.mp3", + "mining_split": "assets/fx/mining-02.mp3", "scan": "assets/fx/scan-01.mp3", "ui_hover": "assets/fx/ui-hover.mp3", "ui_click": "assets/fx/ui-click.mp3", diff --git a/data/ship.json b/data/ship.json index cb7de2e..ef56a06 100644 --- a/data/ship.json +++ b/data/ship.json @@ -17,7 +17,7 @@ "arriveRadius": 8, "arriveSpeed": 50, "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, "shields": 0, "cargoHold": 100, diff --git a/dev/mining.test.mjs b/dev/mining.test.mjs new file mode 100644 index 0000000..60953e4 --- /dev/null +++ b/dev/mining.test.mjs @@ -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.'); diff --git a/dev/phaser-loader.mjs b/dev/phaser-loader.mjs new file mode 100644 index 0000000..20f6fed --- /dev/null +++ b/dev/phaser-loader.mjs @@ -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; + }, +}); diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs new file mode 100644 index 0000000..43a2ed0 --- /dev/null +++ b/dev/phaser-stub.mjs @@ -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' }, +}; diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 03a5516..70f649e 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -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); } }, galaxy, 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), tetherField: { tethers: [ @@ -175,6 +175,7 @@ const makeStorage = (fail = false) => { check('capture: seed carried', rec.seed === SEED); 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: the hold is carried (ship.minerals)', rec.ship.minerals === 37); 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: playtime carried', rec.playTimeMs === 123456); @@ -193,6 +194,14 @@ const makeStorage = (fail = false) => { reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false); check('prepare: the live state is staged under the pending key', 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. const first = consumeRestore(reg); diff --git a/js/entities/AsteroidCluster.js b/js/entities/AsteroidCluster.js index 8c0eb75..10caae6 100644 --- a/js/entities/AsteroidCluster.js +++ b/js/entities/AsteroidCluster.js @@ -72,6 +72,11 @@ export class AsteroidCluster extends Phaser.GameObjects.Container { this.debrisPhase = record.debrisPhase ?? 0; 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) ------------------------- const haloCfg = config.get('asteroids.cluster.halo', {}); 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 ---- - const frameSize = Math.max(1, config.get('asteroids.frameWidth', 128)); - const tint = record.tint; this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0); for (const m of this.members) { - const img = scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame); - 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.groupBody.add(this._rockImage(m)); } this.add(this.groupBody); @@ -117,7 +116,7 @@ export class AsteroidCluster extends Phaser.GameObjects.Container { */ update(time) { const t = time / 1000; - const rot = this.groupPhase + this.groupSpin * t; + const rot = this.groupRotation(time); this.groupBody.rotation = rot; const cos = Math.cos(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 * planet (ship may reach `clearance` edge-to-edge, never closer), diff --git a/js/entities/Ship.js b/js/entities/Ship.js index 5c72ec1..c7eda36 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -27,6 +27,9 @@ import { toColor } from '../utils/Color.js'; * - cargoHold → goods 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 { static TEXTURE_KEY = '__ship'; @@ -104,6 +107,11 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { 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 // dart is drawn at `size` px — the sprite scale maps either one onto // the same world size, so both look identical. @@ -153,6 +161,31 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { 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). */ setTarget(x, y) { this.target = { x, y }; diff --git a/js/mining/Mining.js b/js/mining/Mining.js index 0117741..47dfd3d 100644 --- a/js/mining/Mining.js +++ b/js/mining/Mining.js @@ -1,5 +1,6 @@ import { config } from '../config/Config.js'; import { MiningBeam } from './MiningBeam.js'; +import { AsteroidCluster } from '../entities/AsteroidCluster.js'; /** * The ship's mining state — the machine behind the beam. The SHIP'S @@ -24,25 +25,69 @@ import { MiningBeam } from './MiningBeam.js'; * retracting the beam is pulling back rock → ship; the ship is already * BACK TO 'normal' (FREE) — the player can fly as it dies. * - * This class owns only the sequence + the beam's lifecycle. Scene - * concerns (ship stop, toasts, sfx) ride the onPhase seam — the same - * pattern as TetherField's onChange. + * THE ORE (data/asteroids.json → mining.economy): the rock IS its + * minerals — its size (width = height, px) at the START of mining is the + * minerals it holds (recorded in this.rock). While the beam is steady: + * - each second extracts `ratePerSec × ship.stats.miningSpeed` minerals: + * the rock SHRINKS by that many px (width and height — the beam reads + * the live member, so the impact point stays latched to the rim) and + * the same amount lands in the ship's hold (ship.addMinerals, capped + * at stats.mineralStorage — a full hold ends the run); + * - once the rock has lost ≥ splitLostFraction of its ORIGINAL size it + * BREAKS (sfx 'mining_split', the mining-02 crack): + * ≤ smallSizeMaxPx → it shatters into `smallPieces` of + * floor(size × pieceFraction) px each and ALL + * the pieces are sucked into the ship (their + * minerals land on impact, capped); + * > smallSizeMaxPx → it splits in HALF (floor(size/2) each): the + * latched rock becomes one half (fresh + * original size — it starts its own ledger) and + * a sibling rock appears beside it; the beam + * stays on the latched half and keeps mining. + * + * This class owns only the sequence + the beam's lifecycle + the ore. + * Scene concerns (ship stop, toasts, sfx) ride the onPhase / onEvent + * seams — the same pattern as TetherField's onChange. */ export class Mining { /** * @param {Phaser.Scene} scene * @param {object} [o] * @param {Function} [o.onPhase] — (phase) => void, phase ∈ 'extending' | 'mining' | 'stopped' + * @param {Function} [o.onEvent] — (name, data) => void: + * 'split' { kind: 'absorb' | 'divide', pieces, pieceSize } + * 'absorbed' { gained, pieces } — the shattered pieces hit the hull + * 'storageFull' — the hold is full; the run ended */ constructor(scene, o = {}) { this.scene = scene; this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null; + this.onEvent = typeof o.onEvent === 'function' ? o.onEvent : null; this.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting' this.cluster = null; this.member = null; this.beam = null; this.stateT0 = 0; this.armExtendMs = config.get('asteroids.mining.armExtendMs', 1500); + + // The ore ledger (see the class note): the rock's size at the start + // of mining IS its minerals; 'original' fixes the break threshold. + this.rock = null; // { original, size } + this.oreAcc = 0; // fractional minerals extracted (< 1) + this.suck = []; // fragments in flight to the hull (outlive the beam) + this.batchSeq = 0; // one per shatter — 'absorbed' fires when a batch is DONE + this.batchState = {}; // batch → { landed, pieces, total } (per-batch totals) + + // Tuning (data/asteroids.json → mining.economy). + const eco = config.section('asteroids.mining.economy', {}); + this.mineralsPerSec = eco.ratePerSec ?? 1; + this.splitLostFraction = eco.splitLostFraction ?? 0.33; // ~1/3 of the original + this.smallSizeMaxPx = eco.smallSizeMaxPx ?? 64; + this.smallPieces = Math.max(1, Math.floor(eco.smallPieces ?? 4)); + this.pieceFraction = eco.pieceFraction ?? 0.25; + this.suckSpeed = eco.suckSpeedPxPerSec ?? 460; // px/s, fragments → hull + this.suckStaggerMs = eco.suckStaggerMs ?? 70; + this.rockFrameSize = Math.max(1, config.get('asteroids.frameWidth', 128)); } /** The arm's sequence is live: mid-extension OR beam live. */ @@ -63,6 +108,11 @@ export class Mining { } this.cluster = cluster; this.member = member; + // Fresh ledger for this rock: its size (width = height, px) NOW is + // the minerals it holds. 'original' anchors the break threshold — + // the rock breaks once it has lost ~1/3 of THIS size. + this.rock = { original: member.radius * 2, size: member.radius * 2 }; + this.oreAcc = 0; this.state = 'extending'; this.stateT0 = this.scene.time.now; this._phase('extending'); @@ -88,7 +138,8 @@ export class Mining { /** * Per-frame (GameScene.update, after the clusters have refreshed their * 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) { // The ship's state is the gate: the sequence (and its visuals) run @@ -120,8 +171,234 @@ 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) { try { this.onPhase?.(phase); @@ -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() { this.beam?.destroy(); this.beam = null; + for (const p of this.suck) p.img?.destroy(); + this.suck.length = 0; + this.batchState = {}; this.state = 'idle'; this.cluster = null; this.member = null; + this.rock = null; + this.oreAcc = 0; this.onPhase = null; + this.onEvent = null; } } diff --git a/js/save/SaveData.js b/js/save/SaveData.js index bc83006..33598c4 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -7,7 +7,7 @@ * * { app, format, savedAt, * seed, galaxyName, currentSystemId, systemName, - * ship: { x, y, heading }, + * ship: { x, y, heading, minerals }, * discovery: Discovery.toJSON(), * reputation: Reputation.toJSON(), * tethers: [{ id, x, y, level, label }], @@ -56,6 +56,9 @@ export function captureState(scene) { x: Number(scene.ship.x), y: Number(scene.ship.y), 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: {} }, reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(), diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 1344661..6ab3466 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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_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_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_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')); @@ -394,6 +395,7 @@ export class GameScene extends Phaser.Scene { // clicked. this.mining = new Mining(this, { onPhase: (p) => this.onMiningPhase(p), + onEvent: (name, data) => this.onMiningEvent(name, data), }); this.miningPopup = new MiningPopup(this, { 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): * 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): - * the ship back where it was, the saved tether field (the level-1 home - * tether is just a saved entry — the field is rebuilt from the record), - * and the saved session time. Discovery + galaxy are already restored - * in the registry (this scene's create() read them). + * the ship back where it was — and with its hold full or empty as saved + * (ship.minerals) — the saved tether field (the level-1 home tether is + * just a saved entry — the field is rebuilt from the record), and the + * saved session time. Discovery + galaxy are already restored in the + * registry (this scene's create() read them). */ applyRestore(r) { if (r.ship) { 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; + // 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) { for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);