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:
parent
2bad1f1e84
commit
b4b656f6e4
Binary file not shown.
|
|
@ -8,6 +8,59 @@
|
||||||
"typeLabel": "Asteroid Cluster",
|
"typeLabel": "Asteroid Cluster",
|
||||||
"compassColor": "#c8d2e0",
|
"compassColor": "#c8d2e0",
|
||||||
"shipClearance": 50,
|
"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": {
|
"cluster": {
|
||||||
"groupSize": { "min": 4, "max": 8 },
|
"groupSize": { "min": 4, "max": 8 },
|
||||||
"sizes": { "min": 64, "max": 128 },
|
"sizes": { "min": 64, "max": 128 },
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"backgroundColor": "#04060d",
|
"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",
|
"markerColor": "#41c7ff",
|
||||||
"physics": {
|
"physics": {
|
||||||
"default": "arcade"
|
"default": "arcade"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"enabled": true,
|
||||||
"volume": 0.55,
|
"volume": 0.55,
|
||||||
"construct": "assets/fx/type-construct.mp3",
|
"construct": "assets/fx/type-construct.mp3",
|
||||||
"deconstruct": "assets/fx/type-deconstruct.mp3",
|
"deconstruct": "assets/fx/type-deconstruct.mp3",
|
||||||
"discovery": "assets/fx/discovery.mp3"
|
"discovery": "assets/fx/discovery.mp3",
|
||||||
|
"mining": "assets/fx/system-scan.mp3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -423,9 +423,11 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
{
|
{
|
||||||
const sfxJson = config.section('sfx');
|
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();
|
const s = makeScene();
|
||||||
s.played = [];
|
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.audioQueued = [];
|
||||||
s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) };
|
s.sound = { play: (key, cfg) => s.played.push({ key, ...(cfg ?? {}) }) };
|
||||||
s.cache = { hasAudio: (key) => s.audio[key] === true };
|
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; },
|
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);
|
GameScene.prototype.preload.call(s);
|
||||||
const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key);
|
const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key);
|
||||||
check('preload loads the three configured SFX',
|
check('preload loads every configured SFX',
|
||||||
s.audioQueued.length === 3 &&
|
s.audioQueued.length === sfxNames.length &&
|
||||||
queuedKey('sfx_construct')?.[1] === sfxJson.construct &&
|
sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n]));
|
||||||
queuedKey('sfx_deconstruct')?.[1] === sfxJson.deconstruct &&
|
|
||||||
queuedKey('sfx_discovery')?.[1] === sfxJson.discovery);
|
|
||||||
check('the configured SFX files exist in the repo',
|
check('the configured SFX files exist in the repo',
|
||||||
[sfxJson.construct, sfxJson.deconstruct, sfxJson.discovery].every(
|
sfxNames.every((n) => fs.existsSync(join(__dirname, '..', sfxJson[n]))));
|
||||||
(p) => fs.existsSync(join(__dirname, '..', p)),
|
|
||||||
));
|
|
||||||
|
|
||||||
// arrival → the construct SFX (once, at the configured volume).
|
// arrival → the construct SFX (once, at the configured volume).
|
||||||
GameScene.prototype.createSystemHud.call(s);
|
GameScene.prototype.createSystemHud.call(s);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ import { SavePanel } from '../ui/SavePanel.js';
|
||||||
import { SaveManager } from '../save/SaveManager.js';
|
import { SaveManager } from '../save/SaveManager.js';
|
||||||
import { consumeRestore } from '../save/SaveData.js';
|
import { consumeRestore } from '../save/SaveData.js';
|
||||||
import { TetherField } from '../tether/TetherField.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 FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
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_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_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_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
|
// Asteroid clusters — loose groups of slowly tumbling rocks scattered
|
||||||
// through the system's void (data/asteroids.json). Every cluster is a
|
// 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
|
// (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 = [];
|
this.asteroidClusters = [];
|
||||||
if (config.get('asteroids.enabled', true) !== false) {
|
if (config.get('asteroids.enabled', true) !== false) {
|
||||||
for (const rec of this.systemContent.asteroids ?? []) {
|
for (const rec of this.systemContent.asteroids ?? []) {
|
||||||
|
|
@ -312,11 +316,27 @@ export class GameScene extends Phaser.Scene {
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
.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
|
// Input: click = fly there. A click ON the command deck is deck
|
||||||
// business, a click on a compass name tag is an autopilot (it
|
// business, a click on a compass name tag is an autopilot (it
|
||||||
// already retargeted the ship), and a click on the system NAME (or
|
// already retargeted the ship), and a click on the system NAME (or
|
||||||
// the caret beside it) toggles the dossier open/closed — none of
|
// 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
|
// 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
|
// ship can stop at the clearance, never inside. (Worlds don't
|
||||||
// overlap, so sequential clamping is exact.) A click BEYOND the
|
// 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
|
this.toggleHud(); // the system name toggles the dossier details
|
||||||
return;
|
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 };
|
let aim = { x: pointer.worldX, y: pointer.worldY };
|
||||||
for (const s of this.solids) {
|
for (const s of this.solids) {
|
||||||
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
|
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,
|
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
|
||||||
// after the physics step has moved the ship.)
|
// after the physics step has moved the ship.)
|
||||||
for (const c of this.asteroidClusters) c.update(_time);
|
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.updateCamera(delta);
|
||||||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||||
|
|
@ -746,35 +806,45 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.tetherToast();
|
this.tetherToast();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-center console warning (magenta). */
|
/**
|
||||||
tetherToast() {
|
* The console note — the game's top-center signal slot. Discovery toasts,
|
||||||
if (Array.isArray(this.tetherToastG)) {
|
* the tether warning, and the mining calls all live here: a small accent
|
||||||
for (const g of this.tetherToastG) g.destroy();
|
* 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 fam = BODY_FONT();
|
||||||
const g1 = this.add
|
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)
|
.setOrigin(0, 0.5)
|
||||||
.setScrollFactor(0);
|
.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
const g2 = this.add
|
const g2 = this.add
|
||||||
.text(0, 0, 'TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
|
.text(0, 0, label, {
|
||||||
fontFamily: fam,
|
fontFamily: fam,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: toCss(themeColor('dim', 0x7d92c4)),
|
color: toCss(themeColor('dim', 0x7d92c4)),
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
})
|
})
|
||||||
.setOrigin(0, 0.5)
|
.setOrigin(0, 0.5)
|
||||||
.setScrollFactor(0);
|
.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
const total = g1.width + 10 + g2.width;
|
const total = g1.width + 10 + g2.width;
|
||||||
const x0 = this.scale.width / 2 - total / 2;
|
const x0 = this.scale.width / 2 - total / 2;
|
||||||
g1.setPosition(x0, 54).setDepth(45).setAlpha(0);
|
const yy = y === undefined ? this.scale.height / 2 - 75 : y; // ~75 px above the screen center
|
||||||
g2.setPosition(x0 + g1.width + 10, 54).setDepth(45).setAlpha(0);
|
g1.setPosition(x0, yy).setDepth(45).setAlpha(0);
|
||||||
this.tetherToastG = [g1, g2];
|
g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0);
|
||||||
this.tweens.add({ targets: this.tetherToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
|
this.consoleToastG = [g1, g2];
|
||||||
this.time.delayedCall(2600, () => {
|
this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
|
||||||
if (!Array.isArray(this.tetherToastG)) return;
|
this.time.delayedCall(durationMs, () => {
|
||||||
const [a, b] = this.tetherToastG;
|
if (!Array.isArray(this.consoleToastG)) return;
|
||||||
this.tetherToastG = null;
|
const [a, b] = this.consoleToastG;
|
||||||
|
this.consoleToastG = null;
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: [a, b],
|
targets: [a, b],
|
||||||
alpha: 0,
|
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:
|
* The camera chases the ship with a frame-rate-independent ease:
|
||||||
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
|
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
|
||||||
|
|
@ -913,6 +993,83 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.hideHint();
|
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).
|
* Play one of the configured sound effects (data/sfx.json).
|
||||||
* Silently no-ops when SFX are disabled, the sound manager isn't
|
* 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.
|
// 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 type = (o.typeLabel ?? 'OBJECT').toUpperCase();
|
||||||
const name = o.name ? String(o.name).toUpperCase() : null;
|
const name = o.name ? String(o.name).toUpperCase() : null;
|
||||||
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
|
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
|
||||||
const g1 = this.add
|
this.consoleToast(label, {
|
||||||
.text(0, 0, '\u25b8', { fontFamily: fam, fontSize: '12px', color: toCss(neon) })
|
glyph: '\u25b8',
|
||||||
.setOrigin(0, 0.5)
|
glyphColor: toCss(neon),
|
||||||
.setScrollFactor(0); // UI — pinned to the screen, not the world
|
durationMs: 2400,
|
||||||
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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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() {
|
escAction() {
|
||||||
if (this.savePanel) {
|
if (this.savePanel) {
|
||||||
if (this.savePanel.confirm.isOpen) {
|
if (this.savePanel.confirm.isOpen) {
|
||||||
|
|
@ -1074,6 +1198,17 @@ export class GameScene extends Phaser.Scene {
|
||||||
return;
|
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();
|
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1130,6 +1265,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.actionBar?.destroy();
|
this.actionBar?.destroy();
|
||||||
this.menuSubBar?.destroy();
|
this.menuSubBar?.destroy();
|
||||||
this.savePanel?.destroy();
|
this.savePanel?.destroy();
|
||||||
|
this.miningPopup?.destroy();
|
||||||
|
this.mining?.destroy();
|
||||||
this.tetherField?.destroy();
|
this.tetherField?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (11–12); 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue