diff --git a/src/scenes/GameMenuScene.js b/src/scenes/GameMenuScene.js index 71fcfbf..b63afc6 100644 --- a/src/scenes/GameMenuScene.js +++ b/src/scenes/GameMenuScene.js @@ -2,12 +2,11 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { api } from '../services/api.js'; import { Button } from '../ui/Button.js'; +import { Plaque } from '../ui/Plaque.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js'; import { TutorialModal } from '../ui/TutorialModal.js'; -let _lastCategory = null; - const CATEGORIES = [ { key: 'tabletop', label: 'Tabletop' }, { key: 'cards', label: 'Cards & Dice' }, @@ -22,10 +21,29 @@ 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; + +// The most recently selected category. Module scope so it survives scene +// restarts (scene.start() reuses the instance) and also covers paths that +// start the menu without category data — the menu always returns to where +// the user last was. +let lastCategory = null; export default class GameMenuScene extends Phaser.Scene { constructor() { super('GameMenu'); } + init(data) { + // Category to restore on entry: the explicit one (coming back from the + // opponent picker) or the most recently selected one. + this._initialCategory = (data && data.category) || lastCategory; + // scene.start() reuses the existing scene instance, so instance props from + // the previous visit survive — reset them so this create() starts clean + // (otherwise showCategory()'s "same category" guard swallows the restore). + this._currentCategory = null; + this._gridAnim = null; + } + async create() { playMenuMusic(); const cx = GAME_WIDTH / 2; @@ -33,12 +51,18 @@ 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); + titleText.setLetterSpacing(2); + titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8); + this._titleText = titleText; + const plateW = titleText.width + 64; + const plateH = titleText.height + 28; + this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5); + this._titlePlateW = plateW; const loadingText = this.add.text(cx, 540, 'Loading game list…', { fontSize: '24px', color: COLORS.mutedHex, @@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene { this._tabIcons[key] = icon; }); - const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key; - this.showCategory(startKey); + // No category is selected by default — the user picks one to see games. + this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', { + 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' }); + + // Returning from the opponent picker: restore the category that was + // active when the game was chosen — its grid flies back in from the right. + if (this._initialCategory) { + this.showCategory(this._initialCategory); + } + + // 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].filter(Boolean); + for (const obj of fadeIn) obj.setAlpha(0); + this.tweens.add({ + targets: fadeIn, + alpha: 1, + duration: 450, + ease: 'Power2.easeOut', + }); } showCategory(key) { - _lastCategory = key; + if (key === this._currentCategory) return; + this._currentCategory = key; + lastCategory = key; + + if (this._hintText) { this._hintText.destroy(); this._hintText = null; } + if (this._titleText) { + this._titleText.setText('Choose a game'); + const newW = this._titleText.width + 64; + const newH = this._titleText.height + 28; + if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) { + this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut'); + this._titlePlateW = newW; + } + // Little pop so the label swap reads as intentional. + this._titleText.setScale(0.9); + this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' }); + } for (const [k, btn] of Object.entries(this._tabs)) { btn.setActive(k === key); } @@ -162,13 +221,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 +354,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..6febc39 100644 --- a/src/scenes/LandingScene.js +++ b/src/scenes/LandingScene.js @@ -2,6 +2,7 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { auth } from '../services/auth.js'; import { Button } from '../ui/Button.js'; +import { Plaque } from '../ui/Plaque.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { playMenuMusic } from '../ui/MenuMusic.js'; @@ -16,6 +17,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 +56,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 +69,8 @@ export default class LandingScene extends Phaser.Scene { fontSize: '36px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(1); + this._welcomeText = welcomeText; + welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5); const hasAvatar = !!user?.avatarPath; const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width; @@ -72,7 +78,8 @@ 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 = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 }) + .setPosition(cx, y); if (hasAvatar) { const avatarCx = groupLeft + avatarR; @@ -86,11 +93,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 +105,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'), + }); } } diff --git a/src/scenes/OpponentSelectScene.js b/src/scenes/OpponentSelectScene.js index 1ffa67f..4b1eb9b 100644 --- a/src/scenes/OpponentSelectScene.js +++ b/src/scenes/OpponentSelectScene.js @@ -1,6 +1,7 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { Button } from '../ui/Button.js'; +import { Plaque } from '../ui/Plaque.js'; import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js'; import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js'; @@ -17,8 +18,8 @@ const CARD_TILE_GAP = 14; // Opponent grid scroll area const OPP_SCROLL_W = 1780; -const OPP_SCROLL_H = 440; -const OPP_SCROLL_TOP = 155; // top edge of scroll area +const OPP_SCROLL_H = 415; +const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque) export default class OpponentSelectScene extends Phaser.Scene { constructor() { super('OpponentSelect'); } @@ -58,21 +59,32 @@ export default class OpponentSelectScene extends Phaser.Scene { this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1); this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1); - const titleText = this.add.text(cx, 60, this.gameDef.name, { + // Brass-plaque headers, same treatment as the game menu headings. The + // subtitle plaque sits in the band between the title plaque and the + // opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either. + const TITLE_Y = 55; + const PLATE_GAP = 10; + const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, { fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex, }).setOrigin(0.5); - const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72); - this.children.moveBelow(titlePill, titleText); + titleText.setLetterSpacing(2); + titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8); + const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y); + this.children.moveBelow(titlePlate, titleText); - const subtitleText = this.add.text(cx, 122, 'Choose your opponent', { + const subtitleText = this.add.text(cx, 0, 'Choose your opponent', { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.mutedHex, }).setOrigin(0.5); - const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72); - this.children.moveBelow(subtitlePill, subtitleText); + subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5); + const subPlateH = subtitleText.height + 28; + const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2; + subtitleText.setY(subtitleY); + const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY); + this.children.moveBelow(subtitlePlate, subtitleText); let opponents = []; try { @@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene { } const min = this.gameDef.minOpponents ?? 1; - new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), { + new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), { variant: 'ghost', width: 280, }); diff --git a/src/ui/Plaque.js b/src/ui/Plaque.js new file mode 100644 index 0000000..24b15b7 --- /dev/null +++ b/src/ui/Plaque.js @@ -0,0 +1,62 @@ +import * as Phaser from 'phaser'; +import { COLORS } from '../config.js'; + +// A rounded, brass-trimmed plaque in the same design language as the +// category tabs. Use instead of a flat black rectangle behind title text. +export class Plaque extends Phaser.GameObjects.Graphics { + constructor(scene, width = 300, height = 90, options = {}) { + super(scene, false); + const { + fill = 0x241c10, + fillAlpha = 0.92, + border = COLORS.accent, + radius = 18, + } = options; + this._opts = { fill, fillAlpha, border, radius }; + this._w = width; + this._h = height; + this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65); + this.redraw(width, height); + scene.add.existing(this); + } + + redraw(w, h) { + this._w = w; + this._h = h; + const { fill, fillAlpha, border, radius } = this._opts; + const r = Math.max(4, Math.min(radius, w / 2, h / 2)); + this.clear(); + this.fillStyle(fill, fillAlpha); + this.fillRoundedRect(-w / 2, -h / 2, w, h, r); + // Faint warm highlight just inside the top edge reads as a raised plate. + this.lineStyle(2, 0xfff6dd, 0.16); + this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4); + this.lineStyle(2, border, 1); + this.strokeRoundedRect(-w / 2, -h / 2, w, h, r); + } + + // Animate the plate to a new size. Graphics in this Phaser build exposes + // no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so + // we tween a size proxy and redraw every frame instead. + animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) { + if (this._sizeTween) this._sizeTween.stop(); + const proxy = { w: this._w, h: this._h }; + this._sizeTween = this.scene.tweens.add({ + targets: proxy, + w, + h, + duration, + ease, + onUpdate: () => { + if (!this.scene) return; // destroyed mid-animation + this.redraw(proxy.w, proxy.h); + }, + onComplete: () => { + this._sizeTween = null; + if (!this.scene) return; // destroyed mid-animation + this.redraw(w, h); + if (onComplete) onComplete(); + }, + }); + } +}