diff --git a/src/scenes/GameMenuScene.js b/src/scenes/GameMenuScene.js index 9eb5864..4e9a2f1 100644 --- a/src/scenes/GameMenuScene.js +++ b/src/scenes/GameMenuScene.js @@ -20,6 +20,8 @@ const ICON_INACTIVE = 56; const ICON_ACTIVE = 72; const ICON_OVERSHOOT = 86; 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 { 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); 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', fontSize: '64px', color: COLORS.textHex, }).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…', { fontSize: '24px', color: COLORS.mutedHex, @@ -110,11 +113,29 @@ export default class GameMenuScene extends Phaser.Scene { fontSize: '26px', color: COLORS.mutedHex, }).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) { + if (key === this._currentCategory) return; + this._currentCategory = key; + 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)) { btn.setActive(k === key); } @@ -162,13 +183,41 @@ export default class GameMenuScene extends Phaser.Scene { } } - for (const obj of this._gameObjects) obj.destroy(); - this._gameObjects = []; + // Interrupt any grid animation still in flight (user changed their mind mid-flight). + if (this._gridAnim) { + for (const obj of this._gridAnim.objects) obj.destroy(); + this._gridAnim = null; + this._gameObjects = []; + } if (this._ptrMoveHandler) { this.input.off('pointermove', this._ptrMoveHandler, this); 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]; if (!games || games.length === 0) return; @@ -267,6 +316,18 @@ export default class GameMenuScene extends Phaser.Scene { 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) { diff --git a/src/scenes/LandingScene.js b/src/scenes/LandingScene.js index 5d959eb..ee83657 100644 --- a/src/scenes/LandingScene.js +++ b/src/scenes/LandingScene.js @@ -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); logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75); + this._logo = logo; + this._transitioning = false; this.tweens.add({ targets: logo, @@ -53,6 +55,7 @@ export default class LandingScene extends Phaser.Scene { renderButtons() { const cx = GAME_WIDTH / 2; const user = auth.user; + this._avatar = null; const avatarR = 28; const avatarGap = 18; @@ -65,6 +68,7 @@ export default class LandingScene extends Phaser.Scene { fontSize: '36px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(1); + this._welcomeText = welcomeText; const hasAvatar = !!user?.avatarPath; 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); - 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) { const avatarCx = groupLeft + avatarR; @@ -86,11 +90,11 @@ export default class LandingScene extends Phaser.Scene { 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 }); maskG.fillStyle(0xffffff); maskG.fillCircle(avatarCx, y, avatarR); - this.add.image(avatarCx, y, key) + this._avatar = this.add.image(avatarCx, y, key) .setDisplaySize(avatarR * 2, avatarR * 2) .setMask(maskG.createGeometryMask()) .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 }); - new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 }); + this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { 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'), + }); } }