refactor: overhaul game menu layout, enhance Button component, and adjust Dominion AI

- Replace column-based game list with a tabbed category interface and grid layout
- Add active state management to tabs and refactor Button component for consistent visual states
- Increase Dominion AI Platinum purchase threshold from 5 to 9 coins in Colony games
- Add Ticket to Ride card assets
This commit is contained in:
Brian Fertig 2026-05-30 10:47:50 -06:00
parent 2dbcb83754
commit 975d40b4b0
5 changed files with 96 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

View File

@ -116,7 +116,7 @@ export function chooseBuy(state, seat, skill = 3) {
}
// Treasure economy. In Colony games Platinum is the premier economy buy.
if (colonyGame && coins >= 5 && (state.supply.platinum ?? 0) > 0) return 'platinum';
if (colonyGame && coins >= 9 && (state.supply.platinum ?? 0) > 0) return 'platinum';
if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
return null;

View File

@ -5,6 +5,13 @@ import { Button } from '../ui/Button.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards' },
{ key: 'casino', label: 'Casino' },
{ key: 'word', label: 'Word' },
];
export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); }
@ -22,7 +29,7 @@ export default class GameMenuScene extends Phaser.Scene {
}).setOrigin(0.5).setDepth(1);
this.add.rectangle(cx, 120, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
const loadingText = this.add.text(cx, 220, 'Loading game list…', {
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5);
@ -36,46 +43,68 @@ export default class GameMenuScene extends Phaser.Scene {
}
loadingText.destroy();
const tabletop = games.filter((g) => g.category === 'tabletop');
const cards = games.filter((g) => g.category === 'cards');
const casino = games.filter((g) => g.category === 'casino');
const word = games.filter((g) => g.category === 'word');
const hasWord = word.length > 0;
if (hasWord) {
this.renderColumn('Tabletop', tabletop, cx - 630, 260);
this.renderColumn('Cards', cards, cx - 210, 260);
this.renderColumn('Casino', casino, cx + 210, 260);
this.renderColumn('Word', word, cx + 630, 260);
} else {
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
this.renderColumn('Cards', cards, cx, 260);
this.renderColumn('Casino', casino, cx + 420, 260);
this._gamesByCategory = {};
for (const { key } of CATEGORIES) {
this._gamesByCategory[key] = games.filter((g) => g.category === key);
}
this._gameObjects = [];
this._tabs = {};
const activeCats = CATEGORIES.filter(({ key }) => this._gamesByCategory[key].length > 0);
const tabSpacing = Math.min(380, 1600 / activeCats.length);
const tabStartX = cx - (tabSpacing * (activeCats.length - 1)) / 2;
activeCats.forEach(({ key, label }, i) => {
const btn = new Button(this, tabStartX + i * tabSpacing, 230, label, () => this.showCategory(key), {
width: 320,
variant: 'ghost',
});
this._tabs[key] = btn;
});
this.showCategory(activeCats[0].key);
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
renderColumn(title, games, x, y) {
const panelTop = y - 44;
const panelBot = games.length > 0 ? y + 80 + (games.length - 1) * 90 + 56 : y + 56;
const panelH = panelBot - panelTop;
this.add.rectangle(x, panelTop + panelH / 2, 420, panelH, 0x000000, 0.7);
showCategory(key) {
for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key);
}
this.add.text(x, y, title, {
fontFamily: 'Righteous',
fontSize: '40px',
color: COLORS.accentHex,
}).setOrigin(0.5);
for (const obj of this._gameObjects) obj.destroy();
this._gameObjects = [];
const games = this._gamesByCategory[key];
if (!games || games.length === 0) return;
const cx = GAME_WIDTH / 2;
const COLS = 3;
const COL_SPACING = 420;
const ROW_SPACING = 90;
const GRID_TOP = 340;
const BTN_WIDTH = 360;
const PADDING = 52;
const rows = Math.ceil(games.length / COLS);
const panelH = (rows - 1) * ROW_SPACING + PADDING * 2;
const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + 40;
const panelCenterY = GRID_TOP + (rows - 1) * ROW_SPACING / 2;
const panel = this.add.rectangle(cx, panelCenterY, panelW, panelH, 0x000000, 0.7);
this._gameObjects.push(panel);
games.forEach((game, i) => {
new Button(this, x, y + 80 + i * 90, game.name, () => this.openGame(game), { width: 360 });
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = cx + (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);
});
}
openGame(game) {
// Solo-only games (maxOpponents === 0) skip the opponent-selection screen,
// so stop the menu music here just as OpponentSelectScene does on start.
if (game.maxOpponents === 0) {
stopMenuMusic();
this.scene.start('GameRoom', { game, opponents: [] });

View File

@ -20,23 +20,11 @@ export class Button extends Phaser.GameObjects.Container {
this.options = { width, height, bg, bgHover, textColor, textHoverColor, fontSize, variant };
const isGhost = variant === 'ghost';
const hw = width / 2;
const hh = height / 2;
this.bgRect = scene.add.graphics();
this.bgRect.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
const drawBg = (fillColor, fillAlpha) => {
this.bgRect.clear();
if (fillAlpha > 0) {
this.bgRect.fillStyle(fillColor, fillAlpha);
this.bgRect.fillRoundedRect(-hw, -hh, width, height, RADIUS);
}
this.bgRect.lineStyle(2, COLORS.accent, 1);
this.bgRect.strokeRoundedRect(-hw, -hh, width, height, RADIUS);
};
drawBg(bg, isGhost ? 0.35 : 1);
this._drawBg(bg, isGhost ? 0.35 : 1);
this.text = scene.add.text(0, 0, label, {
fontFamily: '"Julius Sans One"',
@ -54,17 +42,21 @@ export class Button extends Phaser.GameObjects.Container {
});
const onOver = () => {
if (isGhost) {
drawBg(bgHover, 0.18);
if (this._active) return;
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
if (v === 'ghost') {
this._drawBg(bgh, 0.18);
this.text.setColor(COLORS.goldHex);
} else {
drawBg(bgHover, 1);
this.text.setColor(textHoverColor);
this._drawBg(bgh, 1);
this.text.setColor(thc);
}
};
const onOut = () => {
drawBg(bg, isGhost ? 0.35 : 1);
this.text.setColor(textColor);
if (this._active) return;
const { bg: b, textColor: tc, variant: v } = this.options;
this._drawBg(b, v === 'ghost' ? 0.35 : 1);
this.text.setColor(tc);
};
const onDown = () => this.bgRect.setScale(0.97);
const onUp = () => this.bgRect.setScale(1);
@ -79,6 +71,32 @@ export class Button extends Phaser.GameObjects.Container {
scene.add.existing(this);
}
_drawBg(fillColor, fillAlpha) {
const { width, height } = this.options;
const hw = width / 2;
const hh = height / 2;
this.bgRect.clear();
if (fillAlpha > 0) {
this.bgRect.fillStyle(fillColor, fillAlpha);
this.bgRect.fillRoundedRect(-hw, -hh, width, height, RADIUS);
}
this.bgRect.lineStyle(2, COLORS.accent, 1);
this.bgRect.strokeRoundedRect(-hw, -hh, width, height, RADIUS);
}
setActive(active) {
this._active = active;
const { bg, bgHover, textColor, textHoverColor, variant } = this.options;
if (active) {
this._drawBg(bgHover, 1);
this.text.setColor(textHoverColor);
} else {
this._drawBg(bg, variant === 'ghost' ? 0.35 : 1);
this.text.setColor(textColor);
}
return this;
}
setLabel(label) {
this.text.setText(label);
return this;