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.
This commit is contained in:
parent
cff8fa335e
commit
75767dc5a5
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue