Close sub-menu on click-outside and guard compass autopilot during dismi
- GameScene now closes the menu sub-bar for any pointerdown outside it instead of only swallowing clicks that land on the panel, so world, deck, HUD, and compass taps all dismiss without triggering fly-here, dossier, or navigation side effects. - autopilotTo() bails while the bar is open or closing, preventing a racing compass chip click from navigating after it just dismissed the bar. - Add MenuSubBar.closing getter so callers can detect the close animation state and treat the bar as input-dead during that window. - Ship a headless CDP test (dev/clickout-test.html/.mjs) that drives the real Phaser input pipeline to verify every dismiss path, button firing, re-openability, and the autopilot guard.
This commit is contained in:
parent
217ba6bedb
commit
f45e3d1cfd
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev click-outside sub-menu test</title>
|
||||
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||
rooted at the project root — resolve them against it. -->
|
||||
<base href="../" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<script>
|
||||
window.__CAPTURED_ERRORS__ = [];
|
||||
window.__CAPTURED_LOGS__ = [];
|
||||
const __oe = console.error.bind(console);
|
||||
const __ol = console.log.bind(console);
|
||||
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
|
||||
console.log = (...a) => { window.__CAPTURED_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __ol(...a); };
|
||||
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
|
||||
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
|
||||
</script>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/clickout-test.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Click-outside the sub-menu (headless browser — NOT a Node test).
|
||||
*
|
||||
* Drives the REAL game's input pipeline (mousedown on the canvas) and
|
||||
* checks the dismiss rule in GameScene's pointerdown handler:
|
||||
*
|
||||
* sub-bar OPEN + click OUTSIDE it (world) → closes, no fly-here
|
||||
* sub-bar OPEN + click on its own panel → stays open
|
||||
* sub-bar OPEN + a sub-bar button (Save Game) → the button still fires
|
||||
* sub-bar OPEN + click on the MENU deck button → closes, no re-open
|
||||
* sub-bar OPEN + compass autopilot seam → closes, no navigation
|
||||
* after any of those, MENU still re-opens it
|
||||
*
|
||||
* Served by dev/clickout-test.html; the results land in
|
||||
* `window.__CLICKOUT__` for the CDP runner (dev/cdp-firefox.mjs):
|
||||
*
|
||||
* python3 -m http.server 8080
|
||||
* node dev/cdp-firefox.mjs http://localhost:8080/dev/clickout-test.html
|
||||
*/
|
||||
import Phaser from '../js/vendor/phaser.js';
|
||||
import { config } from '../js/config/Config.js';
|
||||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||||
import { createGameConfig } from '../js/config/GameConfig.js';
|
||||
import { GameScene } from '../js/scenes/GameScene.js';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
|
||||
// A deterministic galaxy (same system every run) + a quiet run (no audio
|
||||
// files to fetch in headless).
|
||||
globalThis.__ORBIT_DEV_SEED = 'CLICKOUT';
|
||||
const gameConfig = createGameConfig();
|
||||
gameConfig.scene = [GameScene]; // GameScene boots first
|
||||
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
|
||||
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
window.game = game;
|
||||
|
||||
const results = [];
|
||||
const check = (label, cond) => {
|
||||
const pass = !!cond;
|
||||
results.push({ label, pass });
|
||||
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
|
||||
};
|
||||
|
||||
// WAIT — throttling-proof. This headless box starves setTimeout and
|
||||
// stretches its compositor clock, so the only honest barrier is the
|
||||
// GAME'S OWN CLOCK: Phaser v4 sets scene time.now from the engine loop
|
||||
// time (monotonic across scene transitions). Poll it on rAF.
|
||||
const gameClock = () => {
|
||||
try {
|
||||
const s = window.game.scene.getScenes(true)[0];
|
||||
if (s && typeof s.time.now === 'number') return s.time.now;
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
const wait = (ms) => new Promise((resolve) => {
|
||||
const base = gameClock();
|
||||
if (base === null) {
|
||||
const start = performance.now();
|
||||
setTimeout(() => resolve(), ms);
|
||||
return;
|
||||
}
|
||||
const poll = () => {
|
||||
const now = gameClock();
|
||||
if (now !== null && now - base >= ms) return resolve();
|
||||
requestAnimationFrame(poll);
|
||||
};
|
||||
requestAnimationFrame(poll);
|
||||
});
|
||||
|
||||
// A real mouse press at CANVAS screen coords (x, y). Phaser 4.2.1 binds
|
||||
// mousedown/mouseup/mousemove on the canvas element, so a dispatched
|
||||
// MouseEvent drives the full input pipeline (pointer → scene handler →
|
||||
// interactive objects). FIT mode scales the canvas, so map canvas →
|
||||
// client space with the rendered rect (not 1:1 pixels).
|
||||
const press = (x, y) => {
|
||||
const canvas = game.canvas;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const zoom = r.width / canvas.width;
|
||||
const mk = (type, buttons) => new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 0,
|
||||
buttons,
|
||||
clientX: r.left + x * zoom,
|
||||
clientY: r.top + y * zoom,
|
||||
});
|
||||
canvas.dispatchEvent(mk('mouseover', 0));
|
||||
canvas.dispatchEvent(mk('mousedown', 1));
|
||||
canvas.dispatchEvent(mk('mouseup', 0));
|
||||
};
|
||||
|
||||
const OPEN_MS = 450; // openMs (340) + button decode margin
|
||||
const CLOSE_MS = 350; // closeMs (200) + margin
|
||||
|
||||
const run = async () => {
|
||||
await wait(900); // boot: galaxy, system, deck, sub-bar
|
||||
const scene = game.scene.getScene('GameScene');
|
||||
check('boots into GameScene', game.scene.isActive('GameScene'));
|
||||
check('the sub-bar starts closed', scene.menuSubBar.isOpen === false);
|
||||
|
||||
// ---- 1) open with MENU, dismiss with a click in open space -----------
|
||||
scene.menuAction();
|
||||
await wait(OPEN_MS);
|
||||
check('MENU folds the sub-bar up', scene.menuSubBar.isOpen);
|
||||
const targetBefore = scene.ship.target;
|
||||
press(scene.scale.width / 2, scene.scale.height * 0.4); // open space
|
||||
await wait(CLOSE_MS);
|
||||
check('a click OUTSIDE the sub-bar closes it',
|
||||
scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed');
|
||||
check('the dismissing click does NOT fly the ship',
|
||||
scene.ship.target === targetBefore);
|
||||
|
||||
// ---- 2) a click on the sub-bar's own panel keeps it open -------------
|
||||
scene.menuAction();
|
||||
await wait(OPEN_MS);
|
||||
check('the sub-bar re-opens', scene.menuSubBar.isOpen);
|
||||
const bar = scene.menuSubBar.rect;
|
||||
press(bar.x + 5, bar.y + bar.h / 2); // left padding — no button there
|
||||
await wait(250);
|
||||
check('a click on the sub-bar panel (not a button) keeps it open',
|
||||
scene.menuSubBar.isOpen === true);
|
||||
|
||||
// ---- 3) a sub-bar button still fires while the bar is open -----------
|
||||
const saveBtn = scene.menuSubBar.buttons.find((b) => b.id === 'save');
|
||||
check('the Save Game button is inside the open bar',
|
||||
scene.menuSubBar.contains(
|
||||
scene.menuSubBar.x + saveBtn.btn.x,
|
||||
scene.menuSubBar.y + saveBtn.btn.y));
|
||||
press(scene.menuSubBar.x + saveBtn.btn.x, scene.menuSubBar.y + saveBtn.btn.y);
|
||||
await wait(500);
|
||||
check('Save Game still opens the 10-slot pop-up',
|
||||
scene.savePanel.isOpen && scene.savePanel.mode === 'save');
|
||||
scene.savePanel.close(); // the sub-bar stays open behind it (existing)
|
||||
await wait(500);
|
||||
|
||||
// ---- 4) MENU toggles it closed again (toggle path intact) ------------
|
||||
check('the sub-bar is still open under the closed pop-up',
|
||||
scene.menuSubBar.isOpen === true);
|
||||
scene.menuAction();
|
||||
await wait(CLOSE_MS);
|
||||
check('MENU folds the sub-bar back down', scene.menuSubBar.state === 'closed');
|
||||
|
||||
// ---- 5) clicking the MENU deck button closes it — no re-open ---------
|
||||
scene.menuAction();
|
||||
await wait(OPEN_MS);
|
||||
check('the sub-bar re-opens (deck-button test)', scene.menuSubBar.isOpen);
|
||||
const menuSlot = scene.actionBar.slots.find((s) => s.id === 'menu');
|
||||
press(menuSlot.slot.x, menuSlot.slot.y);
|
||||
await wait(CLOSE_MS);
|
||||
check('clicking the MENU button closes the sub-bar (no re-open)',
|
||||
scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed');
|
||||
|
||||
// ---- 6) the compass seam can't navigate while the bar is open --------
|
||||
scene.menuAction();
|
||||
await wait(OPEN_MS);
|
||||
check('the sub-bar re-opens (compass test)', scene.menuSubBar.isOpen);
|
||||
const targetBefore2 = scene.ship.target;
|
||||
scene.autopilotTo('home'); // the chip's seam, with the bar open
|
||||
await wait(CLOSE_MS);
|
||||
check('a compass autopilot while open: bar closes, ship NOT targeted',
|
||||
scene.menuSubBar.state === 'closed' && scene.ship.target === targetBefore2);
|
||||
|
||||
// ---- 7) all dismiss paths left the bar re-openable -------------------
|
||||
scene.menuAction();
|
||||
await wait(OPEN_MS);
|
||||
check('the sub-bar re-opens after every dismiss path', scene.menuSubBar.isOpen);
|
||||
};
|
||||
|
||||
let done = false;
|
||||
game.events.once('ready', async () => {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
results.push({ label: `THREW: ${String(err && err.message || err)}`, pass: false });
|
||||
}
|
||||
const pass = results.length > 0 && results.every((r) => r.pass);
|
||||
window.__CLICKOUT__ = { pass, results, errors: window.__CAPTURED_ERRORS__ || [] };
|
||||
done = true;
|
||||
console.log(pass ? 'CLICKOUT PASS' : 'CLICKOUT FAIL');
|
||||
});
|
||||
|
||||
// Hard timeout: a hung flow must not hang the driver forever.
|
||||
setTimeout(() => {
|
||||
if (!done) {
|
||||
window.__CLICKOUT__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] };
|
||||
}
|
||||
}, 300000);
|
||||
|
|
@ -326,8 +326,18 @@ export class GameScene extends Phaser.Scene {
|
|||
// The save pop-up is MODAL — while it's up it owns all input
|
||||
// (its scrim / cards / dialog eat the click; the world stays put).
|
||||
if (this.savePanel && this.savePanel.isOpen) return;
|
||||
// A click ON the sub-bar is the sub-bar's (its buttons live there).
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen && this.menuSubBar.contains(pointer.x, pointer.y)) return;
|
||||
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
|
||||
// its buttons — the buttons fire on their own pointerdown). ANY
|
||||
// click OUTSIDE — world, deck, HUD, compass — folds it back down
|
||||
// and that click is done (no fly-here, no dossier toggle, no
|
||||
// autopilot). Deck buttons still get their own press from the
|
||||
// deck's handler; the Menu button's toggle is safe either order
|
||||
// because open() is a no-op while the bar is still 'closing'.
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen) {
|
||||
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
|
||||
this.menuSubBar.close();
|
||||
return;
|
||||
}
|
||||
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
|
||||
if (this.compass.contains(pointer.x, pointer.y)) return;
|
||||
if (this.hudTitleContains(pointer.x, pointer.y)) {
|
||||
|
|
@ -875,6 +885,14 @@ export class GameScene extends Phaser.Scene {
|
|||
* click wins).
|
||||
*/
|
||||
autopilotTo(id) {
|
||||
// While the sub-bar is open (or this very click just closed it — the
|
||||
// scene's handler and the chip's own listener race on event order),
|
||||
// the chip's click is the menu-dismiss, not a navigation command.
|
||||
const bar = this.menuSubBar;
|
||||
if (bar && (bar.isOpen || bar.closing)) {
|
||||
if (bar.isOpen) bar.close();
|
||||
return;
|
||||
}
|
||||
const o = this.discoverableObjects().find((v) => v.id === id);
|
||||
if (!o) return;
|
||||
// The solid body behind this discovery entry (a world or a cluster).
|
||||
|
|
|
|||
|
|
@ -199,6 +199,13 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
|
|||
return this.state === 'open' || this.state === 'opening';
|
||||
}
|
||||
|
||||
/** True while the close animation is running (the bar is already dead
|
||||
* as far as input goes — GameScene uses this so a click that dismissed
|
||||
* it can't re-trigger its seams regardless of event order). */
|
||||
get closing() {
|
||||
return this.state === 'closing';
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.state !== 'closed' || this.dead) return;
|
||||
this.state = 'opening';
|
||||
|
|
|
|||
Loading…
Reference in New Issue