Fix menu sub-bar, save panel, and confirm dialog input and z-order bugs

- Replace the no-op `ignorePointer` idiom with a new `setInteractiveEnabled` helper that toggles Phaser v4's `input.enabled`, so hidden/inert UI elements (sub-bar buttons, save cards, scrims, confirm buttons) stop swallowing clicks
- Set explicit depths for the menu sub-bar (60) and save panel (70) so they paint above the command deck, HUD, and world objects as intended
- Add a headless-browser z-order + input-state probe (`dev/zorder-test.*`) that boots the real engine and asserts both paint order and hit-testing behavior across open/close transitions
- Add gas giant landing video asset
This commit is contained in:
Brian Fertig 2026-09-04 14:31:34 -06:00
parent 834be56557
commit bf43e12a1c
7 changed files with 297 additions and 20 deletions

Binary file not shown.

26
dev/zorder-test.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev z-order + input-state 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__ = [];
const __oe = console.error.bind(console);
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...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/zorder-test.mjs"></script>
</body>
</html>

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

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

View File

@ -4,6 +4,7 @@ import { toColor, toCss } from '../utils/Color.js';
import { fontStack } from '../utils/Theme.js'; import { fontStack } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js'; import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js'; import { MenuButton } from './MenuButton.js';
import { setInteractiveEnabled } from '../utils/Input.js';
import { ScrambleDecode } from '../utils/Decode.js'; import { ScrambleDecode } from '../utils/Decode.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
@ -59,7 +60,10 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
hitAreaCallback: () => true, hitAreaCallback: () => true,
}); });
this.scrim.on('pointerdown', () => this.cancel()); 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.add(this.scrim);
this.panelG = scene.add.graphics().setScrollFactor(0); this.panelG = scene.add.graphics().setScrollFactor(0);
@ -117,6 +121,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.confirmBtn.setAlpha(0); this.confirmBtn.setAlpha(0);
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this.add([this.cancelBtn, this.confirmBtn]); 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._titleDec = null;
this._btnsUp = false; this._btnsUp = false;
@ -165,9 +174,9 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this._btnsUp = false; this._btnsUp = false;
this._confirmUp = false; this._confirmUp = false;
this.scrim.ignorePointer = false; setInteractiveEnabled(this.scrim, true);
this.confirmBtn.panel.ignorePointer = false; setInteractiveEnabled(this.confirmBtn.panel, true);
this.cancelBtn.panel.ignorePointer = false; setInteractiveEnabled(this.cancelBtn.panel, true);
const cfg = config.section('save.confirm.animation', {}); const cfg = config.section('save.confirm.animation', {});
this.openDur = cfg.openMs ?? 170; this.openDur = cfg.openMs ?? 170;
@ -282,9 +291,9 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.title.setText(''); this.title.setText('');
this.bodyTexts.forEach((t) => t.setText('')); this.bodyTexts.forEach((t) => t.setText(''));
this.state = 'hidden'; this.state = 'hidden';
this.scrim.ignorePointer = true; setInteractiveEnabled(this.scrim, false);
this.confirmBtn.panel.ignorePointer = true; setInteractiveEnabled(this.confirmBtn.panel, false);
this.cancelBtn.panel.ignorePointer = true; setInteractiveEnabled(this.cancelBtn.panel, false);
const fired = this._fired; const fired = this._fired;
this._fired = null; this._fired = null;
const pending = this._pending; const pending = this._pending;

View File

@ -3,6 +3,7 @@ import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js'; import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js'; import { canvasTexture } from '../utils/Textures.js';
import { MenuButton } from './MenuButton.js'; import { MenuButton } from './MenuButton.js';
import { setInteractiveEnabled } from '../utils/Input.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
/** /**
@ -55,6 +56,10 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
super(scene, 0, 0); super(scene, 0, 0);
this.scene.add.existing(this); this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed UI 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 cfg = config.section('save.subBar', {});
const anim = cfg.animation ?? {}; 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); bx += widths[i] / 2 + this.gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
return slot; 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 // ---- 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 }; 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 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; if (this.state !== 'closed' || this.dead) return;
this.state = 'opening'; this.state = 'opening';
this.t0 = this.lastTime ?? this.scene.time.now; 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) => { this.buttons.forEach((s) => {
s.up = false; s.up = false;
s._dec = null; s._dec = null;
@ -228,6 +240,8 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
if (this.state !== 'open' || this.dead) return; if (this.state !== 'open' || this.dead) return;
this.state = 'closing'; this.state = 'closing';
this.t0 = this.lastTime ?? this.scene.time.now; 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. // Labels deconstruct (the reverse decode) while the edge folds down.
this.buttons.forEach((s, i) => { this.buttons.forEach((s, i) => {
s._dec = new ScrambleDecode(s.finalLabel, this.t0, 150, true); 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.ghostM.setAlpha(0);
this.seamG.clear(); this.seamG.clear();
this.scan.setAlpha(0).setSize(this.W, 0); 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(); for (const sl of this.slices) sl.g.destroy();
this.slices.length = 0; this.slices.length = 0;
} }

View File

@ -10,6 +10,7 @@ import { ConfirmOverlay } from './ConfirmOverlay.js';
import { Toast } from './Toast.js'; import { Toast } from './Toast.js';
import { SaveManager } from '../save/SaveManager.js'; import { SaveManager } from '../save/SaveManager.js';
import { captureState, prepareLoad } from '../save/SaveData.js'; import { captureState, prepareLoad } from '../save/SaveData.js';
import { setInteractiveEnabled } from '../utils/Input.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; 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); super(scene, 0, 0);
this.scene.add.existing(this); this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed UI 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 cfg = config.section('save.panel', {});
const cols = cfg.cols ?? 5; const cols = cfg.cols ?? 5;
@ -96,7 +102,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
hitAreaCallback: () => true, hitAreaCallback: () => true,
}); });
this.scrim.on('pointerdown', () => this.close()); 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); this.add(this.scrim);
// ---- the plate (redrawn taller every frame while it cracks open) -- // ---- 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.setAlpha(0);
card._up = false; card._up = false;
card._locked = true; 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.cards.push(card);
this.add(card); this.add(card);
} }
@ -211,8 +221,10 @@ export class SavePanel extends Phaser.GameObjects.Container {
); );
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0); this.downloadBtn.setAlpha(0);
this.cancelBtn.ignorePointer = true; // Hidden = input-inert (v4: `input.enabled` — the `ignorePointer`
this.downloadBtn.ignorePointer = true; // 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]); this.add([this.cancelBtn, this.downloadBtn]);
// ---- the confirm dialog + toast ------------------------------------ // ---- the confirm dialog + toast ------------------------------------
@ -278,17 +290,17 @@ export class SavePanel extends Phaser.GameObjects.Container {
card.setAccent(this.accent); card.setAccent(this.accent);
card._up = false; card._up = false;
card._locked = false; card._locked = false;
card.panel.ignorePointer = false; setInteractiveEnabled(card.panel, true);
card.setRecord(records[i]); card.setRecord(records[i]);
card.setDisabled(this.mode === 'load' && records[i] === null); card.setDisabled(this.mode === 'load' && records[i] === null);
card.setAlpha(0); card.setAlpha(0);
card.setY(card.y + 5); card.setY(card.y + 5);
}); });
// Input back on. // Input back on (v4: `input.enabled` — `ignorePointer` is a no-op).
this.scrim.ignorePointer = false; setInteractiveEnabled(this.scrim, true);
this.cancelBtn.panel.ignorePointer = false; setInteractiveEnabled(this.cancelBtn.panel, true);
this.downloadBtn.panel.ignorePointer = false; setInteractiveEnabled(this.downloadBtn.panel, true);
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0); this.downloadBtn.setAlpha(0);
@ -326,14 +338,14 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.cards.forEach((c) => { this.cards.forEach((c) => {
c._up = true; c._up = true;
c._dec = null; 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.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.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.scene.tweens.add({ targets: this.scrim, alpha: 0, duration: this.closeDur * 0.8, ease: 'Sine.easeIn' });
this.scrim.ignorePointer = true; setInteractiveEnabled(this.scrim, false);
this.cancelBtn.panel.ignorePointer = true; setInteractiveEnabled(this.cancelBtn.panel, false);
this.downloadBtn.panel.ignorePointer = true; setInteractiveEnabled(this.downloadBtn.panel, false);
this.scene.playSfx?.('deconstruct'); this.scene.playSfx?.('deconstruct');
} }

23
js/utils/Input.js Normal file
View File

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