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
|
// 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).
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue