diff --git a/assets/videos/gasgiant-land-02.mp4 b/assets/videos/gasgiant-land-02.mp4 new file mode 100644 index 0000000..846591a Binary files /dev/null and b/assets/videos/gasgiant-land-02.mp4 differ diff --git a/dev/zorder-test.html b/dev/zorder-test.html new file mode 100644 index 0000000..fa94fcb --- /dev/null +++ b/dev/zorder-test.html @@ -0,0 +1,26 @@ + + + + + Orbit — dev z-order + input-state test + + + + + + + +
+ + + diff --git a/dev/zorder-test.mjs b/dev/zorder-test.mjs new file mode 100644 index 0000000..9b17f9d --- /dev/null +++ b/dev/zorder-test.mjs @@ -0,0 +1,190 @@ +/** + * Z-order + input-state probe (headless browser — NOT a Node test). + * + * Verifies the two menu fixes against the real engine: + * + * Z-ORDER (paint order — the scene display list is depth-sorted + * ascending, and containers expand their children inline at their + * slot, so list order IS back-to-front paint order): + * stars(0-2) < planet(5) < ship(10) < HUD(30) < compass(40) + * < toast(45) < command deck(50) < menu sub-bar(60) + * < save pop-up(70) + * ... and the confirm dialog is the pop-up's last content child (only + * the toast trails it, and it sits outside the dialog's area), so it + * paints on top of the pop-up. + * + * INPUT STATE (v4 gates hit-testing on `input.enabled`): + * sub-bar buttons inert while closed, live while open + * pop-up scrim / cards / footer inert while hidden, live while shown + * confirm scrim + buttons inert while the dialog is hidden, live + * while it is up + * + * Served by dev/zorder-test.html; results land in `window.__ZORDER__`: + * + * python3 -m http.server 8091 + * node dev/cdp-firefox.mjs http://127.0.0.1:8091/dev/zorder-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); + +globalThis.__ORBIT_DEV_SEED = 'ZORDER'; +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +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}`); +}; + +// Throttling-proof wait on the game's own clock (see clickout-test). +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) { + setTimeout(() => resolve(), ms); + return; + } + const poll = () => { + const now = gameClock(); + if (now !== null && now - base >= ms) return resolve(); + requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); +}); + +const run = async () => { + await wait(900); // boot + const scene = game.scene.getScene('GameScene'); + check('boots into GameScene', game.scene.isActive('GameScene')); + + // ---- declared depths --------------------------------------------------- + check('planets sit at depth 5', scene.planet.depth === 5); + check('the ship sits at depth 10', scene.ship.depth === 10); + check('the command deck sits at depth 50', scene.actionBar.depth === 50); + check('the menu sub-bar sits at depth 60', scene.menuSubBar.depth === 60); + check('the save pop-up sits at depth 70', scene.savePanel.depth === 70); + + // ---- paint order: the scene display list ------------------------------- + const dl = scene.sys.displayList.getChildren(); + const idx = (o) => dl.indexOf(o); + const iPlanet = idx(scene.planet); + const iShip = idx(scene.ship); + const iCompass = idx(scene.compass); + const iDeck = idx(scene.actionBar); + const iSubBar = idx(scene.menuSubBar); + const iPanel = idx(scene.savePanel); + const name = (o) => o === scene.planet ? 'planet' : o === scene.ship ? 'ship' + : o === scene.compass ? 'compass' : o === scene.actionBar ? 'actionBar' + : o === scene.menuSubBar ? 'menuSubBar' : o === scene.savePanel ? 'savePanel' + : (o.type || 'obj') + '@' + (o._depth !== undefined ? o._depth : o.depth); + console.log('displayList tail:', dl.slice(-8).map(name).join(' < ')); + check('every probed object is in the display list', + [iPlanet, iShip, iCompass, iDeck, iSubBar, iPanel].every((i) => i >= 0)); + results.push({ label: `DL planet=${iPlanet} ship=${iShip} compass=${iCompass} actionBar=${iDeck} menuSubBar=${iSubBar} savePanel=${iPanel} len=${dl.length}`, pass: true }); + check('the display list is depth-sorted (ascending)', + dl.slice(-20).every((o, i, a) => i === 0 || o._depth >= a[i - 1]._depth)); + check('world (planet, ship) paints UNDER the command deck', iPlanet < iDeck && iShip < iDeck); + check('the command deck paints UNDER the sub-bar', iDeck < iSubBar); + check('the sub-bar paints UNDER the save pop-up', iSubBar < iPanel); + const kids = (() => { + const p = scene.savePanel; + if (p.list && Array.isArray(p.list)) return p.list; // v4 Container children live in `list` + const c = p.children; + if (!c) return null; + if (Array.isArray(c)) return c; + if (c.list && Array.isArray(c.list)) return c.list; + if (typeof c.getChildren === 'function') return c.getChildren(); + return null; + })(); + check('the confirm dialog paints above the panel contents (only the toast trails it)', (() => { + if (!Array.isArray(kids) || kids.length === 0) return false; + const ci = kids.indexOf(scene.savePanel.confirm); + return ci >= 0 && kids.slice(ci + 1).every((c) => c === scene.savePanel.toast); + })()); + + // ---- input states (v4: input.enabled) ---------------------------------- + const enabled = (o) => !!(o.input && o.input.enabled); + check('sub-bar buttons are input-INERT while the bar is closed', + scene.menuSubBar.buttons.every((s) => !enabled(s.btn.panel))); + check('pop-up scrim/cards/footer are input-INERT while hidden', + !enabled(scene.savePanel.scrim) + && scene.savePanel.cards.every((c) => !enabled(c.panel)) + && !enabled(scene.savePanel.cancelBtn.panel) && !enabled(scene.savePanel.downloadBtn.panel)); + check('confirm scrim + buttons are input-INERT while the dialog is hidden', + !enabled(scene.savePanel.confirm.scrim) + && !enabled(scene.savePanel.confirm.confirmBtn.panel) + && !enabled(scene.savePanel.confirm.cancelBtn.panel)); + + scene.menuAction(); // open the sub-bar + await wait(450); + check('sub-bar buttons are input-LIVE while the bar is open', + scene.menuSubBar.isOpen && scene.menuSubBar.buttons.every((s) => enabled(s.btn.panel))); + + scene.subBarAction('save'); // open the pop-up (save mode) + await wait(450); + check('pop-up scrim + cards + footer are input-LIVE while shown', + scene.savePanel.isOpen + && enabled(scene.savePanel.scrim) + && scene.savePanel.cards.every((c) => enabled(c.panel)) + && enabled(scene.savePanel.cancelBtn.panel) && enabled(scene.savePanel.downloadBtn.panel)); + + scene.savePanel.confirmOverwrite(1, { galaxyName: 'X', savedAt: new Date().toISOString() }); + await wait(300); + check('confirm scrim + buttons are input-LIVE while the dialog is up', + scene.savePanel.confirm.isOpen + && enabled(scene.savePanel.confirm.scrim) + && enabled(scene.savePanel.confirm.confirmBtn.panel) + && enabled(scene.savePanel.confirm.cancelBtn.panel)); + scene.savePanel.confirm.cancel(); + await wait(350); + check('confirm goes inert again after CANCEL', + scene.savePanel.confirm.isOpen === false + && !enabled(scene.savePanel.confirm.scrim) + && !enabled(scene.savePanel.confirm.confirmBtn.panel)); + + scene.savePanel.close(); + await wait(400); + scene.menuSubBar.close(); + await wait(400); + check('sub-bar goes inert again after close', + scene.menuSubBar.state === 'closed' + && scene.menuSubBar.buttons.every((s) => !enabled(s.btn.panel))); + check('pop-up goes inert again after close', + scene.savePanel.isOpen === false && !enabled(scene.savePanel.scrim) + && scene.savePanel.cards.every((c) => !enabled(c.panel))); +}; + +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.__ZORDER__ = { pass, results, errors: window.__CAPTURED_ERRORS__ || [] }; + done = true; + console.log(pass ? 'ZORDER PASS' : 'ZORDER FAIL'); +}); + +setTimeout(() => { + if (!done) window.__ZORDER__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] }; +}, 300000); diff --git a/js/ui/ConfirmOverlay.js b/js/ui/ConfirmOverlay.js index 39ab07e..07813aa 100644 --- a/js/ui/ConfirmOverlay.js +++ b/js/ui/ConfirmOverlay.js @@ -4,6 +4,7 @@ import { toColor, toCss } from '../utils/Color.js'; import { fontStack } from '../utils/Theme.js'; import { CyberShape } from './CyberShape.js'; import { MenuButton } from './MenuButton.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; import { ScrambleDecode } from '../utils/Decode.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; @@ -59,7 +60,10 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container { hitAreaCallback: () => true, }); this.scrim.on('pointerdown', () => this.cancel()); - this.scrim.ignorePointer = true; // armed while shown only + // Armed while shown ONLY (v4: `input.enabled` — the `ignorePointer` + // idiom is a physics-world property and a no-op, which is why this + // invisible scrim used to swallow every click beneath it). + setInteractiveEnabled(this.scrim, false); this.add(this.scrim); this.panelG = scene.add.graphics().setScrollFactor(0); @@ -117,6 +121,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container { this.confirmBtn.setAlpha(0); this.cancelBtn.setAlpha(0); this.add([this.cancelBtn, this.confirmBtn]); + // Hidden dialog = inert buttons (same v4 `input.enabled` rule as the + // scrim — otherwise the invisible CONFIRM/CANCEL swallow centre- + // screen clicks while the dialog is closed). + setInteractiveEnabled(this.confirmBtn.panel, false); + setInteractiveEnabled(this.cancelBtn.panel, false); this._titleDec = null; this._btnsUp = false; @@ -165,9 +174,9 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container { this.cancelBtn.setAlpha(0); this._btnsUp = false; this._confirmUp = false; - this.scrim.ignorePointer = false; - this.confirmBtn.panel.ignorePointer = false; - this.cancelBtn.panel.ignorePointer = false; + setInteractiveEnabled(this.scrim, true); + setInteractiveEnabled(this.confirmBtn.panel, true); + setInteractiveEnabled(this.cancelBtn.panel, true); const cfg = config.section('save.confirm.animation', {}); this.openDur = cfg.openMs ?? 170; @@ -282,9 +291,9 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container { this.title.setText(''); this.bodyTexts.forEach((t) => t.setText('')); this.state = 'hidden'; - this.scrim.ignorePointer = true; - this.confirmBtn.panel.ignorePointer = true; - this.cancelBtn.panel.ignorePointer = true; + setInteractiveEnabled(this.scrim, false); + setInteractiveEnabled(this.confirmBtn.panel, false); + setInteractiveEnabled(this.cancelBtn.panel, false); const fired = this._fired; this._fired = null; const pending = this._pending; diff --git a/js/ui/MenuSubBar.js b/js/ui/MenuSubBar.js index 8748732..513e26d 100644 --- a/js/ui/MenuSubBar.js +++ b/js/ui/MenuSubBar.js @@ -3,6 +3,7 @@ import { config } from '../config/Config.js'; import { toColor, toCss } from '../utils/Color.js'; import { canvasTexture } from '../utils/Textures.js'; import { MenuButton } from './MenuButton.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; /** @@ -55,6 +56,10 @@ export class MenuSubBar extends Phaser.GameObjects.Container { super(scene, 0, 0); this.scene.add.existing(this); this.setScrollFactor(0); // screen-fixed UI + // The drawer belongs to the deck but RISES above it — the planets (5), + // HUD (30), compass (40), toast (45) and the deck itself (50) must all + // paint UNDER the menu, or the bar gets overlapped the moment it opens. + this.setDepth(60); // above the command deck (50) const cfg = config.section('save.subBar', {}); const anim = cfg.animation ?? {}; @@ -166,6 +171,11 @@ export class MenuSubBar extends Phaser.GameObjects.Container { bx += widths[i] / 2 + this.gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0); return slot; }); + // CLOSED = input-inert. The v4 `ignorePointer` idiom is a no-op (it's a + // physics-world property — see js/utils/Input.js), so while closed the + // ghost buttons must be switched off via `input.enabled`, or they + // swallow every world click in their band (and the bar starts closed). + this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, false)); // ---- the anchor: right-aligned over the menu button, seam on its top edge const a = o.anchor ?? { x: sceneW / 2, y: scene.scale.height - 60, w: 120 }; let cx = a.x + a.w / 2 - this.W / 2; // bar's right edge on the button's right edge @@ -210,6 +220,8 @@ export class MenuSubBar extends Phaser.GameObjects.Container { if (this.state !== 'closed' || this.dead) return; this.state = 'opening'; this.t0 = this.lastTime ?? this.scene.time.now; + // Input on — the buttons catch clicks (v4: input.enabled, not ignorePointer). + this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, true)); this.buttons.forEach((s) => { s.up = false; s._dec = null; @@ -228,6 +240,8 @@ export class MenuSubBar extends Phaser.GameObjects.Container { if (this.state !== 'open' || this.dead) return; this.state = 'closing'; this.t0 = this.lastTime ?? this.scene.time.now; + // Input off — the folding bar must not swallow clicks as it dies. + this.buttons.forEach((s) => setInteractiveEnabled(s.btn.panel, false)); // Labels deconstruct (the reverse decode) while the edge folds down. this.buttons.forEach((s, i) => { s._dec = new ScrambleDecode(s.finalLabel, this.t0, 150, true); @@ -246,7 +260,10 @@ export class MenuSubBar extends Phaser.GameObjects.Container { this.ghostM.setAlpha(0); this.seamG.clear(); this.scan.setAlpha(0).setSize(this.W, 0); - this.buttons.forEach((s) => s.btn.setAlpha(0)); + this.buttons.forEach((s) => { + s.btn.setAlpha(0); + setInteractiveEnabled(s.btn.panel, false); + }); for (const sl of this.slices) sl.g.destroy(); this.slices.length = 0; } diff --git a/js/ui/SavePanel.js b/js/ui/SavePanel.js index c0d5a74..80793c4 100644 --- a/js/ui/SavePanel.js +++ b/js/ui/SavePanel.js @@ -10,6 +10,7 @@ import { ConfirmOverlay } from './ConfirmOverlay.js'; import { Toast } from './Toast.js'; import { SaveManager } from '../save/SaveManager.js'; import { captureState, prepareLoad } from '../save/SaveData.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; @@ -59,6 +60,11 @@ export class SavePanel extends Phaser.GameObjects.Container { super(scene, 0, 0); this.scene.add.existing(this); this.setScrollFactor(0); // screen-fixed UI + // The pop-up is a MODAL — it must paint above the world (planets 5, + // HUD 30, compass 40, toast 45), the deck (50) and the sub-bar (60), + // or planets overlap it the moment it cracks open and its scrim can't + // darken the deck. + this.setDepth(70); // above the sub-bar (60) and the deck (50) const cfg = config.section('save.panel', {}); const cols = cfg.cols ?? 5; @@ -96,7 +102,7 @@ export class SavePanel extends Phaser.GameObjects.Container { hitAreaCallback: () => true, }); this.scrim.on('pointerdown', () => this.close()); - this.scrim.ignorePointer = true; // off while hidden + setInteractiveEnabled(this.scrim, false); // off while hidden (v4: input.enabled — `ignorePointer` is a no-op) this.add(this.scrim); // ---- the plate (redrawn taller every frame while it cracks open) -- @@ -170,6 +176,10 @@ export class SavePanel extends Phaser.GameObjects.Container { card.setAlpha(0); card._up = false; card._locked = true; + // Hidden panel = inert cards (v4: `input.enabled`; the old + // `ignorePointer` idiom is a no-op and left these swallowing + // centre-screen clicks forever). + setInteractiveEnabled(card.panel, false); this.cards.push(card); this.add(card); } @@ -211,8 +221,10 @@ export class SavePanel extends Phaser.GameObjects.Container { ); this.cancelBtn.setAlpha(0); this.downloadBtn.setAlpha(0); - this.cancelBtn.ignorePointer = true; - this.downloadBtn.ignorePointer = true; + // Hidden = input-inert (v4: `input.enabled` — the `ignorePointer` + // idiom is a physics-world property and a no-op here). + setInteractiveEnabled(this.cancelBtn.panel, false); + setInteractiveEnabled(this.downloadBtn.panel, false); this.add([this.cancelBtn, this.downloadBtn]); // ---- the confirm dialog + toast ------------------------------------ @@ -278,17 +290,17 @@ export class SavePanel extends Phaser.GameObjects.Container { card.setAccent(this.accent); card._up = false; card._locked = false; - card.panel.ignorePointer = false; + setInteractiveEnabled(card.panel, true); card.setRecord(records[i]); card.setDisabled(this.mode === 'load' && records[i] === null); card.setAlpha(0); card.setY(card.y + 5); }); - // Input back on. - this.scrim.ignorePointer = false; - this.cancelBtn.panel.ignorePointer = false; - this.downloadBtn.panel.ignorePointer = false; + // Input back on (v4: `input.enabled` — `ignorePointer` is a no-op). + setInteractiveEnabled(this.scrim, true); + setInteractiveEnabled(this.cancelBtn.panel, true); + setInteractiveEnabled(this.downloadBtn.panel, true); this.cancelBtn.setAlpha(0); this.downloadBtn.setAlpha(0); @@ -326,14 +338,14 @@ export class SavePanel extends Phaser.GameObjects.Container { this.cards.forEach((c) => { c._up = true; c._dec = null; - c.panel.ignorePointer = true; + setInteractiveEnabled(c.panel, false); }); this.scene.tweens.add({ targets: this.cards, alpha: 0, duration: 110, ease: 'Sine.easeIn' }); this.scene.tweens.add({ targets: [this.cancelBtn, this.downloadBtn], alpha: 0, duration: 90 }); this.scene.tweens.add({ targets: this.scrim, alpha: 0, duration: this.closeDur * 0.8, ease: 'Sine.easeIn' }); - this.scrim.ignorePointer = true; - this.cancelBtn.panel.ignorePointer = true; - this.downloadBtn.panel.ignorePointer = true; + setInteractiveEnabled(this.scrim, false); + setInteractiveEnabled(this.cancelBtn.panel, false); + setInteractiveEnabled(this.downloadBtn.panel, false); this.scene.playSfx?.('deconstruct'); } diff --git a/js/utils/Input.js b/js/utils/Input.js new file mode 100644 index 0000000..1ba3dee --- /dev/null +++ b/js/utils/Input.js @@ -0,0 +1,23 @@ +/** + * Enable / disable hit-testing on an interactive object (Phaser v4). + * + * v4 (4.2.1 Giedi) quirk — the trap behind the dead sub-bar buttons: + * the Phaser 3 idiom `obj.ignorePointer = true` is a NO-OP in the INPUT + * system (that property belongs to the physics world — matter.js — only). + * v4's InputManager gates hit-testing on `obj.input.enabled` (see + * InputManager.inputCandidate: `if (!i || !i.enabled || ...) return false`), + * and a pointer press lands on only the single TOPMOST hit (topOnly) — + * so an invisible rect that was "supposed to be off" keeps swallowing + * every click beneath it, forever. + * + * Use this wherever the old code wrote `ignorePointer`: + * + * setInteractiveEnabled(obj, false) // the object stops catching clicks + * setInteractiveEnabled(obj, true) // it catches them again + * + * No-op if the object never got setInteractive() — safe either way. + */ +export function setInteractiveEnabled(obj, enabled) { + if (obj && obj.input) obj.input.enabled = !!enabled; + return obj; +}