372 lines
13 KiB
JavaScript
372 lines
13 KiB
JavaScript
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 });
|
|
}
|
|
}
|