Polish game menu and landing scene transitions

- Add slide-in/out grid animations when switching categories in the game menu, replacing the instant rebuild
- Rename "Choose a game" to "Choose a Game Category" and update title dynamically on category change
- Fade in menu controls (tabs, hint, back button) on entry to complete the landing handoff
- Animate logo zoom-out and fade buttons out when transitioning from landing to game menu via goToGameMenu()
- Guard against double-taps and stale avatar loads during scene transitions
This commit is contained in:
Brian Fertig 2026-08-31 19:53:22 -06:00
parent f2108e6258
commit cbec3b3a44
2 changed files with 107 additions and 10 deletions

View File

@ -20,6 +20,8 @@ const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72; const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86; const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -145; const ICON_X_OFFSET = -145;
// How far (px) a grid travels off screen when switching categories.
const GRID_TRAVEL = GAME_WIDTH + 800;
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
@ -31,12 +33,13 @@ export default class GameMenuScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT); this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this); addFullscreenButton(this);
const titleText = this.add.text(cx, 60, 'Choose a game', { const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '64px', fontSize: '64px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7); this._titleText = titleText;
this._titleBg = this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
const loadingText = this.add.text(cx, 540, 'Loading game list…', { const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex, fontSize: '24px', color: COLORS.mutedHex,
@ -110,11 +113,29 @@ export default class GameMenuScene extends Phaser.Scene {
fontSize: '26px', color: COLORS.mutedHex, fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
// The landing scene zooms the logo out before starting us, so fade the
// menu controls in to complete the handoff.
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn];
for (const obj of fadeIn) obj.setAlpha(0);
this.tweens.add({
targets: fadeIn,
alpha: 1,
duration: 450,
ease: 'Power2.easeOut',
});
} }
showCategory(key) { showCategory(key) {
if (key === this._currentCategory) return;
this._currentCategory = key;
if (this._hintText) { this._hintText.destroy(); this._hintText = null; } if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
if (this._titleText) {
this._titleText.setText('Choose a game');
this._titleBg.setSize(this._titleText.width + 64, this._titleText.height + 28);
}
for (const [k, btn] of Object.entries(this._tabs)) { for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key); btn.setActive(k === key);
} }
@ -162,13 +183,41 @@ export default class GameMenuScene extends Phaser.Scene {
} }
} }
for (const obj of this._gameObjects) obj.destroy(); // Interrupt any grid animation still in flight (user changed their mind mid-flight).
this._gameObjects = []; if (this._gridAnim) {
for (const obj of this._gridAnim.objects) obj.destroy();
this._gridAnim = null;
this._gameObjects = [];
}
if (this._ptrMoveHandler) { if (this._ptrMoveHandler) {
this.input.off('pointermove', this._ptrMoveHandler, this); this.input.off('pointermove', this._ptrMoveHandler, this);
this._ptrMoveHandler = null; this._ptrMoveHandler = null;
} }
const oldObjects = this._gameObjects;
this._gameObjects = [];
if (oldObjects.length === 0) {
this.buildCategoryGrid(key);
return;
}
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
for (const obj of oldObjects) obj.disableInteractive();
this._gridAnim = { objects: oldObjects };
this.tweens.add({
targets: oldObjects,
x: `-=${GRID_TRAVEL}`,
duration: 380,
ease: 'Power2.easeIn',
onComplete: () => {
this._gridAnim = null;
for (const obj of oldObjects) obj.destroy();
this.buildCategoryGrid(key);
},
});
}
buildCategoryGrid(key) {
const games = this._gamesByCategory[key]; const games = this._gamesByCategory[key];
if (!games || games.length === 0) return; if (!games || games.length === 0) return;
@ -267,6 +316,18 @@ export default class GameMenuScene extends Phaser.Scene {
this._gameObjects.push(qg, qLabel); this._gameObjects.push(qg, qLabel);
} }
}); });
// Fly the grid in from off screen right.
const objs = this._gameObjects;
for (const obj of objs) obj.x += GRID_TRAVEL;
this._gridAnim = { objects: objs };
this.tweens.add({
targets: objs,
x: `-=${GRID_TRAVEL}`,
duration: 420,
ease: 'Power2.easeOut',
onComplete: () => { this._gridAnim = null; },
});
} }
openGame(game) { openGame(game) {

View File

@ -16,6 +16,8 @@ export default class LandingScene extends Phaser.Scene {
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0); const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75); logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
this._logo = logo;
this._transitioning = false;
this.tweens.add({ this.tweens.add({
targets: logo, targets: logo,
@ -53,6 +55,7 @@ export default class LandingScene extends Phaser.Scene {
renderButtons() { renderButtons() {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const user = auth.user; const user = auth.user;
this._avatar = null;
const avatarR = 28; const avatarR = 28;
const avatarGap = 18; const avatarGap = 18;
@ -65,6 +68,7 @@ export default class LandingScene extends Phaser.Scene {
fontSize: '36px', fontSize: '36px',
color: COLORS.accentHex, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this._welcomeText = welcomeText;
const hasAvatar = !!user?.avatarPath; const hasAvatar = !!user?.avatarPath;
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width; const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
@ -72,7 +76,7 @@ export default class LandingScene extends Phaser.Scene {
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx); welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45); this._welcomeBg = this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
if (hasAvatar) { if (hasAvatar) {
const avatarCx = groupLeft + avatarR; const avatarCx = groupLeft + avatarR;
@ -86,11 +90,11 @@ export default class LandingScene extends Phaser.Scene {
this.load.start(); this.load.start();
}); });
} }
if (!this.scene.isActive('Landing')) return; if (this._transitioning || !this.scene.isActive('Landing')) return;
const maskG = this.make.graphics({ x: 0, y: 0, add: false }); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff); maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarCx, y, avatarR); maskG.fillCircle(avatarCx, y, avatarR);
this.add.image(avatarCx, y, key) this._avatar = this.add.image(avatarCx, y, key)
.setDisplaySize(avatarR * 2, avatarR * 2) .setDisplaySize(avatarR * 2, avatarR * 2)
.setMask(maskG.createGeometryMask()) .setMask(maskG.createGeometryMask())
.setDepth(1); .setDepth(1);
@ -98,7 +102,39 @@ export default class LandingScene extends Phaser.Scene {
})(); })();
} }
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 }); this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 }); this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
}
goToGameMenu() {
if (this._transitioning) return;
this._transitioning = true;
this.playBtn.disableInteractive();
this.profileBtn.disableInteractive();
// Fade the landing buttons out while the logo zooms past the camera.
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
if (this._avatar) fadeOut.push(this._avatar);
this.tweens.add({
targets: fadeOut,
alpha: 0,
duration: 350,
ease: 'Power2.easeOut',
});
if (!this._logo) { this.scene.start('GameMenu'); return; }
this.tweens.killTweensOf(this._logo);
this.tweens.add({
targets: this._logo,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scaleX: 2.4,
scaleY: 2.4,
alpha: 0,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: () => this.scene.start('GameMenu'),
});
} }
} }