Fix SavePanel button overflow, header decode freeze, and card lock state

- Pin DOWNLOAD ALL button's right edge to the panel inner boundary using its actual rendered width instead of a fixed 210px assumption that let the label spill past the window
- Drive title/subtitle decode animations through both 'opening' and 'shown' states so the scramble completes rather than freezing mid-glyph when the crack-open settles
- Set `card.locked` (no underscore) to match what SlotCard.press() actually gates on, preventing stale `_locked` writes from leaving cards inert
- Add a real pointer-press helper in the dev test that maps canvas coords through the FIT-mode zoom and dispatches mouseover/down/up to exercise the full click pipeline end-to-end
This commit is contained in:
Brian Fertig 2026-09-04 14:48:36 -06:00
parent bf43e12a1c
commit 2bad1f1e84
2 changed files with 69 additions and 6 deletions

View File

@ -74,6 +74,29 @@ const wait = (ms) => new Promise((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 run = async () => { const run = async () => {
const scene = game.scene.getScene('GameScene'); const scene = game.scene.getScene('GameScene');
@ -116,6 +139,33 @@ const run = async () => {
&& [scene.savePanel.cancelBtn, scene.savePanel.downloadBtn].every((b) => && [scene.savePanel.cancelBtn, scene.savePanel.downloadBtn].every((b) =>
Number.isFinite(scene.savePanel.x + b.x) && Number.isFinite(scene.savePanel.y + b.y))); Number.isFinite(scene.savePanel.x + b.x) && Number.isFinite(scene.savePanel.y + b.y)));
// DOWNLOAD ALL SAVED GAMES fits INSIDE the window (right edge ≤ the
// plate's inner edge — the label outgrows the fixed 210 px box, so the
// button must be pinned by its actual width).
check('the DOWNLOAD ALL button fits inside the panel',
scene.savePanel.x + scene.savePanel.downloadBtn.x + scene.savePanel.downloadBtn.width / 2
<= scene.savePanel.x + scene.savePanel.W / 2 + 1);
// The header decodes run LONGER than the crack-open (decodeDur > openMs);
// once the window settles they must hold the FINAL strings — not the
// mid-scramble garbage the frozen drive left on screen.
await wait(1200);
check('the title decode completes (no mid-scramble garbage)',
scene.savePanel.title.text === String(config.get('save.panel.title.save', 'SAVE GAME')).toUpperCase());
check('the subtitle decode completes',
scene.savePanel.subtitle.text === String(config.get('save.panel.subtitle.save', '')).toUpperCase());
// A REAL pointer press on an empty slot saves the game — the whole
// click path (hit-test → SlotCard.press → onCard → doSave → bank), not
// a direct doSave() call.
const card2 = scene.savePanel.cards[1];
check('slot 2 is empty before the click', scene.saveManager.get(2) === null);
press(scene.savePanel.x + card2.x, scene.savePanel.y + card2.y);
await wait(250);
const rec2 = scene.saveManager.get(2);
check('a pointer click on slot 2 saves the game',
rec2 !== null && rec2.seed === scene.galaxy.seed && rec2.ship.x === scene.ship.x);
// A direct write to slot 1 (the same path the confirm dialog uses). // A direct write to slot 1 (the same path the confirm dialog uses).
scene.savePanel.doSave(1); scene.savePanel.doSave(1);
await wait(120); await wait(120);
@ -143,7 +193,7 @@ const run = async () => {
await wait(450); await wait(450);
check('the panel reopens in LOAD mode', scene.savePanel.isOpen && scene.savePanel.mode === 'load'); check('the panel reopens in LOAD mode', scene.savePanel.isOpen && scene.savePanel.mode === 'load');
check('empty slots are inert in load mode', check('empty slots are inert in load mode',
scene.savePanel.cards[1].disabled === true && scene.savePanel.cards[0].disabled === false); scene.savePanel.cards[2].disabled === true && scene.savePanel.cards[0].disabled === false);
// ---- load confirm: stage the restore, cut to the menu ----------------- // ---- load confirm: stage the restore, cut to the menu -----------------
const shipX = scene.ship.x; const shipX = scene.ship.x;

View File

@ -175,7 +175,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
card.setRecord(null); card.setRecord(null);
card.setAlpha(0); card.setAlpha(0);
card._up = false; card._up = false;
card._locked = true; card.locked = true; // SlotCard.press() gates on `locked` (no underscore)
// Hidden panel = inert cards (v4: `input.enabled`; the old // Hidden panel = inert cards (v4: `input.enabled`; the old
// `ignorePointer` idiom is a no-op and left these swallowing // `ignorePointer` idiom is a no-op and left these swallowing
// centre-screen clicks forever). // centre-screen clicks forever).
@ -227,6 +227,13 @@ export class SavePanel extends Phaser.GameObjects.Container {
setInteractiveEnabled(this.downloadBtn.panel, false); setInteractiveEnabled(this.downloadBtn.panel, false);
this.add([this.cancelBtn, this.downloadBtn]); this.add([this.cancelBtn, this.downloadBtn]);
// The export label outgrows its configured box (MenuButton widens to
// max(width, textW + 20)), so pin its RIGHT edge to the plate's inner
// edge using the button's actual width — the old fixed offset
// (W/2 - pad - 100, assuming a 210 px button) spilled the label past
// the window's right edge.
this.downloadBtn.setX(this.W / 2 - pad - this.downloadBtn.width / 2);
// ---- the confirm dialog + toast ------------------------------------ // ---- the confirm dialog + toast ------------------------------------
this.confirm = new ConfirmOverlay( this.confirm = new ConfirmOverlay(
scene, scene,
@ -289,7 +296,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.cards.forEach((card, i) => { this.cards.forEach((card, i) => {
card.setAccent(this.accent); card.setAccent(this.accent);
card._up = false; card._up = false;
card._locked = false; card.locked = false; // unlock SlotCard.press()
setInteractiveEnabled(card.panel, true); 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);
@ -600,6 +607,15 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.confirm.update(time); this.confirm.update(time);
this.toast.update(time); this.toast.update(time);
// Header decodes — the scramble runs LONGER than the crack open
// (decodeDur(…) > openMs), so drive them while the panel settles AND
// after it lands in 'shown'; stopping the drive at 'shown' froze the
// title mid-scramble and left garbage glyphs on screen.
if (this.state === 'opening' || this.state === 'shown') {
this.driveDecode(this._titleDec, this.title, time, this._titleFinal);
this.driveDecode(this._subDec, this.subtitle, time, this._subFinal);
}
if (this.state === 'opening') { if (this.state === 'opening') {
const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1); const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1);
const e = 1 - Math.pow(1 - p, 3); const e = 1 - Math.pow(1 - p, 3);
@ -611,9 +627,6 @@ export class SavePanel extends Phaser.GameObjects.Container {
const off = this.ghostStart * (1 - e); const off = this.ghostStart * (1 - e);
this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.35); this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.35);
this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.45); this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.45);
// Header decodes.
this.driveDecode(this._titleDec, this.title, time, this._titleFinal);
this.driveDecode(this._subDec, this.subtitle, time, this._subFinal);
// The slot cards flicker up one by one. // The slot cards flicker up one by one.
this.cards.forEach((card, i) => { this.cards.forEach((card, i) => {
if (card._up) return; if (card._up) return;