diff --git a/dev/mining-test.mjs b/dev/mining-test.mjs index 8912aa5..59fa597 100644 --- a/dev/mining-test.mjs +++ b/dev/mining-test.mjs @@ -2,19 +2,24 @@ * 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: + * and walks the whole arm sequence against a seeded galaxy — including the + * SHIP'S STATE (js/entities/Ship.js): 'normal' by default, 'mining' while + * the arm's sequence runs, and ANY movement (a world click or the compass + * autopilot) drops it back to 'normal' and ends the sequence: * * 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 + * click MINE ASTEROIDS → "extending": the ship is in 'mining' state, + * holds station, console says "Extending..." + * a world click meanwhile → MOVES the ship: the extension ABORTS, the + * ship goes back to 'normal' and flies + * mine again, the ~1.5 s → "mining": beam live, ore motes ride it, + * reach the ship still 'mining' + * the COMPASS AUTOPILOT → MOVES the ship: 'normal' again, the beam + * retracts as the ship goes + * mine again; ESC → breaks the beam, the ship is 'normal' + * ESC with the menu open → closes it, no mining + * click CANCEL → closes, no mining, ship 'normal' * * 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 @@ -152,9 +157,36 @@ const rockScreen = () => { const s = game.scene.getScene('GameScene'); return w2s(rock.wx, rock.wy); }; +// A screen point guaranteed to be OFF every rock of the cluster (the +// members are scattered — a fixed point can land on one) AND outside the +// mining panel's footprint (a click right after a button press that +// lands on the panel area is consumed as the panel's own click). const openSpaceScreen = () => { const s = game.scene.getScene('GameScene'); - return { x: s.scale.width / 2, y: s.scale.height / 2 }; + const cam = s.cameras.main; + const W = s.scale.width, H = s.scale.height; + const p = s.miningPopup; + const inPanel = (wx, wy) => { + const r = p && p.rect; + if (!r) return false; + return wx >= r.x - 20 && wx <= r.x + r.w + 20 && wy >= r.y - 20 && wy <= r.y + r.h + 20; + }; + const cands = [ + [W / 2, H / 2], [120, H / 2], [W - 120, H / 2], + [W / 2, 120], [W / 2, H - 120], [120, 120], [W - 120, H - 120], + ]; + for (const [cx, cy] of cands) { + const wx = cam.scrollX + cx, wy = cam.scrollY + cy; + let clear = true; + for (const m of (cluster ? cluster.members : [])) { + if (Math.hypot(m.wx - wx, m.wy - wy) < m.radius + 40) { clear = false; break; } + } + // Far enough from the ship that the flight stays observable (a click + // next to the ship arrives instantly and clears the target). + const fromShip = Math.hypot(s.ship.x - wx, s.ship.y - wy) > 400; + if (clear && fromShip && !inPanel(wx, wy)) return { x: cx, y: cy }; + } + return { x: 120, y: 120 }; // last resort (practically unreachable) }; // ---------------------------------------------------------------------- @@ -176,10 +208,33 @@ const finish = (pass) => { 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 }; + window.__MINING__ = { pass: ok, results: all, errors, dbg: window.__DBG__STATE__ || null }; console.log(ok ? 'MINING PASS' : 'MINING FAIL'); }; +// ---------------------------------------------------------------------- +// Diagnostics: the state machine can stash snapshots here; finish() +// ships them back in window.__MINING__.dbg. +// ---------------------------------------------------------------------- +const dbg = (window.__DBG__STATE__ = {}); +const snapshot = (tag) => { + try { + const s = scene(); + const arr = (dbg[tag] = dbg[tag] || []); + arr.push({ + t: Math.round(s.time.now), + mining: s.mining.state, + ship: s.ship.state, + target: s.ship.target ? [Math.round(s.ship.target.x), Math.round(s.ship.target.y)] : null, + xy: [Math.round(s.ship.x), Math.round(s.ship.y)], + scroll: [Math.round(s.cameras.main.scrollX), Math.round(s.cameras.main.scrollY)], + popup: s.miningPopup ? s.miningPopup.state : null, + stamp: s.miningPopup ? s.miningPopup.closedByButtonAt : null, + }); + if (arr.length > 30) arr.shift(); + } catch { /* booting */ } +}; + const scene = () => game.scene.getScene('GameScene'); const fail = (label) => { @@ -187,6 +242,41 @@ const fail = (label) => { finish(false); }; +/** Park the ship by the rock and frame it (the test's teleport). */ +const parkNearRock = () => { + const s = scene(); + 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, + ); +}; + +// The camera eases onto the ship (GameScene.updateCamera) — after a park +// or a flight it keeps drifting for a while. Pump until it settles so a +// rock's screen position is stable when we compute the next click. +const settleCamera = (maxBatches = 60) => { + let last = null; + let still = 0; + for (let i = 0; i < maxBatches; i++) { + pump(6, 80); + try { + const cam = scene().cameras.main; + const cur = [cam.scrollX, cam.scrollY]; + if (last) { + if (Math.abs(cur[0] - last[0]) < 0.5 && Math.abs(cur[1] - last[1]) < 0.5) { + still++; + if (still >= 3) return true; + } else still = 0; + } + last = cur; + } catch { /* booting */ } + } + return false; +}; + const stepMachine = () => { let s = null; try { s = scene(); } catch { /* not booted yet */ } @@ -208,13 +298,8 @@ const stepMachine = () => { } 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, - ); + parkNearRock(); + settleCamera(); settle(200); press(rockScreen().x, rockScreen().y); stage = 1; @@ -235,6 +320,7 @@ const stepMachine = () => { 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'); + check('the ship is in its NORMAL state', s.ship.state === 'normal'); press(openSpaceScreen().x, openSpaceScreen().y); // open space — outside the panel stage = 2; return; @@ -279,8 +365,8 @@ const stepMachine = () => { 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); + check('the ship is in the MINING state while the arm extends', s.ship.state === 'mining'); + check('the ship holds station while the arm extends', s.ship.target === null); if (!sawExtendingToast) { // One more chance while the arm is still out (the toast holds the // whole extension window). @@ -293,26 +379,95 @@ const stepMachine = () => { } 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); + // A world click mid-extension MOVES the ship — which ends the + // mining state (the new contract: movement beats the arm's reach). + if (s.mining.state === 'extending') { + const p5 = openSpaceScreen(); + dbg.stage4press = p5; + press(p5.x, p5.y); + } stage = 5; return; } - // ---- 5) the reach completes into mining -------------------------------- + // ---- 5) the movement broke the arm; set up the real attempt ---------- case 5: { + // If the "open space" click landed on another member of the cluster + // (the members are scattered), it opened the menu instead of flying + // — close it and click true open space. + if (s.miningPopup.isOpen) { + esc(); + settle(150); + const p = openSpaceScreen(); + dbg.stage5repress = p; + press(p.x, p.y); + } + const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, + () => ['extending', 'mining', 'retracting', 'idle'].includes(s.mining.state), + () => snapshot('stage5'), 1200, 90000); + if (r === null) return; + if (!r) fail('a world click breaks the arm (abort or retract)'); + check('a world click breaks the arm (abort or retract)', true); + check('the beam is gone (aborted, or retracted and reaped)', s.mining.beam === null); + check('the ship is back in the NORMAL state', s.ship.state === 'normal'); + check('the breaking click flew the ship', s.ship.target !== null); + + // Set up the next attempt: park by the rock, let the camera settle, + // menu, MINE. + parkNearRock(); + settleCamera(); + dbg.stage6press = rockScreen(); + press(rockScreen().x, rockScreen().y); + stage = 6; + return; + } + + // ---- 6) menu again; start mining for real ------------------------------ + case 6: { + const r = wait(() => s.miningPopup.isOpen === true, () => true, + () => snapshot('stage6'), 1500, 20000); + if (r === null) return; + if (!r) { + const pp = dbg.stage6press || rockScreen(); + dbg.stage6 = { + press: pp, + rockHit: !!s.rockAt(rock.wx, rock.wy), + rockScreenNow: rockScreen(), + savePanelOpen: !!(s.savePanel && s.savePanel.isOpen), + subbarOpen: !!(s.menuSubBar && s.menuSubBar.isOpen), + actionbarHit: !!(s.actionBar && s.actionBar.contains(pp.x, pp.y)), + compassHit: !!s.compass.contains(pp.x, pp.y), + titleHit: !!s.hudTitleContains(pp.x, pp.y), + popupState: s.miningPopup ? s.miningPopup.state : null, + miningState: s.mining.state, + shipTarget: s.ship.target, + }; + fail('the menu re-opens (mine-for-real test)'); + } + const b = btnScreen(s.miningPopup.primaryBtn); + press(b.x, b.y); + stage = 7; + return; + } + + // ---- 7) the reach completes into mining -------------------------------- + case 7: { const r = wait(() => s.mining.state === 'mining', - () => s.mining.state === 'extending' || s.mining.state === 'mining', + () => ['extending', 'mining'].includes(s.mining.state), 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 ship is still in the MINING state', s.ship.state === 'mining'); 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); + stage = 8; + return; + } - // Let the beam settle to steady + the ore stream build. + // ---- 8) beam steady + ore; then the AUTOPILOT moves the ship ----------- + case 8: { 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', @@ -321,139 +476,133 @@ const stepMachine = () => { 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); + check('the ship is in the MINING state while the beam is live', s.ship.state === 'mining'); - 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) + // The compass autopilot seam — the player sends the ship elsewhere + // (a different discovered object — the mining cluster's rim is right + // next to the ship and the flight would "arrive" before it's + // observable): movement must drop the ship back to 'normal' and end + // the mining. + const objs = s.discoverableObjects(); + const other = objs.find((o) => o.id !== cluster.discoveryId) || objs[0]; + dbg.autopilotTarget = { + id: other.id, + distFromShip: Math.round(Math.hypot(other.x - s.ship.x, other.y - s.ship.y)), + }; + s.autopilotTo(other.id); stage = 9; return; } - // ---- 9) menu again; start a second mining ------------------------------- + // ---- 9) the autopilot break: normal state, beam reaped ------------------ case 9: { + const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, + () => ['mining', 'retracting', 'idle'].includes(s.mining.state), null, 1200, 90000); + if (r === null) return; + if (!r) fail('the autopilot ends the mining and reaps the beam'); + check('the autopilot ends the mining and reaps the beam', true); + check('the ship is back in the NORMAL state', s.ship.state === 'normal'); + check('the autopilot flew the ship', s.ship.target !== null); + + parkNearRock(); + settleCamera(); + press(rockScreen().x, rockScreen().y); // mine again (ESC test) + stage = 10; + return; + } + + // ---- 10) menu again; start a third mining --------------------------------- + case 10: { 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; + stage = 11; return; } - // ---- 10) mining is live again; ESC breaks it ------------------------------ - case 10: { + // ---- 11) mining is live again; ESC breaks it -------------------------------- + case 11: { 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); + check('the ship is in the MINING state', s.ship.state === 'mining'); esc(); - stage = 11; + stage = 12; return; } - // ---- 11) the ESC stop landed ---------------------------------------------- - case 11: { + // ---- 12) the ESC stop landed ------------------------------------------------ + case 12: { 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; + stage = 13; return; } - // ---- 12) arm idle after the ESC stop --------------------------------------- - case 12: { + // ---- 13) arm idle after the ESC stop; the ship is NORMAL again ------------ + case 13: { 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); + check('the ship is back in the NORMAL state', s.ship.state === 'normal'); 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 ------------------------------------------ + // ---- 14) ESC closes an open menu -------------------------------------------- case 14: { + 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 = 15; + return; + } + + // ---- 15) the menu closed; now CANCEL ---------------------------------------- + case 15: { 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'); + check('the ship is in the NORMAL state', s.ship.state === 'normal'); press(rockScreen().x, rockScreen().y); - stage = 15; + stage = 16; return; } - // ---- 15) CANCEL closes without mining -------------------------------------------- - case 15: { + // ---- 16) CANCEL closes without mining --------------------------------------- + case 16: { 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; + stage = 17; return; } - // ---- 16) done ---------------------------------------------------------------------- - case 16: { + // ---- 17) done ----------------------------------------------------------------- + case 17: { 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); + check('the ship is in the NORMAL state', s.ship.state === 'normal'); finish(results.every((x) => x.pass)); return; } diff --git a/js/entities/Ship.js b/js/entities/Ship.js index aeced36..20ed546 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -99,11 +99,49 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { this.radius = worldSize / 2; this.target = null; + + // SHIP STATE — what the ship is doing right now. 'normal' is the + // default: free to fly wherever the player sends it. 'mining' = the + // arm's sequence owns the ship (js/mining/Mining.js drives it; the + // beam + ore stream only live while the state holds). More states + // will land here (docking, boarding, …). Any change OUT of a state — + // another state, or the player MOVING the ship (setTarget, autopilot) + // — is signalled via onStateChange, and the scene's handler tears + // down whatever that state was doing (ends the mining sequence). + this.state = 'normal'; // 'normal' | 'mining' (later: more) + this.onStateChange = null; // (next, prev, reason) => void — the scene installs + } + + /** + * Change the ship's state ('normal' | 'mining' | …). A no-op when the + * state is unchanged. On a real change, onStateChange fires — the scene + * ends the mining sequence when the ship leaves 'mining', whichever + * state (or movement) took it out. + * + * @returns {boolean} true when the state actually changed + */ + setState(next, reason) { + if (next === this.state) return false; + const prev = this.state; + this.state = next; + if (typeof this.onStateChange === 'function') { + try { + this.onStateChange(next, prev, reason); + } catch (err) { + console.error('[ship] onStateChange handler failed', err); + } + } + return true; } /** Set the destination to fly to (world coordinates). */ setTarget(x, y) { this.target = { x, y }; + // Moving the ship ends whatever non-normal state it was in (mining, + // and later states): the player's movement always wins, so any state + // hands the ship back to 'normal' — the scene's onStateChange handler + // finishes the teardown (retracts the beam, releases the arm). + this.setState('normal', 'move'); } /** Stop steering and brake immediately. */ diff --git a/js/mining/Mining.js b/js/mining/Mining.js index 9eba787..0117741 100644 --- a/js/mining/Mining.js +++ b/js/mining/Mining.js @@ -2,20 +2,27 @@ import { config } from '../config/Config.js'; import { MiningBeam } from './MiningBeam.js'; /** - * The ship's mining state — the machine behind the beam: + * The ship's mining state — the machine behind the beam. The SHIP'S + * state gates it (js/entities/Ship.js): the sequence runs only while + * the ship is in its 'mining' state — the scene puts the ship there + * when the arm engages and takes it back to 'normal' when the sequence + * ends. Moving the ship (a world click, the autopilot) or any other + * state change drops it out of 'mining', which ends the sequence: + * the scene's onStateChange handler does the teardown, and update() + * below enforces the gate. * * 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; + * extending the arm is reaching for the rock — the ship holds + * station (GameScene stops it) and sits in 'mining'; + * mining the beam is live (MiningBeam) — the ship stays at its + * rim, still 'mining'; 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. + * BACK TO 'normal' (FREE) — the player can fly as it dies. * * This class owns only the sequence + the beam's lifecycle. Scene * concerns (ship stop, toasts, sfx) ride the onPhase seam — the same @@ -38,8 +45,8 @@ export class Mining { this.armExtendMs = config.get('asteroids.mining.armExtendMs', 1500); } - /** The ship may not fly: mid-extension OR beam live. */ - get isLocked() { + /** The arm's sequence is live: mid-extension OR beam live. */ + get isActive() { return this.state === 'extending' || this.state === 'mining'; } @@ -84,6 +91,19 @@ export class Mining { * mining hand-off and reaps a finished retract. */ update(time, delta) { + // The ship's state is the gate: the sequence (and its visuals) run + // only while the ship is in its 'mining' state. It left that state — + // the player moved the ship, or a state change — so end the sequence + // right here (belt & braces over the scene's onStateChange handler). + const ship = this.scene && this.scene.ship; + if ( + ship && + ship.state !== 'mining' && + (this.state === 'extending' || this.state === 'mining') + ) { + this.stop(); + return; + } if (this.state === 'extending' && time >= this.stateT0 + this.armExtendMs) { this.state = 'mining'; this.beam = new MiningBeam(this.scene, this); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 96c11c5..b7a1de8 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -318,10 +318,11 @@ export class GameScene extends Phaser.Scene { // ---- 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. + // the ship holds station, "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), }); @@ -329,14 +330,24 @@ export class GameScene extends Phaser.Scene { onAction: (id, rock) => this.miningAction(id, rock), }); + // SHIP STATE (js/entities/Ship.js): 'normal' is the default — the + // ship is free; 'mining' — the arm's sequence owns the ship. The + // mining visuals live and die with that state: ANY change out of it + // (the player moving the ship — a world click or the compass + // autopilot — a cancel, or a future state) ends the mining sequence. + this.ship.onStateChange = (next, prev) => { + if (prev === 'mining' && next !== 'mining') this.mining.stop(); + }; + // 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. 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. + // elsewhere closes it and is consumed). Any OTHER click moves the + // ship — which ends the mining state (the beam retracts as the + // ship goes; a mid-reach arm aborts). // 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 @@ -377,19 +388,20 @@ export class GameScene extends Phaser.Scene { // 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. + // the menu's click, never a fly-here. The button and this handler + // fire on the SAME input pass (same frame — this.time.now is + // identical), so "same frame AND inside the panel's footprint" + // isolates that exact click without swallowing the player's next + // deliberate one (a click on the rock, to re-open the menu). if ( this.miningPopup && this.miningPopup.closedByButtonAt !== null && - this.time.now - this.miningPopup.closedByButtonAt < 300 + this.time.now - this.miningPopup.closedByButtonAt < 20 && + this.miningPopup.containsScreen(pointer.x, pointer.y) ) { 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. @@ -399,9 +411,10 @@ export class GameScene extends Phaser.Scene { 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(); + // Any other click MOVES the ship — which ends the mining state + // (the beam retracts as the ship goes; a mid-reach arm aborts). + // The state exit is signalled via ship.onStateChange above. + if (this.mining.isActive) this.mining.stop(); let aim = { x: pointer.worldX, y: pointer.worldY }; for (const s of this.solids) { @@ -1050,11 +1063,12 @@ export class GameScene extends Phaser.Scene { /** * Mining phase changes (Mining → onPhase): the scene's share of the - * sequence — the ship lock, the console calls, the sfx. + * sequence — the ship's STATE, 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.ship.setState('mining', 'mining'); // the arm's sequence owns the ship this.playSfx('mining'); // the arm powers up this.hideHint(); // The console call, in the same slot as the discovery toasts — @@ -1066,6 +1080,9 @@ export class GameScene extends Phaser.Scene { }); } else if (phase === 'stopped') { this.playSfx('deconstruct'); // the arm pulls back + // Back to normal — a no-op when the ship already left 'mining' + // itself (the player moved it, which ended the sequence). + this.ship.setState('normal', 'mining-ended'); } // 'mining' = the beam is live — the visual speaks for itself. } diff --git a/js/ui/MiningPopup.js b/js/ui/MiningPopup.js index 31cd214..59c8585 100644 --- a/js/ui/MiningPopup.js +++ b/js/ui/MiningPopup.js @@ -259,6 +259,18 @@ export class MiningPopup extends Phaser.GameObjects.Container { } } + /** + * Does a SCREEN point (canvas coords, top-left origin) sit inside the + * panel's current footprint — buttons included? GameScene uses this to + * swallow the very click that just closed the panel through a button: + * that click landed ON the panel, so it was panel business, not a + * world click. Position-based, no timing. + */ + containsScreen(sx, sy) { + const cam = this.scene.cameras.main; + return this.contains(cam.scrollX + sx, cam.scrollY + sy); + } + destroy() { this.panelG?.destroy(); this.headerText?.destroy();