diff --git a/assets/videos/gasgiant-takeoff-02.mp4 b/assets/videos/gasgiant-takeoff-02.mp4 new file mode 100644 index 0000000..d440de2 Binary files /dev/null and b/assets/videos/gasgiant-takeoff-02.mp4 differ diff --git a/data/asteroids.json b/data/asteroids.json index 8329ded..19fe46f 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -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 }, diff --git a/data/game.json b/data/game.json index f6e3da2..e802c4b 100644 --- a/data/game.json +++ b/data/game.json @@ -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" diff --git a/data/sfx.json b/data/sfx.json index c9bdf1c..3c60899 100644 --- a/data/sfx.json +++ b/data/sfx.json @@ -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" } diff --git a/dev/mining-test.html b/dev/mining-test.html new file mode 100644 index 0000000..bf933cc --- /dev/null +++ b/dev/mining-test.html @@ -0,0 +1,29 @@ + + + + + Orbit — dev mining flow test + + + + + + + +
+ + + diff --git a/dev/mining-test.mjs b/dev/mining-test.mjs new file mode 100644 index 0000000..8912aa5 --- /dev/null +++ b/dev/mining-test.mjs @@ -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); + } +}; diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index 2b0a9ed..92522da 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -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); diff --git a/js/mining/Mining.js b/js/mining/Mining.js new file mode 100644 index 0000000..9eba787 --- /dev/null +++ b/js/mining/Mining.js @@ -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; + } +} diff --git a/js/mining/MiningBeam.js b/js/mining/MiningBeam.js new file mode 100644 index 0000000..9b868b7 --- /dev/null +++ b/js/mining/MiningBeam.js @@ -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); +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index adeabe1..96c11c5 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); } } diff --git a/js/ui/MiningPopup.js b/js/ui/MiningPopup.js new file mode 100644 index 0000000..4e04966 --- /dev/null +++ b/js/ui/MiningPopup.js @@ -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: + * + * ▸ + * ┌──────────────────┐ + * │ 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; + } +}