Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Fertig 7b48e030ef Additional planetary resources. 2026-09-04 18:07:00 -06:00
Brian Fertig 75767dc5a5 Add ship state machine and let movement break the mining sequence
- Introduce `Ship.state` ('normal' | 'mining') with `setState()` and an
  `onStateChange` callback so the scene can react to state exits.
- Any player-initiated movement (world click or compass autopilot) now
  drops the ship back to 'normal', which ends the mining sequence via the
  scene's handler; Mining.update() enforces the gate as a belt-and-braces.
- Rename `Mining.isLocked` to `isActive` and update GameScene click logic:
  clicks no longer hold during extension — they move the ship and abort the
  arm's reach.
- Tighten the popup-button swallow window from 300 ms to 20 ms and add a
  `containsScreen()` hit-test so only the exact button press is consumed,
  not subsequent deliberate clicks on the panel area.
- Expand the headless mining test to cover the new movement-abort contract:
  world click mid-extension breaks the arm, autopilot retracts the beam,
  and ship state transitions are asserted at every stage.
2026-09-04 18:06:33 -06:00
7 changed files with 355 additions and 119 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

View File

@ -2,19 +2,24 @@
* Mining flow (headless browser NOT a Node test). * Mining flow (headless browser NOT a Node test).
* *
* Drives the REAL game's input pipeline (mousedown/keydown on the canvas) * 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) * click a rock the menu opens at the click (MINE ASTEROIDS)
* a click OUTSIDE it closes, and does not fly the ship * a click OUTSIDE it closes, and does not fly the ship
* click MINE ASTEROIDS "extending" (ship locked, console toast * click MINE ASTEROIDS "extending": the ship is in 'mining' state,
* "Extending Mining Arm...") * holds station, console says "Extending..."
* a world click meanwhile is held (no fly, no abort) * a world click meanwhile MOVES the ship: the extension ABORTS, the
* the ~1.5 s reach "mining": the beam is live, ore motes ride it * ship goes back to 'normal' and flies
* click the rock again the menu now says STOP MINING * mine again, the ~1.5 s "mining": beam live, ore motes ride it,
* click STOP MINING the beam retracts, then the arm is idle * reach the ship still 'mining'
* ESC while mining breaks the beam * the COMPASS AUTOPILOT MOVES the ship: 'normal' again, the beam
* ESC with the menu open closes it * retracts as the ship goes
* click CANCEL closes, no mining * 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 * 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 * 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'); const s = game.scene.getScene('GameScene');
return w2s(rock.wx, rock.wy); 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 openSpaceScreen = () => {
const s = game.scene.getScene('GameScene'); 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); const errors = (window.__CAPTURED_ERRORS__ || []).concat(pumpErrors);
if (errors.length === 0) all.push({ label: 'no console errors were captured', pass: true }); if (errors.length === 0) all.push({ label: 'no console errors were captured', pass: true });
const ok = all.every((r) => r.pass); 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'); 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 scene = () => game.scene.getScene('GameScene');
const fail = (label) => { const fail = (label) => {
@ -187,6 +242,41 @@ const fail = (label) => {
finish(false); 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 = () => { const stepMachine = () => {
let s = null; let s = null;
try { s = scene(); } catch { /* not booted yet */ } try { s = scene(); } catch { /* not booted yet */ }
@ -208,13 +298,8 @@ const stepMachine = () => {
} }
cluster = s.asteroidClusters[0]; cluster = s.asteroidClusters[0];
rock = cluster.members[0]; rock = cluster.members[0];
s.ship.stop(); parkNearRock();
const sx = rock.wx - 190; settleCamera();
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); settle(200);
press(rockScreen().x, rockScreen().y); press(rockScreen().x, rockScreen().y);
stage = 1; stage = 1;
@ -235,6 +320,7 @@ const stepMachine = () => {
s.miningPopup.headerText.text.toUpperCase().includes(String(cluster.discoveryName).toUpperCase())); 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 fly the ship', s.ship.target === null);
check('the rock click did NOT start mining', s.mining.state === 'idle'); 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 press(openSpaceScreen().x, openSpaceScreen().y); // open space — outside the panel
stage = 2; stage = 2;
return; return;
@ -279,8 +365,8 @@ const stepMachine = () => {
if (!r) fail('MINE ASTEROIDS starts the extension'); if (!r) fail('MINE ASTEROIDS starts the extension');
check('MINE ASTEROIDS starts the extension', true); check('MINE ASTEROIDS starts the extension', true);
check('the menu closed on the button press', s.miningPopup.isOpen === false); check('the menu closed on the button press', s.miningPopup.isOpen === false);
check('the ship is locked while the arm extends', check('the ship is in the MINING state while the arm extends', s.ship.state === 'mining');
s.ship.target === null && s.mining.isLocked === true); check('the ship holds station while the arm extends', s.ship.target === null);
if (!sawExtendingToast) { if (!sawExtendingToast) {
// One more chance while the arm is still out (the toast holds the // One more chance while the arm is still out (the toast holds the
// whole extension window). // whole extension window).
@ -293,26 +379,95 @@ const stepMachine = () => {
} }
check('the console says Extending Mining Arm...', sawExtendingToast); check('the console says Extending Mining Arm...', sawExtendingToast);
// A world click mid-extension holds (no fly, no abort). // A world click mid-extension MOVES the ship — which ends the
if (s.mining.state === 'extending') press(openSpaceScreen().x, openSpaceScreen().y); // 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; stage = 5;
return; return;
} }
// ---- 5) the reach completes into mining -------------------------------- // ---- 5) the movement broke the arm; set up the real attempt ----------
case 5: { 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', const r = wait(() => s.mining.state === 'mining',
() => s.mining.state === 'extending' || s.mining.state === 'mining', () => ['extending', 'mining'].includes(s.mining.state),
null, 1200, 90000); null, 1200, 90000);
if (r === null) return; if (r === null) return;
if (!r) fail('the extension completes into mining'); if (!r) fail('the extension completes into mining');
check('the extension completes into mining', true); 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 live', !!s.mining.beam && (s.mining.beam.state === 'in' || s.mining.beam.state === 'steady'));
check('the beam is locked to the clicked rock', check('the beam is locked to the clicked rock',
!!s.mining.beam && s.mining.beam.member === rock && s.mining.member === 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( const r2 = wait(
() => !!s.mining.beam && s.mining.beam.state === 'steady' && s.mining.beam.particles.some((p) => p.active), () => !!s.mining.beam && s.mining.beam.state === 'steady' && s.mining.beam.particles.some((p) => p.active),
() => !!s.mining.beam && s.mining.state === 'mining', () => !!s.mining.beam && s.mining.state === 'mining',
@ -321,139 +476,133 @@ const stepMachine = () => {
if (!r2) fail('the beam settles and ore motes ride it'); if (!r2) fail('the beam settles and ore motes ride it');
check('the beam is live and steady', true); check('the beam is live and steady', true);
check('ore motes ride the beam toward the ship', 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 // The compass autopilot seam — the player sends the ship elsewhere
stage = 6; // (a different discovered object — the mining cluster's rim is right
return; // 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.
// ---- 6) the target menu now offers STOP MINING ------------------------ const objs = s.discoverableObjects();
case 6: { const other = objs.find((o) => o.id !== cluster.discoveryId) || objs[0];
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); dbg.autopilotTarget = {
if (r === null) return; id: other.id,
if (!r) fail('the menu opens on the mining target'); distFromShip: Math.round(Math.hypot(other.x - s.ship.x, other.y - s.ship.y)),
check('the menu opens on the mining target', true); };
check('the primary button now says STOP MINING', s.autopilotTo(other.id);
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; stage = 9;
return; return;
} }
// ---- 9) menu again; start a second mining ------------------------------- // ---- 9) the autopilot break: normal state, beam reaped ------------------
case 9: { 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); const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return; if (r === null) return;
if (!r) fail('the menu opens again (ESC test)'); if (!r) fail('the menu opens again (ESC test)');
const b = btnScreen(s.miningPopup.primaryBtn); const b = btnScreen(s.miningPopup.primaryBtn);
press(b.x, b.y); press(b.x, b.y);
stage = 10; stage = 11;
return; return;
} }
// ---- 10) mining is live again; ESC breaks it ------------------------------ // ---- 11) mining is live again; ESC breaks it --------------------------------
case 10: { case 11: {
const r = wait(() => s.mining.state === 'mining', const r = wait(() => s.mining.state === 'mining',
() => ['extending', 'mining'].includes(s.mining.state), null, 1200, 90000); () => ['extending', 'mining'].includes(s.mining.state), null, 1200, 90000);
if (r === null) return; if (r === null) return;
if (!r) fail('mining is live again (ESC test)'); if (!r) fail('mining is live again (ESC test)');
check('mining is live again (ESC test)', true); check('mining is live again (ESC test)', true);
check('the ship is in the MINING state', s.ship.state === 'mining');
esc(); esc();
stage = 11; stage = 12;
return; return;
} }
// ---- 11) the ESC stop landed ---------------------------------------------- // ---- 12) the ESC stop landed ------------------------------------------------
case 11: { case 12: {
const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle', const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle',
() => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000); () => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return; if (r === null) return;
if (!r) fail('ESC breaks the beam'); if (!r) fail('ESC breaks the beam');
check('ESC breaks the beam', true); check('ESC breaks the beam', true);
stage = 12; stage = 13;
return; return;
} }
// ---- 12) arm idle after the ESC stop --------------------------------------- // ---- 13) arm idle after the ESC stop; the ship is NORMAL again ------------
case 12: { case 13: {
const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null,
() => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000); () => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000);
if (r === null) return; if (r === null) return;
if (!r) fail('the arm is idle after the ESC stop'); if (!r) fail('the arm is idle after the ESC stop');
check('the arm is idle after the ESC stop', true); 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) 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; stage = 14;
return; return;
} }
// ---- 14) the menu closed; now CANCEL ------------------------------------------ // ---- 14) ESC closes an open menu --------------------------------------------
case 14: { 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); const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000);
if (r === null) return; if (r === null) return;
if (!r) fail('ESC closes the open menu'); if (!r) fail('ESC closes the open menu');
check('ESC closes the open menu', true); check('ESC closes the open menu', true);
check('...and did not start mining', s.mining.state === 'idle'); 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); press(rockScreen().x, rockScreen().y);
stage = 15; stage = 16;
return; return;
} }
// ---- 15) CANCEL closes without mining -------------------------------------------- // ---- 16) CANCEL closes without mining ---------------------------------------
case 15: { case 16: {
const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000);
if (r === null) return; if (r === null) return;
if (!r) fail('the menu opens again (CANCEL test)'); if (!r) fail('the menu opens again (CANCEL test)');
const c = btnScreen(s.miningPopup.cancelBtn); const c = btnScreen(s.miningPopup.cancelBtn);
press(c.x, c.y); press(c.x, c.y);
stage = 16; stage = 17;
return; return;
} }
// ---- 16) done ---------------------------------------------------------------------- // ---- 17) done -----------------------------------------------------------------
case 16: { case 17: {
const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000); const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000);
if (r === null) return; if (r === null) return;
if (!r) fail('CANCEL closes the menu'); if (!r) fail('CANCEL closes the menu');
check('CANCEL closes the menu', true); check('CANCEL closes the menu', true);
check('CANCEL does not start mining', s.mining.state === 'idle'); check('CANCEL does not start mining', s.mining.state === 'idle');
check('CANCEL does not fly the ship', s.ship.target === null); 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)); finish(results.every((x) => x.pass));
return; return;
} }

View File

@ -99,11 +99,49 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.radius = worldSize / 2; this.radius = worldSize / 2;
this.target = null; 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). */ /** Set the destination to fly to (world coordinates). */
setTarget(x, y) { setTarget(x, y) {
this.target = { 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. */ /** Stop steering and brake immediately. */

View File

@ -2,20 +2,27 @@ import { config } from '../config/Config.js';
import { MiningBeam } from './MiningBeam.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 * idle begin() extending (armExtendMs) mining stop() retracting
* *
* (stop() cuts the extension) beam out * (stop() cuts the extension) beam out
* *
* *
* extending the arm is reaching for the rock the ship is LOCKED * extending the arm is reaching for the rock the ship holds
* (GameScene stops it) and the world holds; * station (GameScene stops it) and sits in 'mining';
* mining the beam is live (MiningBeam) the ship stays LOCKED at * mining the beam is live (MiningBeam) the ship stays at its
* its rim; retargeting (begin on another rock) cuts the * rim, still 'mining'; retargeting (begin on another rock)
* current beam and re-extends; * cuts the current beam and re-extends;
* retracting the beam is pulling back rock ship; the ship is already * 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 * This class owns only the sequence + the beam's lifecycle. Scene
* concerns (ship stop, toasts, sfx) ride the onPhase seam the same * 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); this.armExtendMs = config.get('asteroids.mining.armExtendMs', 1500);
} }
/** The ship may not fly: mid-extension OR beam live. */ /** The arm's sequence is live: mid-extension OR beam live. */
get isLocked() { get isActive() {
return this.state === 'extending' || this.state === 'mining'; return this.state === 'extending' || this.state === 'mining';
} }
@ -84,6 +91,19 @@ export class Mining {
* mining hand-off and reaps a finished retract. * mining hand-off and reaps a finished retract.
*/ */
update(time, delta) { 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) { if (this.state === 'extending' && time >= this.stateT0 + this.armExtendMs) {
this.state = 'mining'; this.state = 'mining';
this.beam = new MiningBeam(this.scene, this); this.beam = new MiningBeam(this.scene, this);

View File

@ -318,10 +318,11 @@ export class GameScene extends Phaser.Scene {
// ---- MINING (the clusters are now verbs, not just rocks) ----------- // ---- MINING (the clusters are now verbs, not just rocks) -----------
// The state machine (js/mining/Mining.js): idle → extending (~1.5 s, // The state machine (js/mining/Mining.js): idle → extending (~1.5 s,
// the ship is locked, "Extending Mining Arm..." in the console slot) // the ship holds station, "Extending Mining Arm..." in the console
// → mining (the beam is live — js/mining/MiningBeam.js) → retracting. // slot) → mining (the beam is live — js/mining/MiningBeam.js)
// The pop-up (js/ui/MiningPopup.js) is the context menu a rock click // → retracting. The pop-up (js/ui/MiningPopup.js) is the context
// opens, world-anchored right where the player clicked. // menu a rock click opens, world-anchored right where the player
// clicked.
this.mining = new Mining(this, { this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p), onPhase: (p) => this.onMiningPhase(p),
}); });
@ -329,14 +330,24 @@ export class GameScene extends Phaser.Scene {
onAction: (id, rock) => this.miningAction(id, rock), 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 // Input: click = fly there. A click ON the command deck is deck
// business, a click on a compass name tag is an autopilot (it // business, a click on a compass name tag is an autopilot (it
// already retargeted the ship), and a click on the system NAME (or // already retargeted the ship), and a click on the system NAME (or
// the caret beside it) toggles the dossier open/closed — none of // the caret beside it) toggles the dossier open/closed — none of
// those is a fly-here. A click on an ASTEROID opens the mining // 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 // pop-up (a context menu — its buttons own their clicks; any click
// elsewhere closes it and is consumed). While the beam is live the // elsewhere closes it and is consumed). Any OTHER click moves the
// ship is LOCKED: a world click breaks the beam first, then flies. // 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 // A click inside a planet clamps to that planet's keep-out rim — the
// ship can stop at the clearance, never inside. (Worlds don't // ship can stop at the clearance, never inside. (Worlds don't
// overlap, so sequential clamping is exact.) A click BEYOND the // overlap, so sequential clamping is exact.) A click BEYOND the
@ -377,19 +388,20 @@ export class GameScene extends Phaser.Scene {
// A click the pop-up's OWN buttons just handled (they close it from // 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 // 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 ( if (
this.miningPopup && this.miningPopup &&
this.miningPopup.closedByButtonAt !== null && 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; 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 // A rock under the cursor: clicking an asteroid opens the mining
// menu (or the stop menu, when this cluster is the beam's target) // menu (or the stop menu, when this cluster is the beam's target)
// — this click does not fly the ship. // — this click does not fly the ship.
@ -399,9 +411,10 @@ export class GameScene extends Phaser.Scene {
return; return;
} }
// Beam live and the click is OFF the rock: break the beam (it // Any other click MOVES the ship — which ends the mining state
// retracts while the ship goes) and fly to the click. // (the beam retracts as the ship goes; a mid-reach arm aborts).
if (this.mining.state === 'mining') this.mining.stop(); // The state exit is signalled via ship.onStateChange above.
if (this.mining.isActive) this.mining.stop();
let aim = { x: pointer.worldX, y: pointer.worldY }; let aim = { x: pointer.worldX, y: pointer.worldY };
for (const s of this.solids) { for (const s of this.solids) {
@ -1050,11 +1063,12 @@ export class GameScene extends Phaser.Scene {
/** /**
* Mining phase changes (Mining onPhase): the scene's share of the * 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) { onMiningPhase(phase) {
if (phase === 'extending') { if (phase === 'extending') {
this.ship.stop(); // hold station — the arm needs a steady hull 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.playSfx('mining'); // the arm powers up
this.hideHint(); this.hideHint();
// The console call, in the same slot as the discovery toasts — // 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') { } else if (phase === 'stopped') {
this.playSfx('deconstruct'); // the arm pulls back 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. // 'mining' = the beam is live — the visual speaks for itself.
} }

View File

@ -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() { destroy() {
this.panelG?.destroy(); this.panelG?.destroy();
this.headerText?.destroy(); this.headerText?.destroy();