diff --git a/dev/clickout-test.html b/dev/clickout-test.html new file mode 100644 index 0000000..2db2f87 --- /dev/null +++ b/dev/clickout-test.html @@ -0,0 +1,29 @@ + + + + + Orbit — dev click-outside sub-menu test + + + + + + + +
+ + + diff --git a/dev/clickout-test.mjs b/dev/clickout-test.mjs new file mode 100644 index 0000000..eab0419 --- /dev/null +++ b/dev/clickout-test.mjs @@ -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); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 01e2147..aab4add 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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). diff --git a/js/ui/MenuSubBar.js b/js/ui/MenuSubBar.js index 7cea124..8748732 100644 --- a/js/ui/MenuSubBar.js +++ b/js/ui/MenuSubBar.js @@ -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';