commit
ae30c66a3a
|
|
@ -2,12 +2,11 @@ import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||||
import { api } from '../services/api.js';
|
import { api } from '../services/api.js';
|
||||||
import { Button } from '../ui/Button.js';
|
import { Button } from '../ui/Button.js';
|
||||||
|
import { Plaque } from '../ui/Plaque.js';
|
||||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||||
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||||
import { TutorialModal } from '../ui/TutorialModal.js';
|
import { TutorialModal } from '../ui/TutorialModal.js';
|
||||||
|
|
||||||
let _lastCategory = null;
|
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: 'tabletop', label: 'Tabletop' },
|
{ key: 'tabletop', label: 'Tabletop' },
|
||||||
{ key: 'cards', label: 'Cards & Dice' },
|
{ key: 'cards', label: 'Cards & Dice' },
|
||||||
|
|
@ -22,10 +21,29 @@ 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;
|
||||||
|
|
||||||
|
// 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 {
|
export default class GameMenuScene extends Phaser.Scene {
|
||||||
constructor() { super('GameMenu'); }
|
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() {
|
async create() {
|
||||||
playMenuMusic();
|
playMenuMusic();
|
||||||
const cx = GAME_WIDTH / 2;
|
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);
|
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);
|
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…', {
|
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
|
||||||
fontSize: '24px', color: COLORS.mutedHex,
|
fontSize: '24px', color: COLORS.mutedHex,
|
||||||
|
|
@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene {
|
||||||
this._tabIcons[key] = icon;
|
this._tabIcons[key] = icon;
|
||||||
});
|
});
|
||||||
|
|
||||||
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
|
// No category is selected by default — the user picks one to see games.
|
||||||
this.showCategory(startKey);
|
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) {
|
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)) {
|
for (const [k, btn] of Object.entries(this._tabs)) {
|
||||||
btn.setActive(k === key);
|
btn.setActive(k === key);
|
||||||
}
|
}
|
||||||
|
|
@ -162,13 +221,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 +354,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) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||||
import { auth } from '../services/auth.js';
|
import { auth } from '../services/auth.js';
|
||||||
import { Button } from '../ui/Button.js';
|
import { Button } from '../ui/Button.js';
|
||||||
|
import { Plaque } from '../ui/Plaque.js';
|
||||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||||
import { playMenuMusic } from '../ui/MenuMusic.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);
|
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 +56,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 +69,8 @@ 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;
|
||||||
|
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||||
|
|
||||||
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 +78,8 @@ 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 = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
|
||||||
|
.setPosition(cx, y);
|
||||||
|
|
||||||
if (hasAvatar) {
|
if (hasAvatar) {
|
||||||
const avatarCx = groupLeft + avatarR;
|
const avatarCx = groupLeft + avatarR;
|
||||||
|
|
@ -86,11 +93,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 +105,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'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||||
import { Button } from '../ui/Button.js';
|
import { Button } from '../ui/Button.js';
|
||||||
|
import { Plaque } from '../ui/Plaque.js';
|
||||||
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
|
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
|
||||||
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.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
|
// Opponent grid scroll area
|
||||||
const OPP_SCROLL_W = 1780;
|
const OPP_SCROLL_W = 1780;
|
||||||
const OPP_SCROLL_H = 440;
|
const OPP_SCROLL_H = 415;
|
||||||
const OPP_SCROLL_TOP = 155; // top edge of scroll area
|
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
|
||||||
|
|
||||||
export default class OpponentSelectScene extends Phaser.Scene {
|
export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
constructor() { super('OpponentSelect'); }
|
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.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);
|
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',
|
fontFamily: 'Righteous',
|
||||||
fontSize: '52px',
|
fontSize: '52px',
|
||||||
color: COLORS.textHex,
|
color: COLORS.textHex,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
|
titleText.setLetterSpacing(2);
|
||||||
this.children.moveBelow(titlePill, titleText);
|
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',
|
fontFamily: 'Righteous',
|
||||||
fontSize: '36px',
|
fontSize: '36px',
|
||||||
color: COLORS.mutedHex,
|
color: COLORS.mutedHex,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
|
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||||
this.children.moveBelow(subtitlePill, subtitleText);
|
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 = [];
|
let opponents = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
const min = this.gameDef.minOpponents ?? 1;
|
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',
|
variant: 'ghost',
|
||||||
width: 280,
|
width: 280,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue