Add asteroid mining: energy arm, beam FX, and context menu

- Click a rock to open a world-anchored mining popup (Mine/Stop/Cancel)
- Mining state machine locks the ship during the ~1.5 s arm extension, then fires an animated beam with ore motes, shock rings, sparks, and periodic surges
- ESC breaks the beam or closes the menu; outside clicks dismiss without flying
- Generalize the top-center console toast so discovery, tether, and mining calls share one slot
- Add a "mining" SFX entry (system-scan.mp3) and update preload/test to cover all configured sounds
- Ship a headless browser test (dev/mining-test.*) that drives the full arm sequence through the real input pipeline
This commit is contained in:
Brian Fertig 2026-09-04 17:09:16 -06:00
parent 2bad1f1e84
commit b4b656f6e4
11 changed files with 1637 additions and 70 deletions

Binary file not shown.

View File

@ -8,6 +8,59 @@
"typeLabel": "Asteroid Cluster",
"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).",
"armExtendMs": 1500,
"beam": {
"inMs": 420,
"outMs": 380,
"glowWidth": 30,
"glowColor": "#2f7bff",
"glowAlpha": 0.1,
"midWidth": 15,
"midColor": "#00b3ff",
"midAlpha": 0.22,
"mainWidth": 7,
"mainColor": "#00e5ff",
"mainAlpha": 0.5,
"coreWidth": 2.6,
"coreColor": "#ffffff",
"coreAlpha": 0.95,
"ghostOffset": 2.6,
"ghostCyan": "#00e5ff",
"ghostMag": "#ff2d6f",
"ghostAlpha": 0.2
},
"ore": {
"maxParticles": 28,
"spawnEveryMs": [55, 110],
"speedPxPerSec": [140, 280],
"wobblePx": [1.5, 4],
"sizePx": [5, 12],
"tailPx": [8, 16]
},
"impact": {
"glowScale": 0.6,
"glowMinPx": 30,
"coreSizePx": 12,
"ringEveryMs": [550, 1100],
"sparkEveryMs": [90, 240]
},
"surge": {
"enabled": true,
"everyMs": [1800, 4200],
"durationMs": 320,
"bandLen": 46
},
"popup": {
"width": 212,
"padTop": 10,
"padBottom": 12,
"gap": 8,
"headerHeight": 20,
"lift": 14
}
},
"cluster": {
"groupSize": { "min": 4, "max": 8 },
"sizes": { "min": 64, "max": 128 },

View File

@ -4,7 +4,7 @@
"width": 1280,
"height": 720,
"backgroundColor": "#04060d",
"hintText": "click anywhere to fly · the tether holds your range",
"hintText": "click anywhere to fly · click a rock to mine · the tether holds your range",
"markerColor": "#41c7ff",
"physics": {
"default": "arcade"

View File

@ -1,8 +1,9 @@
{
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view. (system-scan.mp3 is unassigned — drop a key like \"scan\" here and wire it in GameScene when used.)",
"_comment": "Sound effects. enabled = master switch (also skips the load). volume is 0..1. construct = the dossier typing in; deconstruct = the dossier deconstructing; discovery = a new object coming into view; mining = the mining arm powering up (the 'Extending Mining Arm...' reach).",
"enabled": true,
"volume": 0.55,
"construct": "assets/fx/type-construct.mp3",
"deconstruct": "assets/fx/type-deconstruct.mp3",
"discovery": "assets/fx/discovery.mp3"
"discovery": "assets/fx/discovery.mp3",
"mining": "assets/fx/system-scan.mp3"
}

29
dev/mining-test.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev mining flow test</title>
<!-- This page lives in /dev, but the game's relative asset paths are
rooted at the project root — resolve them against it. -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script>
window.__CAPTURED_ERRORS__ = [];
window.__CAPTURED_LOGS__ = [];
const __oe = console.error.bind(console);
const __ol = console.log.bind(console);
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
console.log = (...a) => { window.__CAPTURED_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __ol(...a); };
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/mining-test.mjs"></script>
</body>
</html>

471
dev/mining-test.mjs Normal file
View File

@ -0,0 +1,471 @@
/**
* Mining flow (headless browser NOT a Node test).
*
* Drives the REAL game's input pipeline (mousedown/keydown on the canvas)
* and walks the whole arm sequence against a seeded galaxy:
*
* click a rock the menu opens at the click (MINE ASTEROIDS)
* a click OUTSIDE it closes, and does not fly the ship
* click MINE ASTEROIDS "extending" (ship locked, console toast
* "Extending Mining Arm...")
* a world click meanwhile is held (no fly, no abort)
* the ~1.5 s reach "mining": the beam is live, ore motes ride it
* click the rock again the menu now says STOP MINING
* click STOP MINING the beam retracts, then the arm is idle
* ESC while mining breaks the beam
* ESC with the menu open closes it
* click CANCEL closes, no mining
*
* HEADLESS PUMP: this box's headless Firefox freezes rAF and timers once
* the first paint is done, so the page can't run itself. The runner
* (dev/cdp-firefox.mjs) keeps waking the JS thread with execute/sync
* so the whole flow is a synchronous stage machine, and each wake calls
* `window.__MINING_PUMP__()`, which (a) steps the Phaser loop manually
* (`game.loop.step(performance.now())` one frame per call, which also
* processes the queued pointer/keyboard events) and (b) advances the
* next stage. Long game-time waits (the 1.5 s arm extension) may span
* many runner polls the stage machine RESUMES where it left off.
* Results land in `window.__MINING__`:
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/mining-test.html \
* 'window.__MINING_PUMP__(); return window.__MINING__;'
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
// A deterministic galaxy (same clusters every run) + quiet audio in headless.
globalThis.__ORBIT_DEV_SEED = 'MINING';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
// ----------------------------------------------------------------------
// Results + checks
// ----------------------------------------------------------------------
const results = [];
const check = (label, cond) => {
const pass = !!cond;
results.push({ label, pass });
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
};
// ----------------------------------------------------------------------
// Manual frame pump (the only clock this box has)
// ----------------------------------------------------------------------
const pumpErrors = [];
const pump = (maxSteps = 20, cpuBudgetMs = 700) => {
const g = window.game;
if (!g || !g.loop) return 0;
const t0 = performance.now();
let n = 0;
while (n < maxSteps) {
if (performance.now() - t0 > cpuBudgetMs) break;
try {
if (g.input && typeof g.input.update === 'function') g.input.update();
g.loop.step(performance.now());
} catch (e) {
pumpErrors.push(`pump: ${String((e && e.message) || e)}`);
break;
}
n++;
}
return n;
};
/**
* Resumable wait. Returns:
* true `pred()` is met
* false `inProgress()` stopped holding, or the wait outlived
* `maxTotalMs` (wall clock) the wait FAILED
* null the CPU budget for THIS wake is spent but `inProgress()`
* still holds the stage must `return`; the runner's next
* wake re-enters the same stage and keeps waiting.
*/
let stageSince = -1;
let stageSinceMs = 0;
const wait = (pred, inProgress, note, cpuBudgetMs = 1200, maxTotalMs = 60000) => {
if (stageSince !== stage) { stageSince = stage; stageSinceMs = performance.now(); }
const t0 = performance.now();
for (;;) {
if (pred()) {
if (note) note();
return true;
}
if (!inProgress()) return false;
if (performance.now() - stageSinceMs > maxTotalMs) return false;
if (performance.now() - t0 > cpuBudgetMs) return null;
pump(8, 120);
}
};
// ----------------------------------------------------------------------
// Input (real events on the canvas / window)
// ----------------------------------------------------------------------
const press = (x, y) => {
const canvas = game.canvas;
const r = canvas.getBoundingClientRect();
const zoom = r.width / canvas.width;
const mk = (type, buttons) => new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
button: 0,
buttons,
clientX: r.left + x * zoom,
clientY: r.top + y * zoom,
});
canvas.dispatchEvent(mk('mouseover', 0));
canvas.dispatchEvent(mk('mousedown', 1));
canvas.dispatchEvent(mk('mouseup', 0));
};
const esc = () =>
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}));
/** World → canvas screen coords (zoom 1, no rotation: screen = world scroll). */
const w2s = (wx, wy) => {
const c = game.scene.getScene('GameScene').cameras.main;
return { x: wx - c.scrollX, y: wy - c.scrollY };
};
const btnScreen = (btn) => {
const s = game.scene.getScene('GameScene');
return w2s(s.miningPopup.x + btn.x, s.miningPopup.y + btn.y);
};
const rockScreen = () => {
const s = game.scene.getScene('GameScene');
return w2s(rock.wx, rock.wy);
};
const openSpaceScreen = () => {
const s = game.scene.getScene('GameScene');
return { x: s.scale.width / 2, y: s.scale.height / 2 };
};
// ----------------------------------------------------------------------
// The stage machine (resumable across runner polls)
// ----------------------------------------------------------------------
let stage = 0;
let cluster = null;
let rock = null;
let finished = false;
let sawExtendingToast = false;
const finish = (pass) => {
if (finished) return;
finished = true;
const all = [...results];
if (pumpErrors.length) {
for (const e of pumpErrors) all.push({ label: e, pass: false });
}
const errors = (window.__CAPTURED_ERRORS__ || []).concat(pumpErrors);
if (errors.length === 0) all.push({ label: 'no console errors were captured', pass: true });
const ok = all.every((r) => r.pass);
window.__MINING__ = { pass: ok, results: all, errors };
console.log(ok ? 'MINING PASS' : 'MINING FAIL');
};
const scene = () => game.scene.getScene('GameScene');
const fail = (label) => {
check(label, false);
finish(false);
};
const stepMachine = () => {
let s = null;
try { s = scene(); } catch { /* not booted yet */ }
if (!s || !s.ship || !s.mining || !s.miningPopup) {
pump(10, 400); // let the scene manager finish booting
return;
}
// Small helper for this scene: pump a little so a dispatched input
// event is processed + a couple of frames of reaction settle.
const settle = (ms = 250) => pump(10, ms);
switch (stage) {
// ---- 0) park the ship by a rock, click the rock ---------------------
case 0: {
if (!s.asteroidClusters || s.asteroidClusters.length === 0) {
fail('the seeded system has asteroid clusters');
return;
}
cluster = s.asteroidClusters[0];
rock = cluster.members[0];
s.ship.stop();
const sx = rock.wx - 190;
s.ship.setPosition(sx, rock.wy);
s.cameras.main.setScroll(
(sx + rock.wx) / 2 - s.scale.width / 2,
rock.wy - s.scale.height / 2,
);
settle(200);
press(rockScreen().x, rockScreen().y);
stage = 1;
return;
}
// ---- 1) the menu opened at the click ---------------------------------
case 1: {
const r = wait(() => s.miningPopup.isOpen === true,
() => true, // a click either opens the menu or it never will
null, 1500, 20000);
if (r === null) return;
if (!r) fail('a click on a rock opens the mining menu');
check('a click on a rock opens the mining menu', true);
check('the primary button says MINE ASTEROIDS',
s.miningPopup.primaryBtn.labelText.text === 'MINE ASTEROIDS');
check('the header names the cluster',
s.miningPopup.headerText.text.toUpperCase().includes(String(cluster.discoveryName).toUpperCase()));
check('the rock click did NOT fly the ship', s.ship.target === null);
check('the rock click did NOT start mining', s.mining.state === 'idle');
press(openSpaceScreen().x, openSpaceScreen().y); // open space — outside the panel
stage = 2;
return;
}
// ---- 2) the outside click closed it, without flying ------------------
case 2: {
const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('a click outside the menu closes it');
check('a click outside the menu closes it', true);
check('the dismissing click does not fly the ship', s.ship.target === null);
check('the dismissing click does not start mining', s.mining.state === 'idle');
press(rockScreen().x, rockScreen().y); // re-open
stage = 3;
return;
}
// ---- 3) press MINE ASTEROIDS -----------------------------------------
case 3: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('the menu re-opens on the rock');
check('the menu re-opens on the rock', true);
const b = btnScreen(s.miningPopup.primaryBtn);
press(b.x, b.y);
stage = 4;
return;
}
// ---- 4) extending: locked + the console call --------------------------
case 4: {
const r = wait(
() => s.mining.state === 'extending' || s.mining.state === 'mining',
() => ['extending', 'mining'].includes(s.mining.state) || s.mining.state === 'idle',
() => {
const t = (s.consoleToastG || []).map((x) => x.text).join(' ');
if (/extending mining arm/i.test(t)) sawExtendingToast = true;
},
1200, 60000);
if (r === null) return;
if (!r) fail('MINE ASTEROIDS starts the extension');
check('MINE ASTEROIDS starts the extension', true);
check('the menu closed on the button press', s.miningPopup.isOpen === false);
check('the ship is locked while the arm extends',
s.ship.target === null && s.mining.isLocked === true);
if (!sawExtendingToast) {
// One more chance while the arm is still out (the toast holds the
// whole extension window).
const r2 = wait(
() => s.mining.state === 'mining' || /extending mining arm/i.test((s.consoleToastG || []).map((x) => x.text).join(' ')),
() => s.mining.state !== 'idle',
null, 1200, 60000);
if (r2 === null) { stage = 4; return; }
sawExtendingToast = /extending mining arm/i.test((s.consoleToastG || []).map((x) => x.text).join(' ')) || sawExtendingToast;
}
check('the console says Extending Mining Arm...', sawExtendingToast);
// A world click mid-extension holds (no fly, no abort).
if (s.mining.state === 'extending') press(openSpaceScreen().x, openSpaceScreen().y);
stage = 5;
return;
}
// ---- 5) the reach completes into mining --------------------------------
case 5: {
const r = wait(() => s.mining.state === 'mining',
() => s.mining.state === 'extending' || s.mining.state === 'mining',
null, 1200, 90000);
if (r === null) return;
if (!r) fail('the extension completes into mining');
check('the extension completes into mining', true);
check('clicks held while the arm extends (no fly)', s.ship.target === null);
check('the beam is live', !!s.mining.beam && (s.mining.beam.state === 'in' || s.mining.beam.state === 'steady'));
check('the beam is locked to the clicked rock',
!!s.mining.beam && s.mining.beam.member === rock && s.mining.member === rock);
// Let the beam settle to steady + the ore stream build.
const r2 = wait(
() => !!s.mining.beam && s.mining.beam.state === 'steady' && s.mining.beam.particles.some((p) => p.active),
() => !!s.mining.beam && s.mining.state === 'mining',
null, 1200, 90000);
if (r2 === null) return;
if (!r2) fail('the beam settles and ore motes ride it');
check('the beam is live and steady', true);
check('ore motes ride the beam toward the ship', true);
press(rockScreen().x, rockScreen().y); // the beam target, again
stage = 6;
return;
}
// ---- 6) the target menu now offers STOP MINING ------------------------
case 6: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('the menu opens on the mining target');
check('the menu opens on the mining target', true);
check('the primary button now says STOP MINING',
s.miningPopup.primaryBtn.labelText.text === 'STOP MINING');
const b = btnScreen(s.miningPopup.primaryBtn);
press(b.x, b.y);
stage = 7;
return;
}
// ---- 7) stop → retract --------------------------------------------------
case 7: {
const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle',
() => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return;
if (!r) fail('STOP MINING retracts the beam');
check('STOP MINING retracts the beam', true);
check('the ship is free again while it retracts', s.mining.isLocked === false);
stage = 8;
return;
}
// ---- 8) the beam is reaped and the arm is idle ---------------------------
case 8: {
const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null,
() => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return;
if (!r) fail('the beam is reaped and the arm is idle');
check('the beam is reaped and the arm is idle', true);
press(rockScreen().x, rockScreen().y); // mine again (ESC test)
stage = 9;
return;
}
// ---- 9) menu again; start a second mining -------------------------------
case 9: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('the menu opens again (ESC test)');
const b = btnScreen(s.miningPopup.primaryBtn);
press(b.x, b.y);
stage = 10;
return;
}
// ---- 10) mining is live again; ESC breaks it ------------------------------
case 10: {
const r = wait(() => s.mining.state === 'mining',
() => ['extending', 'mining'].includes(s.mining.state), null, 1200, 90000);
if (r === null) return;
if (!r) fail('mining is live again (ESC test)');
check('mining is live again (ESC test)', true);
esc();
stage = 11;
return;
}
// ---- 11) the ESC stop landed ----------------------------------------------
case 11: {
const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle',
() => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return;
if (!r) fail('ESC breaks the beam');
check('ESC breaks the beam', true);
stage = 12;
return;
}
// ---- 12) arm idle after the ESC stop ---------------------------------------
case 12: {
const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null,
() => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return;
if (!r) fail('the arm is idle after the ESC stop');
check('the arm is idle after the ESC stop', true);
press(rockScreen().x, rockScreen().y); // menu again (ESC-closes test)
stage = 13;
return;
}
// ---- 13) ESC closes an open menu ---------------------------------------------
case 13: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('the menu opens again (ESC-closes test)');
esc();
stage = 14;
return;
}
// ---- 14) the menu closed; now CANCEL ------------------------------------------
case 14: {
const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('ESC closes the open menu');
check('ESC closes the open menu', true);
check('...and did not start mining', s.mining.state === 'idle');
press(rockScreen().x, rockScreen().y);
stage = 15;
return;
}
// ---- 15) CANCEL closes without mining --------------------------------------------
case 15: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('the menu opens again (CANCEL test)');
const c = btnScreen(s.miningPopup.cancelBtn);
press(c.x, c.y);
stage = 16;
return;
}
// ---- 16) done ----------------------------------------------------------------------
case 16: {
const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000);
if (r === null) return;
if (!r) fail('CANCEL closes the menu');
check('CANCEL closes the menu', true);
check('CANCEL does not start mining', s.mining.state === 'idle');
check('CANCEL does not fly the ship', s.ship.target === null);
finish(results.every((x) => x.pass));
return;
}
}
};
window.__MINING_PUMP__ = () => {
try {
stepMachine();
} catch (e) {
(window.__CAPTURED_ERRORS__ || (window.__CAPTURED_ERRORS__ = [])).push(`stage: ${String((e && e.stack) || e)}`);
check(`THREW: ${String((e && e.message) || e)}`, false);
finish(false);
}
};

View File

@ -423,9 +423,11 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
// ===========================================================================
{
const sfxJson = config.section('sfx');
// Every sound name in data/sfx.json (the string-valued keys, excluding the _comment).
const sfxNames = Object.keys(sfxJson).filter((k) => k !== '_comment' && typeof sfxJson[k] === 'string');
const s = makeScene();
s.played = [];
s.audio = { sfx_construct: true, sfx_deconstruct: true, sfx_discovery: true }; // the real preload() puts them here
s.audio = Object.fromEntries(sfxNames.map((n) => [`sfx_${n}`, true])); // the real preload() puts them here
s.audioQueued = [];
s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) };
s.cache = { hasAudio: (key) => s.audio[key] === true };
@ -443,18 +445,14 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
destroy() { this.destroyed = true; return this; },
});
// preload: the three configured SFX at their configured paths.
// preload: every configured SFX at its configured path.
GameScene.prototype.preload.call(s);
const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key);
check('preload loads the three configured SFX',
s.audioQueued.length === 3 &&
queuedKey('sfx_construct')?.[1] === sfxJson.construct &&
queuedKey('sfx_deconstruct')?.[1] === sfxJson.deconstruct &&
queuedKey('sfx_discovery')?.[1] === sfxJson.discovery);
check('preload loads every configured SFX',
s.audioQueued.length === sfxNames.length &&
sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n]));
check('the configured SFX files exist in the repo',
[sfxJson.construct, sfxJson.deconstruct, sfxJson.discovery].every(
(p) => fs.existsSync(join(__dirname, '..', p)),
));
sfxNames.every((n) => fs.existsSync(join(__dirname, '..', sfxJson[n]))));
// arrival → the construct SFX (once, at the configured volume).
GameScene.prototype.createSystemHud.call(s);

121
js/mining/Mining.js Normal file
View File

@ -0,0 +1,121 @@
import { config } from '../config/Config.js';
import { MiningBeam } from './MiningBeam.js';
/**
* The ship's mining state the machine behind the beam:
*
* idle begin() extending (armExtendMs) mining stop() retracting
*
* (stop() cuts the extension) beam out
*
*
* extending the arm is reaching for the rock the ship is LOCKED
* (GameScene stops it) and the world holds;
* mining the beam is live (MiningBeam) the ship stays LOCKED at
* its rim; retargeting (begin on another rock) cuts the
* current beam and re-extends;
* retracting the beam is pulling back rock ship; the ship is already
* FREE (isLocked is false) 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.
*/
export class Mining {
/**
* @param {Phaser.Scene} scene
* @param {object} [o]
* @param {Function} [o.onPhase] (phase) => void, phase 'extending' | 'mining' | 'stopped'
*/
constructor(scene, o = {}) {
this.scene = scene;
this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : 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 ship may not fly: mid-extension OR beam live. */
get isLocked() {
return this.state === 'extending' || this.state === 'mining';
}
/**
* Start mining `cluster`'s rock `member` (from the pop-up's
* "Mine Asteroids"). Cuts any live/retracting beam first (retarget).
* Returns false if already extending the arm is committed.
*/
begin(cluster, member) {
if (this.state === 'extending') return false;
if (this.state === 'mining' || this.state === 'retracting') {
this.beam?.destroy();
this.beam = null;
}
this.cluster = cluster;
this.member = member;
this.state = 'extending';
this.stateT0 = this.scene.time.now;
this._phase('extending');
return true;
}
/**
* Stop mining (the pop-up's "Stop Mining", a world click, or ESC):
* mid-extension abort; beam live it retracts (retracting).
*/
stop() {
if (this.state === 'extending') {
this.state = 'idle';
this._phase('stopped');
return;
}
if (this.state === 'mining' && this.beam && !this.beam.outRunning) {
this.state = 'retracting';
this.beam.beginOut(this.scene.time.now);
}
}
/**
* Per-frame (GameScene.update, after the clusters have refreshed their
* rock positions the beam tracks them). Drives the extending
* mining hand-off and reaps a finished retract.
*/
update(time, delta) {
if (this.state === 'extending' && time >= this.stateT0 + this.armExtendMs) {
this.state = 'mining';
this.beam = new MiningBeam(this.scene, this);
this._phase('mining');
}
if (this.beam) {
this.beam.update(time, delta);
if (this.beam.finished) {
this.beam.destroy();
this.beam = null;
if (this.state === 'retracting') {
this.state = 'idle';
this._phase('stopped');
}
}
}
}
_phase(phase) {
try {
this.onPhase?.(phase);
} catch (err) {
console.error('[mining] onPhase handler failed', err);
}
}
destroy() {
this.beam?.destroy();
this.beam = null;
this.state = 'idle';
this.cluster = null;
this.member = null;
this.onPhase = null;
}
}

506
js/mining/MiningBeam.js Normal file
View File

@ -0,0 +1,506 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
const SOFT_KEY = '__mining_soft';
/**
* A soft radial-falloff dot (white, 64 px) the beam's light "brush".
* Generated once per scene (texture FIRST, then images the v4
* __MISSING quirk), tinted per use. Same recipe as the cluster halo.
*/
function ensureSoftTexture(scene) {
if (scene.textures.exists(SOFT_KEY)) return;
const S = 64;
const C = S / 2;
const g = scene.make.graphics({ add: false });
const steps = 22;
for (let i = steps; i >= 1; i--) {
const t = i / steps;
g.fillStyle(0xffffff, Math.pow(1 - t, 2.1) * 0.9);
g.fillCircle(C, C, C * t);
}
g.generateTexture(SOFT_KEY, S, S);
g.destroy();
}
/**
* The mining beam the ship's energy arm latched onto a rock.
*
* What it is, layer by layer (all world space, redrawn per frame with
* the TetherField graphics pattern two Graphics passes + soft-light
* sprites on top):
*
* in the beam EXTENDS from the ship's hull to the rock (a bright
* head races the line), then slams on: arrival flash, shock
* rings, and the first ore stream leaves the surface.
* steady a white core in a blue glow, with the console's RGB channel
* fringe split off to the sides; the surface stays molten
* (hot core + pulsing halo); ore motes stream along the beam
* asteroid ship (each with a wobble and a tail) and are
* absorbed at the hull (the emitter flashes); shock rings
* peel off the impact point; sparks chip off the rock; and
* every few seconds a SURGE a bright packet of ore rides
* the beam home while the whole arm brightens.
* out the beam retracts rock ship and the whole thing dies in
* ~380 ms (the "retracting arm" state of the ship).
*
* The beam tracks its target every frame: the rock keeps drifting
* (AsteroidCluster.update refreshes m.wx/m.wy) and the ship may be held
* at a rim the geometry is re-derived from live positions, never
* cached.
*
* Tuning: data/asteroids.json mining (beam/ore/impact/surge blocks).
*/
export class MiningBeam {
/**
* @param {Phaser.Scene} scene
* @param {object} mining the Mining controller (its .member is the rock)
*/
constructor(scene, mining) {
this.scene = scene;
this.ship = scene.ship;
this.member = mining.member;
const cfg = config.section('asteroids.mining', {});
const b = cfg.beam ?? {};
const ore = cfg.ore ?? {};
const imp = cfg.impact ?? {};
const sur = cfg.surge ?? {};
this.inMs = b.inMs ?? 420;
this.outMs = b.outMs ?? 380;
this.glowWidth = b.glowWidth ?? 30;
this.glowColor = toColor(b.glowColor, 0x2f7bff);
this.glowAlpha = b.glowAlpha ?? 0.1;
this.midWidth = b.midWidth ?? 15;
this.midColor = toColor(b.midColor, 0x00b3ff);
this.midAlpha = b.midAlpha ?? 0.22;
this.mainWidth = b.mainWidth ?? 7;
this.mainColor = toColor(b.mainColor, 0x00e5ff);
this.mainAlpha = b.mainAlpha ?? 0.5;
this.coreWidth = b.coreWidth ?? 2.6;
this.coreColor = toColor(b.coreColor, 0xffffff);
this.coreAlpha = b.coreAlpha ?? 0.95;
this.ghostOffset = b.ghostOffset ?? 2.6;
this.ghostCyan = toColor(b.ghostCyan, 0x00e5ff);
this.ghostMag = toColor(b.ghostMag, 0xff2d6f);
this.ghostAlpha = b.ghostAlpha ?? 0.2;
this.oreMax = Math.max(4, ore.maxParticles ?? 28);
this.oreSpawn = Array.isArray(ore.spawnEveryMs) ? ore.spawnEveryMs : [55, 110];
this.oreSpeed = Array.isArray(ore.speedPxPerSec) ? ore.speedPxPerSec : [140, 280];
this.oreWobble = Array.isArray(ore.wobblePx) ? ore.wobblePx : [1.5, 4];
this.oreSize = Array.isArray(ore.sizePx) ? ore.sizePx : [5, 12];
this.oreTail = Array.isArray(ore.tailPx) ? ore.tailPx : [8, 16];
this.impactGlowScale = imp.glowScale ?? 0.6; // × rock radius
this.impactGlowMin = imp.glowMinPx ?? 30;
this.impactCoreSize = imp.coreSizePx ?? 12;
this.ringEvery = Array.isArray(imp.ringEveryMs) ? imp.ringEveryMs : [550, 1100];
this.sparkEvery = Array.isArray(imp.sparkEveryMs) ? imp.sparkEveryMs : [90, 240];
this.surgeEnabled = sur.enabled !== false;
this.surgeEvery = Array.isArray(sur.everyMs) ? sur.everyMs : [1800, 4200];
this.surgeDur = sur.durationMs ?? 320;
this.surgeBand = sur.bandLen ?? 46;
ensureSoftTexture(scene);
// ---- the two graphics passes (the TetherField pattern) -------------
this.gUnder = scene.add.graphics().setDepth(11).setBlendMode(Phaser.BlendModes.ADD);
this.gOver = scene.add.graphics().setDepth(12);
// ---- the soft-light sprites (radial texture, additive) -------------
this.impactGlow = this._soft(0xcfeaff, 11);
this.impactCore = this._soft(0xffffff, 12.5);
this.emitter = this._soft(0x9fd8ff, 12.5);
this.head = this._soft(0xffffff, 12.5).setAlpha(0);
// ---- particle / event state ----------------------------------------
this.particles = []; // { active, u, t0, v, amp, phase, size, tail, tint, dot, px, py, pa }
this.rings = []; // { t0, dur, r0, r1, w, a, color }
this.sparks = []; // { t0, dur, x, y, vx, vy, size, color }
this.surge = null; // { t0 }
this.nextOre = 0;
this.nextRing = 0;
this.nextSpark = 0;
this.nextSurge = 0;
this.impactPulse = 0; // decaying flash at the rock (arrival / surges)
this.emitterPulse = 0; // decaying flash at the ship (ore absorbed)
this.state = 'in'; // 'in' | 'steady' | 'out'
this.t0 = scene.time.now;
this.inP = 0;
this.outP = 0;
this.outRunning = false;
this.finished = false;
}
/** A soft additive sprite (the light brush), hidden until first use. */
_soft(tint, depth) {
const img = this.scene.add.image(0, 0, SOFT_KEY).setTint(tint).setDepth(depth);
img.setBlendMode(Phaser.BlendModes.ADD);
img.setAlpha(0);
return img;
}
/**
* Begin the retract (rock ship). No-op unless the beam is in or
* steady a beam cut off mid-extension (ESC right after the fire)
* fades from its CURRENT reach, so it never pops longer first.
*/
beginOut(time) {
if ((this.state !== 'steady' && this.state !== 'in') || this.finished) return;
const extent = this.state === 'in' ? this.inP : 1;
this.state = 'out';
this.outRunning = true;
this.outP = 1 - extent; // continuous with the extent the beam already had
this.t0 = time;
// Kill the ambient schedules — nothing new is born while it dies.
this.nextOre = time + 1e9;
this.nextRing = time + 1e9;
this.nextSpark = time + 1e9;
this.nextSurge = time + 1e9;
this.surge = null;
}
// ------------------------------------------------------------------
// Per-frame (Mining.update drives this)
// ------------------------------------------------------------------
update(time, delta) {
if (this.finished) return;
const dt = Math.min(delta, 64) / 1000;
const t = time * 0.001;
// ---- geometry (live: the rock drifts, the ship is held) ------------
const ship = this.ship;
const ex = this.member.wx - ship.x;
const ey = this.member.wy - ship.y;
const el = Math.hypot(ex, ey) || 1;
const unx = ex / el; // ship → rock
const uny = ey / el;
const px = -uny; // perpendicular
const py = unx;
const rr = this.member.radius;
// The impact point: on the rock's surface, facing the ship (slightly
// inside the rim so the glow sits ON the stone, not floating off it).
const ix = this.member.wx - unx * rr * 0.94;
const iy = this.member.wy - uny * rr * 0.94;
const sx = ship.x;
const sy = ship.y;
const len = Math.max(12, Math.hypot(ix - sx, iy - sy));
// ---- phase machine --------------------------------------------------
if (this.state === 'in') {
const p = Phaser.Math.Clamp((time - this.t0) / this.inMs, 0, 1);
this.inP = 1 - Math.pow(1 - p, 3); // ease-out: the head races, then settles
if (p >= 1) {
this.state = 'steady';
this.impactPulse = 1.6; // arrival flash
this.spawnRing(1.25);
for (let i = 0; i < 5; i++) this.spawnOre(0.04 + i * 0.13); // the first stream
}
} else if (this.state === 'out') {
this.outP = Phaser.Math.Clamp((time - this.t0) / this.outMs, 0, 1);
if (this.outP >= 1) {
this.finished = true;
return;
}
}
// ---- decays ----------------------------------------------------------
this.impactPulse *= Math.exp(-(delta / 130));
this.emitterPulse *= Math.exp(-(delta / 110));
// ---- ambient schedules (steady only) ---------------------------------
const steady = this.state === 'steady';
if (steady) {
if (time >= this.nextOre) {
this.spawnOre(0);
this.nextOre = time + this.range(this.oreSpawn[0], this.oreSpawn[1]);
}
if (time >= this.nextRing) {
this.spawnRing(1);
this.nextRing = time + this.range(this.ringEvery[0], this.ringEvery[1]);
}
if (time >= this.nextSpark) {
this.spawnSpark(ix, iy);
this.nextSpark = time + this.range(this.sparkEvery[0], this.sparkEvery[1]);
}
if (this.surgeEnabled && time >= this.nextSurge) {
this.surge = { t0: time };
this.impactPulse = Math.max(this.impactPulse, 1);
this.spawnRing(0.9);
this.nextSurge = time + this.range(this.surgeEvery[0], this.surgeEvery[1]);
}
}
// ---- advance the ore stream ------------------------------------------
for (const o of this.particles) {
if (!o.active) continue;
o.u += (o.v / len) * dt;
if (o.u >= 1) {
o.active = false;
o.dot.setAlpha(0);
this.emitterPulse = Math.min(1.3, this.emitterPulse + 0.28); // absorbed at the hull
}
}
for (const s of this.sparks) {
s.vx *= Math.exp(-2.4 * dt);
s.vy *= Math.exp(-2.4 * dt);
s.x += s.vx * dt;
s.y += s.vy * dt;
}
this.sparks = this.sparks.filter((s) => time - s.t0 < s.dur);
this.draw(time, { sx, sy, ix, iy, unx, uny, px, py, len, rr, t });
}
// ------------------------------------------------------------------
// Spawners
// ------------------------------------------------------------------
/** An ore mote leaving the rock and riding the beam toward the ship. */
spawnOre(u0 = 0) {
let o = this.particles.find((p) => !p.active);
if (!o) {
if (this.particles.length >= this.oreMax) return;
o = {
dot: this._soft(0xffffff, 12.5),
px: 0, py: 0, pa: 0,
};
this.particles.push(o);
}
o.active = true;
o.u = u0;
o.t0 = this.scene.time.now;
o.v = this.range(this.oreSpeed[0], this.oreSpeed[1]);
o.amp = this.range(this.oreWobble[0], this.oreWobble[1]);
o.phase = Math.random() * Math.PI * 2;
o.size = this.range(this.oreSize[0], this.oreSize[1]);
o.tail = this.range(this.oreTail[0], this.oreTail[1]);
o.tint = [0xffffff, 0xbfefff, 0x9fdcff, 0xffffff][(Math.random() * 4) | 0];
o.dot.setTint(o.tint).setScale(o.size / 64).setAlpha(0);
}
/** A shock ring peeling off the impact point (cyan + a hot white inner). */
spawnRing(strength = 1) {
const rr = this.member.radius;
this.rings.push({
t0: this.scene.time.now,
dur: 700,
r0: rr * 0.5,
r1: rr * 2.1,
w: 2.2,
a: 0.5 * strength,
color: 0x7fe8ff,
});
this.rings.push({
t0: this.scene.time.now,
dur: 430,
r0: rr * 0.28,
r1: rr * 1.2,
w: 1.6,
a: 0.7 * strength,
color: 0xffffff,
});
if (this.rings.length > 8) this.rings.splice(0, this.rings.length - 8);
}
/** A spark chipping off the rock (energy fleck, radial). */
spawnSpark(ix, iy) {
const a = Math.random() * Math.PI * 2;
const v = this.range(46, 150);
this.sparks.push({
t0: this.scene.time.now,
dur: this.range(260, 640),
x: ix,
y: iy,
vx: Math.cos(a) * v,
vy: Math.sin(a) * v,
size: this.range(1.2, 2.6),
color: Math.random() < 0.5 ? 0xdff4ff : 0xffffff,
});
if (this.sparks.length > 14) this.sparks.splice(0, this.sparks.length - 14);
}
// ------------------------------------------------------------------
// Drawing
// ------------------------------------------------------------------
/**
* One frame of the beam. `g` holds this frame's geometry (see update).
* extent = how much of the beam exists (in/out animation), master =
* the global alpha envelope.
*/
draw(time, g) {
const { sx, sy, ix, iy, unx, uny, px, py, t } = g;
const U = this.gUnder;
const O = this.gOver;
U.clear();
O.clear();
let extent = 1;
let master = 1;
if (this.state === 'in') {
extent = Math.max(0.02, this.inP);
master = 0.3 + 0.7 * this.inP;
} else if (this.state === 'out') {
extent = 1 - this.outP;
master = 1 - this.outP;
}
// The beam's live end (the head while it extends/retracts).
const hx = sx + (ix - sx) * extent;
const hy = sy + (iy - sy) * extent;
// A living beam: slow breath + a 12 Hz console flicker (subtle —
// power hum, not strobe).
const quant = Math.floor(time / 80);
const f = hash01(quant, 7);
const flick = 0.86 + 0.28 * f;
const breath = 0.92 + 0.08 * Math.sin(t * 2.4);
const A = master * flick * breath;
// The surge packet (a bright ore charge riding impact → ship).
let surge = 0;
let band = null;
if (this.surge) {
const sp = (time - this.surge.t0) / this.surgeDur;
if (sp >= 1) {
this.surge = null;
} else {
surge = 1 - sp;
const bp = 1 - Math.pow(1 - sp, 2.2); // leaves fast, settles at the hull
band = { x: ix + (sx - ix) * bp, y: iy + (sy - iy) * bp };
}
}
if (extent > 0.01) {
// ---- the glow stack (additive): wide blue → mid → fringe ---------
U.lineStyle(
this.glowWidth * (1 + 0.1 * Math.sin(t * 3.1)) * (1 + surge * 0.35),
this.glowColor,
A * this.glowAlpha * (1 + surge * 0.9),
);
U.lineBetween(sx, sy, hx, hy);
U.lineStyle(this.midWidth * (1 + surge * 0.3), this.midColor, A * this.midAlpha * (1 + surge * 0.7));
U.lineBetween(sx, sy, hx, hy);
// The console's RGB channel split — the fringe rides perpendicular
// to the beam (cyan up, magenta down), widening during a surge.
const go = this.ghostOffset * (1 + surge * 0.9);
U.lineStyle(1.8, this.ghostCyan, A * this.ghostAlpha * (1 + surge * 0.5));
U.lineBetween(sx + px * go, sy + py * go, hx + px * go, hy + py * go);
U.lineStyle(1.8, this.ghostMag, A * this.ghostAlpha * 0.9 * (1 + surge * 0.5));
U.lineBetween(sx - px * go, sy - py * go, hx - px * go, hy - py * go);
// ---- the surge band (a bright packet on the beam) -----------------
if (band) {
const hl = (this.surgeBand * 0.5) / extent; // keep the band on the live part
U.lineStyle(this.glowWidth * 0.8, this.midColor, 0.35 * surge);
U.lineBetween(band.x - unx * hl, band.y - uny * hl, band.x + unx * hl, band.y + uny * hl);
U.lineStyle(this.mainWidth * 2.4, 0xffffff, 0.55 * surge);
U.lineBetween(band.x - unx * hl * 0.7, band.y - uny * hl * 0.7, band.x + unx * hl * 0.7, band.y + uny * hl * 0.7);
}
// ---- the hot core (over layer): white over cyan --------------------
O.lineStyle(this.mainWidth, this.mainColor, A * this.mainAlpha * (1 + surge * 0.4));
O.lineBetween(sx, sy, hx, hy);
O.lineStyle(this.coreWidth * 3.2, 0xffffff, A * 0.18);
O.lineBetween(sx, sy, hx, hy);
O.lineStyle(this.coreWidth, this.coreColor, A * this.coreAlpha);
O.lineBetween(sx, sy, hx, hy);
}
// ---- shock rings at the impact point --------------------------------
this.rings = this.rings.filter((r) => {
const p = (time - r.t0) / r.dur;
if (p >= 1) return false;
if (p < 0) return true;
const rad = Phaser.Math.Linear(r.r0, r.r1, 1 - Math.pow(1 - p, 3));
U.lineStyle(r.w, r.color, r.a * (1 - p) * master);
U.strokeCircle(ix, iy, rad);
return true;
});
// ---- sparks (energy flecks chipping off the stone) -------------------
for (const s of this.sparks) {
const p = (time - s.t0) / s.dur;
if (p < 0 || p >= 1) continue;
U.lineStyle(s.size, s.color, (1 - p) * 0.85 * master);
U.lineBetween(s.x, s.y, s.x - s.vx * 0.05, s.y - s.vy * 0.05);
}
// ---- the ore stream (motes + tails, asteroid → ship) ------------------
for (const o of this.particles) {
if (!o.active) continue;
const age = time - o.t0;
const inF = Math.min(1, age / 110); // fade in at the surface
const outF = o.u > 0.8 ? (1 - o.u) / 0.2 : 1; // absorbed at the hull
const a = inF * outF * 0.9 * master;
if (a < 0.02) {
o.dot.setAlpha(0);
continue;
}
const wob = Math.sin(o.phase + o.u * 14) * o.amp; // a wave travelling with the mote
const ox = ix + (sx - ix) * o.u + px * wob;
const oy = iy + (sy - iy) * o.u + py * wob;
o.dot.setPosition(ox, oy).setAlpha(Math.min(1, a * 1.1));
U.lineStyle(1.5, o.tint, a * 0.5);
U.lineBetween(ox - unx * o.tail, oy - uny * o.tail, ox, oy);
}
// ---- the impact point (molten where the arm bites the stone) -----------
const igD = Math.max(this.impactGlowMin, this.member.radius * this.impactGlowScale) * (1 + this.impactPulse * 0.9);
this.impactGlow.setPosition(ix, iy).setScale(igD / 64).setAlpha(Math.min(1, A * 0.4 + this.impactPulse * 0.45));
this.impactCore
.setPosition(ix, iy)
.setScale((this.impactCoreSize * (1 + this.impactPulse * 0.7)) / 64)
.setAlpha(Math.min(1, A * 0.75 + this.impactPulse * 0.3));
// ---- the emitter (where the arm leaves the hull) ------------------------
this.emitter
.setPosition(sx, sy)
.setScale((18 * (1 + this.emitterPulse)) / 64)
.setAlpha(Math.min(1, A * 0.3 + this.emitterPulse * 0.55));
// ---- the head (only while the beam extends/retracts) --------------------
if (this.state !== 'steady') {
const hp = this.state === 'in' ? this.inP : 1 - this.outP;
this.head.setPosition(hx, hy).setScale(22 / 64).setAlpha(Math.min(1, master * (0.4 + 0.6 * (1 - Math.abs(hp - 0.5) * 2))));
} else {
this.head.setAlpha(0);
}
}
// ------------------------------------------------------------------
range(lo, hi) {
return lo + Math.random() * (hi - lo);
}
destroy() {
this.gUnder?.destroy();
this.gOver?.destroy();
this.impactGlow?.destroy();
this.impactCore?.destroy();
this.emitter?.destroy();
this.head?.destroy();
for (const o of this.particles) o.dot?.destroy();
this.particles.length = 0;
this.rings.length = 0;
this.sparks.length = 0;
this.gUnder = this.gOver = null;
this.impactGlow = this.impactCore = this.emitter = this.head = null;
}
}
/** Deterministic 0..1 hash (the same recipe as the tether's flicker). */
function hash01(i, salt) {
const s = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;
return s - Math.floor(s);
}

View File

@ -18,6 +18,8 @@ import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore } from '../save/SaveData.js';
import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
@ -111,6 +113,7 @@ export class GameScene extends Phaser.Scene {
this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3'));
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.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'));
}
}
@ -173,9 +176,10 @@ export class GameScene extends Phaser.Scene {
// Asteroid clusters — loose groups of slowly tumbling rocks scattered
// through the system's void (data/asteroids.json). Every cluster is a
// DISCOVERABLE object (the compass knows it once found) and a SOLID one
// DISCOVERABLE object (the compass knows it once found), a SOLID one
// (the ship can park at a rock's rim, never fly through it) — the same
// contract as the worlds, at rock scale. Mining them comes later.
// contract as the worlds, at rock scale — and a MINABLE one: a click
// on a rock opens the mining menu (see the Mining wiring below).
this.asteroidClusters = [];
if (config.get('asteroids.enabled', true) !== false) {
for (const rec of this.systemContent.asteroids ?? []) {
@ -312,11 +316,27 @@ export class GameScene extends Phaser.Scene {
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// ---- MINING (the clusters are now verbs, not just rocks) -----------
// The state machine (js/mining/Mining.js): idle → extending (~1.5 s,
// the ship is locked, "Extending Mining Arm..." in the console slot)
// → mining (the beam is live — js/mining/MiningBeam.js) → retracting.
// The pop-up (js/ui/MiningPopup.js) is the context menu a rock click
// opens, world-anchored right where the player clicked.
this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p),
});
this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock),
});
// Input: click = fly there. A click ON the command deck is deck
// business, a click on a compass name tag is an autopilot (it
// already retargeted the ship), and a click on the system NAME (or
// the caret beside it) toggles the dossier open/closed — none of
// those is a fly-here.
// those is a fly-here. A click on an ASTEROID opens the mining
// pop-up (a context menu — its buttons own their clicks; any click
// elsewhere closes it and is consumed). While the beam is live the
// ship is LOCKED: a world click breaks the beam first, then flies.
// A click inside a planet clamps to that planet's keep-out rim — the
// ship can stop at the clearance, never inside. (Worlds don't
// overlap, so sequential clamping is exact.) A click BEYOND the
@ -344,6 +364,45 @@ export class GameScene extends Phaser.Scene {
this.toggleHud(); // the system name toggles the dossier details
return;
}
// Mining pop-up OPEN: a click on one of its buttons is the
// button's (its own pointerdown listener); a click ANYWHERE else
// closes the menu and that click is consumed — no fly-here (the
// same contract as the sub-bar).
if (this.miningPopup && this.miningPopup.isOpen) {
if (this.miningPopup.contains(pointer.worldX, pointer.worldY)) return;
this.miningPopup.close();
return;
}
// A click the pop-up's OWN buttons just handled (they close it from
// their side first — the two handlers race on event order): it is
// the menu's click, never a fly-here.
if (
this.miningPopup &&
this.miningPopup.closedByButtonAt !== null &&
this.time.now - this.miningPopup.closedByButtonAt < 300
) {
return;
}
// The arm is mid-extension: the ship is committed to the rock for
// the ~1.5 s reach — the world holds (the toast says why).
if (this.mining.state === 'extending') return;
// A rock under the cursor: clicking an asteroid opens the mining
// menu (or the stop menu, when this cluster is the beam's target)
// — this click does not fly the ship.
const rock = this.rockAt(pointer.worldX, pointer.worldY);
if (rock) {
this.openMiningMenu(rock, pointer);
return;
}
// Beam live and the click is OFF the rock: break the beam (it
// retracts while the ship goes) and fly to the click.
if (this.mining.state === 'mining') this.mining.stop();
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const s of this.solids) {
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
@ -721,6 +780,7 @@ export class GameScene extends Phaser.Scene {
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time);
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
@ -746,35 +806,45 @@ export class GameScene extends Phaser.Scene {
this.tetherToast();
}
/** Top-center console warning (magenta). */
tetherToast() {
if (Array.isArray(this.tetherToastG)) {
for (const g of this.tetherToastG) g.destroy();
/**
* The console note the game's top-center signal slot. Discovery toasts,
* the tether warning, and the mining calls all live here: a small accent
* glyph + a console-caps label, fading in, holding, fading out. One at a
* time a new note replaces any old one.
*
* @param {string} label the console line
* @param {object} [o] { glyph, glyphColor, durationMs, y (screen px; default ~75 above center) }
*/
consoleToast(label, { glyph = '\u25b8', glyphColor = '#00e5ff', durationMs = 2400, y } = {}) {
if (Array.isArray(this.consoleToastG)) {
for (const g of this.consoleToastG) g.destroy();
this.consoleToastG = null;
}
const fam = BODY_FONT();
const g1 = this.add
.text(0, 0, '\u26a0', { fontFamily: fam, fontSize: '13px', color: toCss(themeColor('neon2', 0xff2d6f)) })
.text(0, 0, glyph, { fontFamily: fam, fontSize: '13px', color: toCss(glyphColor) })
.setOrigin(0, 0.5)
.setScrollFactor(0);
.setScrollFactor(0); // UI — pinned to the screen, not the world
const g2 = this.add
.text(0, 0, 'TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
.text(0, 0, label, {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0);
.setScrollFactor(0); // UI — pinned to the screen, not the world
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
g1.setPosition(x0, 54).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, 54).setDepth(45).setAlpha(0);
this.tetherToastG = [g1, g2];
this.tweens.add({ targets: this.tetherToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(2600, () => {
if (!Array.isArray(this.tetherToastG)) return;
const [a, b] = this.tetherToastG;
this.tetherToastG = null;
const yy = y === undefined ? this.scale.height / 2 - 75 : y; // ~75 px above the screen center
g1.setPosition(x0, yy).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0);
this.consoleToastG = [g1, g2];
this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.consoleToastG)) return;
const [a, b] = this.consoleToastG;
this.consoleToastG = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
@ -787,6 +857,16 @@ export class GameScene extends Phaser.Scene {
});
}
/** Top-center console warning (magenta), high up, clear of the signal slot. */
tetherToast() {
this.consoleToast('TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
glyph: '\u26a0',
glyphColor: toCss(themeColor('neon2', 0xff2d6f)),
durationMs: 2600,
y: 54,
});
}
/**
* The camera chases the ship with a frame-rate-independent ease:
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
@ -913,6 +993,83 @@ export class GameScene extends Phaser.Scene {
this.hideHint();
}
// ==================================================================
// Mining — the ship's energy arm (js/mining/Mining.js, MiningBeam.js)
// ==================================================================
/**
* Which rock (if any) sits under a WORLD point the click-to-mine
* hit test. Nearest rock wins (clusters are far apart; members may
* slightly overlap). A few px of slop so smaller rocks are easy to hit.
*/
rockAt(wx, wy) {
let best = null;
let bestD = Infinity;
for (const c of this.asteroidClusters) {
for (const m of c.members) {
const d = Math.hypot(wx - m.wx, wy - m.wy);
if (d <= m.radius + 8 && d < bestD) {
best = { cluster: c, member: m, x: wx, y: wy };
bestD = d;
}
}
}
return best;
}
/**
* A rock click: the context menu unfolds right where the player
* clicked (js/ui/MiningPopup.js world-anchored on the asteroid).
* The primary button is "Mine Asteroids" or "Stop Mining" when
* this cluster is already the beam's target.
*/
openMiningMenu(rock, pointer) {
const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster;
this.playSfx('construct'); // the menu decodes in
this.miningPopup.open(rock.x, rock.y, pointer.y, {
name: rock.cluster.discoveryName,
primaryId: isTarget ? 'stop' : 'mine',
primaryLabel: isTarget ? 'Stop Mining' : 'Mine Asteroids',
target: rock,
});
this.hideHint();
}
/** The pop-up's button presses (MiningPopup → onAction). */
miningAction(id, rock) {
this.miningPopup.close();
if (!rock) return;
if (id === 'mine') {
if (this.mining.state === 'extending') return; // the arm is committed
this.mining.begin(rock.cluster, rock.member);
} else if (id === 'stop') {
this.mining.stop();
}
// 'cancel' — just close.
}
/**
* Mining phase changes (Mining onPhase): the scene's share of the
* sequence the ship lock, the console calls, the sfx.
*/
onMiningPhase(phase) {
if (phase === 'extending') {
this.ship.stop(); // hold station — the arm needs a steady hull
this.playSfx('mining'); // the arm powers up
this.hideHint();
// The console call, in the same slot as the discovery toasts —
// it holds for the whole reach, then fades as the beam fires.
this.consoleToast('Extending Mining Arm...', {
glyph: '\u2316',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.mining.armExtendMs,
});
} else if (phase === 'stopped') {
this.playSfx('deconstruct'); // the arm pulls back
}
// 'mining' = the beam is live — the visual speaks for itself.
}
/**
* Play one of the configured sound effects (data/sfx.json).
* Silently no-ops when SFX are disabled, the sound manager isn't
@ -945,46 +1102,13 @@ export class GameScene extends Phaser.Scene {
});
// Center HUD toast (~75 px above screen center), in the console language.
if (Array.isArray(this.toast)) {
for (const g of this.toast) g.destroy();
}
const fam = BODY_FONT();
const type = (o.typeLabel ?? 'OBJECT').toUpperCase();
const name = o.name ? String(o.name).toUpperCase() : null;
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
const g1 = this.add
.text(0, 0, '\u25b8', { fontFamily: fam, fontSize: '12px', color: toCss(neon) })
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const g2 = this.add
.text(0, 0, label, {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
const y = this.scale.height / 2 - 75; // ~75 px above the screen center
g1.setPosition(x0, y).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, y).setDepth(45).setAlpha(0);
this.toast = [g1, g2];
this.tweens.add({ targets: this.toast, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(2400, () => {
if (!Array.isArray(this.toast)) return;
const [a, b] = this.toast;
this.toast = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
this.consoleToast(label, {
glyph: '\u25b8',
glyphColor: toCss(neon),
durationMs: 2400,
});
}
@ -1062,7 +1186,7 @@ export class GameScene extends Phaser.Scene {
}
}
/** ESC: topmost open thing first — confirm dialog → pop-up → sub-bar. */
/** ESC: topmost open thing first — confirm dialog → pop-up → mining → sub-bar. */
escAction() {
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
@ -1074,6 +1198,17 @@ export class GameScene extends Phaser.Scene {
return;
}
}
// The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close();
return;
}
// Beam live (or arm extending) → break the mining (the beam retracts;
// the ship is free again — no fly).
if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) {
this.mining.stop();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
@ -1130,6 +1265,8 @@ export class GameScene extends Phaser.Scene {
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.miningPopup?.destroy();
this.mining?.destroy();
this.tetherField?.destroy();
}
}

251
js/ui/MiningPopup.js Normal file
View File

@ -0,0 +1,251 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
import { setInteractiveEnabled } from '../utils/Input.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The mining context menu a small cut-corner panel with two stacked
* buttons that opens right where the player clicked a rock:
*
* <cluster name>
*
* MINE ASTEROIDS (STOP MINING, when this cluster is the beam's target)
* CANCEL
*
*
* WORLD-ANCHORED (unlike the deck/sub-bar, which are screen-fixed): the
* menu sits on the asteroid it refers to, so it stays with the rock while
* the camera trails. A click ON a button is the button's (MenuButton's
* own pointerdown listener); any click ELSEWHERE is the scene's it
* closes the menu and that click is consumed (no fly-here). Same
* contract as the menu sub-bar, driven from GameScene's pointerdown.
*
* The panel unfolds out of the click point (scale + fade, ~150 ms).
*
* const pop = new MiningPopup(scene, { onAction: (id, target) => ... });
* pop.open(wx, wy, screenY, { name, primaryId, primaryLabel, target });
* pop.close();
* pop.contains(wx, wy); // WORLD coords
* pop.isOpen; pop.destroy();
*/
export class MiningPopup extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
* @param {object} [o] { onAction?: (id: 'mine'|'stop'|'cancel', target) => void }
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene.add.existing(this); // v4: new'd containers are not on the display list
// Above the ship (10) and the mining beam (1112); under the console
// toasts (45) and the deck (50).
this.setDepth(40);
this.onAction = typeof o.onAction === 'function' ? o.onAction : null;
const cfg = config.section('asteroids.mining.popup', {});
this.W = cfg.width ?? 212;
this.padTop = cfg.padTop ?? 10;
this.padBottom = cfg.padBottom ?? 12;
this.gap = cfg.gap ?? 8;
this.headerH = cfg.headerHeight ?? 20;
this.lift = cfg.lift ?? 14; // px between the click point and the panel
const panelFill = toColor(themeColor('panel', 0x0a1120));
const border = toColor(config.get('menu.colors.buttonBorder', '#2aa9c9'));
const neon = themeColor('neon', 0x00e5ff);
const ink = toColor(themeColor('ink', 0xeaf6ff));
const dim = toColor(themeColor('dim', 0x7d92c4));
const btnW = this.W - 24;
const mkBtn = (label, accent, text) =>
new MenuButton(scene, 0, 0, label, null, {
width: btnW,
fontSize: 13,
paddingX: 12,
paddingY: 9,
letterSpacing: 2,
upper: true,
textColor: text,
bgColor: panelFill,
hoverColor: panelFill,
borderColor: toCss(accent),
});
// The primary is built at its LONGEST label ("MINE ASTEROIDS") so the
// panel width is final; open() re-labels it per target.
this.primaryBtn = mkBtn('MINE ASTEROIDS', neon, toCss(ink));
this.primaryBtn.style.stroke = neon;
this.primaryBtn.style.neon = neon;
this.primaryBtn.panel.on('pointerdown', () => this.fire(this.primaryId));
this.cancelBtn = mkBtn('CANCEL', dim, toCss(dim));
this.cancelBtn.style.stroke = toColor(0x44506e);
this.cancelBtn.style.neon = dim;
this.cancelBtn.panel.on('pointerdown', () => this.fire('cancel'));
const h1 = this.primaryBtn.style.height;
const h2 = this.cancelBtn.style.height;
this.H = this.padTop + this.headerH + this.gap + h1 + this.gap + h2 + this.padBottom;
this.h1 = h1;
this.h2 = h2;
// The panel body (redrawn only on open — it is static while up).
this.panelG = scene.add.graphics();
const panel = (dir) => {
this.panelG.clear();
const cy = dir * (this.H / 2);
this.panelG.setPosition(0, cy);
CyberShape.draw(this.panelG, this.W, this.H, {
notch: Math.min(12, this.H * 0.16),
fill: panelFill,
fillAlpha: 0.9,
stroke: border,
strokeAlpha: 0.85,
lineWidth: 1.5,
glow: neon,
glowAlpha: 0.16,
});
// The top rail (the sub-bar's language): a neon edge + a magenta
// counter-edge where the panel meets its header.
const railY = dir < 0 ? -this.H / 2 + 0.5 : this.H / 2 - 2;
this.panelG.fillStyle(neon, 0.85);
this.panelG.fillRect(-this.W / 2 + 8, railY, this.W - 16, 1.5);
this.panelG.fillStyle(themeColor('neon2', 0xff2d6f), 0.35);
this.panelG.fillRect(-this.W / 2 + 8, dir < 0 ? railY + 2 : railY - 1, this.W - 16, 1);
};
panel(-1);
// The header: the target's name (dim, small, console caps).
const fam = fontStack('body', FONT_FALLBACK);
this.headerText = scene.add
.text(0, 0, '', {
fontFamily: fam,
fontSize: '11px',
color: toCss(dim),
letterSpacing: 2,
})
.setOrigin(0.5, 0.5);
this.add([this.panelG, this.headerText, this.primaryBtn, this.cancelBtn]);
this.layout(-1);
// CLOSED = input-inert (the v4 idiom — setInteractiveEnabled).
setInteractiveEnabled(this.primaryBtn.panel, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
this.setAlpha(0);
this.state = 'closed'; // 'closed' | 'open' | 'closing'
this.lastTarget = null; // the scene's rock hit record (passed to onAction)
this.primaryId = 'mine'; // what the primary button fires ('mine' | 'stop')
// Stamped when a BUTTON press fires (its action closes the menu). The
// scene's pointerdown uses this to know that very click belongs to the
// menu — button handlers and the scene handler race on event order, so
// the click must never fall through into "fly here".
this.closedByButtonAt = null;
}
get isOpen() {
return this.state === 'open';
}
/**
* Open at the click point (WORLD coords). `screenY` (the click's
* SCREEN y) decides the side: near the top edge the panel drops BELOW
* the click so it stays on screen.
*/
open(wx, wy, screenY, o = {}) {
if (this.scene === null || this.active === false) return;
this.lastTarget = o.target ?? null;
const name = String(o.name ?? 'ASTEROID CLUSTER').toUpperCase();
const primaryLabel = String(o.primaryLabel ?? 'MINE ASTEROIDS').toUpperCase();
this.primaryId = o.primaryId ?? 'mine';
this.headerText.setText(`\u25b8 ${name}`);
this.primaryBtn.labelText.setText(primaryLabel);
const dir = screenY !== undefined && screenY < this.H + 30 ? 1 : -1;
this.layout(dir);
this.setPosition(wx, wy + dir * this.lift);
this.rect = {
x: wx - this.W / 2,
y: dir < 0 ? wy - this.lift - this.H : wy + this.lift,
w: this.W,
h: this.H,
};
this.scene.tweens.killTweensOf(this);
this.state = 'open';
this.primaryBtn.hover(false);
this.cancelBtn.hover(false);
this.primaryBtn.paint('base');
this.cancelBtn.paint('base');
setInteractiveEnabled(this.primaryBtn.panel, true);
setInteractiveEnabled(this.cancelBtn.panel, true);
this.setAlpha(0).setScale(0.9);
this.scene.tweens.add({
targets: this,
alpha: 1,
scale: 1,
duration: 150,
ease: 'Sine.easeOut',
});
}
/** Close (any click outside, a button press, or ESC). */
close() {
if (this.state !== 'open') return;
this.state = 'closing';
this.scene.tweens.killTweensOf(this);
setInteractiveEnabled(this.primaryBtn.panel, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
this.scene.tweens.add({
targets: this,
alpha: 0,
scale: 0.94,
duration: 120,
ease: 'Sine.easeIn',
onComplete: () => {
if (this.state === 'closing') this.state = 'closed';
},
});
}
/** Is (wx, wy) — WORLD coords — over the panel? */
contains(wx, wy) {
const r = this.rect;
return !!r && wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h;
}
/** Lay the panel out: dir 1 = above the anchor (default), +1 = below. */
layout(dir) {
this.panelG.setPosition(0, dir * (this.H / 2));
this.headerText.setPosition(0, dir * (this.H - this.padTop - this.headerH / 2));
const y1 = this.H - this.padTop - this.headerH - this.gap - this.h1 / 2;
const y2 = y1 + this.h1 / 2 + this.gap + this.h2 / 2;
this.primaryBtn.setPosition(0, dir * y1);
this.cancelBtn.setPosition(0, dir * y2);
}
/** Fire an action id to the scene (its handler closes the menu). */
fire(id) {
if (typeof this.onAction === 'function') {
this.closedByButtonAt = this.scene.time.now; // this click is the menu's
try {
this.onAction(id, this.lastTarget);
} catch (err) {
console.error('[mining-popup] onAction failed', err);
}
}
}
destroy() {
this.panelG?.destroy();
this.headerText?.destroy();
this.primaryBtn?.destroy();
this.cancelBtn?.destroy();
this.panelG = this.headerText = this.primaryBtn = this.cancelBtn = null;
}
}