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'; const CATEGORIES = [ { key: 'tabletop', label: 'Tabletop' }, { key: 'cards', label: 'Cards & Dice' }, { key: 'casino', label: 'Casino' }, { key: 'word', label: 'Words & Numbers' }, { key: 'logic', label: 'Logic & Puzzle' }, { key: 'arcade-console-pc', label: 'Video Games' }, ]; const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4, 'arcade-console-pc': 5 }; 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'); } init(data) { // Set when coming back from the opponent picker — restore that category. this._initialCategory = (data && data.category) || null; // 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; 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 Category', { fontFamily: 'Righteous', fontSize: '64px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(1); 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, }).setOrigin(0.5); let games = []; try { const res = await api.get('/games'); games = res.games ?? []; } catch (err) { loadingText.setText(`Failed to load games: ${err.message}`); return; } loadingText.destroy(); this._gamesByCategory = {}; for (const { key } of CATEGORIES) { this._gamesByCategory[key] = games.filter((g) => g.category === key); } this._gameObjects = []; this._tabs = {}; // Two-row tab layout: row 1 has 4 categories, row 2 has the rest const ROW1_KEYS = ['tabletop', 'cards', 'casino', 'word']; const ROW2_KEYS = ['logic', 'arcade-console-pc']; const ROW1 = CATEGORIES.filter(c => ROW1_KEYS.includes(c.key)); const ROW2 = CATEGORIES.filter(c => ROW2_KEYS.includes(c.key)); const activeRow1 = ROW1.filter(({ key }) => this._gamesByCategory[key].length > 0); const activeRow2 = ROW2.filter(({ key }) => this._gamesByCategory[key].length > 0); const tabSpacing = Math.min(420, 1800 / activeRow1.length); const tabStartX = cx - (tabSpacing * (activeRow1.length - 1)) / 2; // Row 1 tabs activeRow1.forEach(({ key, label }, i) => { const btn = new Button(this, tabStartX + i * tabSpacing, 170, label, () => this.showCategory(key), { width: 390, variant: 'ghost', }); btn.text.setX(40); this._tabs[key] = btn; }); // Row 2 tabs (centered) if (activeRow2.length > 0) { const row2Spacing = Math.min(420, 1800 / activeRow2.length); const row2StartX = cx - (row2Spacing * (activeRow2.length - 1)) / 2; activeRow2.forEach(({ key, label }, i) => { const btn = new Button(this, row2StartX + i * row2Spacing, 250, label, () => this.showCategory(key), { width: 390, variant: 'ghost', }); btn.text.setX(40); this._tabs[key] = btn; }); } this._tabIcons = {}; const allActiveCats = [...activeRow1, ...activeRow2]; allActiveCats.forEach(({ key }) => { const btn = this._tabs[key]; const icon = this.add.image(btn.x + ICON_X_OFFSET, btn.y, 'tab-icons', TAB_ICON_FRAMES[key]) .setDisplaySize(ICON_INACTIVE, ICON_INACTIVE); icon._glow = null; this._tabIcons[key] = icon; }); // 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); 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) { 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'); 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); } if (this._tabIcons) { for (const [k, icon] of Object.entries(this._tabIcons)) { this.tweens.killTweensOf(icon._glow); icon.postFX.clear(); icon._glow = null; if (k === key) { this.tweens.add({ targets: icon, displayWidth: ICON_OVERSHOOT, displayHeight: ICON_OVERSHOOT, duration: 160, ease: 'Quad.easeOut', onComplete: () => { this.tweens.add({ targets: icon, displayWidth: ICON_ACTIVE, displayHeight: ICON_ACTIVE, duration: 220, ease: 'Back.easeOut', onComplete: () => { icon._glow = icon.postFX.addGlow(0xd4a017, 0, 0, false, 0.1, 16); this.tweens.add({ targets: icon._glow, outerStrength: 6, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); }, }); }, }); } else { this.tweens.add({ targets: icon, displayWidth: ICON_INACTIVE, displayHeight: ICON_INACTIVE, duration: 180, ease: 'Quad.easeIn', }); } } } // 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; const cx = GAME_WIDTH / 2; const COLS = 3; const COL_SPACING = 520; const ROW_SPACING = 90; const GRID_TOP = 370; const BTN_WIDTH = 360; const PADDING = 52; const QBTN_SIZE = 44; const QBTN_GAP = 10; const ICON_SIZE = 44; const ICON_PAD = 12; const rows = Math.ceil(games.length / COLS); const panelH = (rows - 1) * ROW_SPACING + PADDING * 2; const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + QBTN_SIZE + QBTN_GAP + 40; const panelCenterY = GRID_TOP + (rows - 1) * ROW_SPACING / 2; const panel = this.add.graphics(); panel.fillStyle(0x000000, 0.7); panel.fillRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16); panel.setPosition(cx, panelCenterY); this._gameObjects.push(panel); // Shared tooltip (one per category render) const tooltipBg = this.add.rectangle(0, 0, 10, 10, 0x1e1a12, 0.95) .setStrokeStyle(1, COLORS.accent).setDepth(50).setVisible(false); const tooltipText = this.add.text(0, 0, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setDepth(51).setVisible(false); this._gameObjects.push(tooltipBg, tooltipText); const showTooltip = (label) => { tooltipText.setText(label); tooltipBg.setSize(tooltipText.width + 24, tooltipText.height + 16); tooltipBg.setVisible(true); tooltipText.setVisible(true); }; const hideTooltip = () => { tooltipBg.setVisible(false); tooltipText.setVisible(false); }; this._ptrMoveHandler = (ptr) => { const tx = ptr.x + 18; const ty = ptr.y - 28; tooltipBg.setPosition(tx + tooltipBg.width / 2, ty); tooltipText.setPosition(tx + 12, ty - tooltipText.height / 2); }; this.input.on('pointermove', this._ptrMoveHandler, this); games.forEach((game, i) => { const col = i % COLS; const row = Math.floor(i / COLS); const x = cx - 20 + (col - (COLS - 1) / 2) * COL_SPACING; const y = GRID_TOP + row * ROW_SPACING; const btn = new Button(this, x, y, game.name, () => this.openGame(game), { width: BTN_WIDTH }); this._gameObjects.push(btn); if (game.iconFrame != null && this.textures.exists('game-icons')) { const iconX = x - BTN_WIDTH / 2 + ICON_PAD + ICON_SIZE / 2; const icon = this.add.image(iconX, y, 'game-icons', game.iconFrame) .setDisplaySize(ICON_SIZE, ICON_SIZE); this._gameObjects.push(icon); btn.text.setX(28); } if (game.hasTutorial) { const qx = x + BTN_WIDTH / 2 + QBTN_GAP + QBTN_SIZE / 2; const qy = y; const qg = this.add.graphics(); const drawQ = (hover) => { qg.clear(); qg.fillStyle(0x1e1a12, 0.9); qg.fillRoundedRect(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE, 8); qg.lineStyle(2, hover ? COLORS.gold : COLORS.accent, 1); qg.strokeRoundedRect(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE, 8); }; drawQ(false); const qLabel = this.add.text(qx, qy, '?', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(2); qg.setInteractive( new Phaser.Geom.Rectangle(qx - QBTN_SIZE / 2, qy - QBTN_SIZE / 2, QBTN_SIZE, QBTN_SIZE), Phaser.Geom.Rectangle.Contains, ); qg.on('pointerover', () => { drawQ(true); showTooltip(`Instructions: ${game.name}`); }); qg.on('pointerout', () => { drawQ(false); hideTooltip(); }); qg.on('pointerdown', () => new TutorialModal(game).open()); 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) { if (game.maxOpponents === 0) { stopMenuMusic(); this.scene.start('GameRoom', { game, opponents: [] }); return; } this.scene.start('OpponentSelect', { game }); } }