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:
Brian Fertig 2026-09-04 13:26:45 -06:00
parent 217ba6bedb
commit f45e3d1cfd
4 changed files with 246 additions and 2 deletions

29
dev/clickout-test.html Normal file
View File

@ -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>

190
dev/clickout-test.mjs Normal file
View File

@ -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);

View File

@ -326,8 +326,18 @@ export class GameScene extends Phaser.Scene {
// The save pop-up is MODAL — while it's up it owns all input // 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). // (its scrim / cards / dialog eat the click; the world stays put).
if (this.savePanel && this.savePanel.isOpen) return; if (this.savePanel && this.savePanel.isOpen) return;
// A click ON the sub-bar is the sub-bar's (its buttons live there). // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
if (this.menuSubBar && this.menuSubBar.isOpen && this.menuSubBar.contains(pointer.x, pointer.y)) return; // 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.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return;
if (this.hudTitleContains(pointer.x, pointer.y)) { if (this.hudTitleContains(pointer.x, pointer.y)) {
@ -875,6 +885,14 @@ export class GameScene extends Phaser.Scene {
* click wins). * click wins).
*/ */
autopilotTo(id) { 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); const o = this.discoverableObjects().find((v) => v.id === id);
if (!o) return; if (!o) return;
// The solid body behind this discovery entry (a world or a cluster). // The solid body behind this discovery entry (a world or a cluster).

View File

@ -199,6 +199,13 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
return this.state === 'open' || this.state === 'opening'; 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() { open() {
if (this.state !== 'closed' || this.dead) return; if (this.state !== 'closed' || this.dead) return;
this.state = 'opening'; this.state = 'opening';