feat(splendor): add player portraits and refine panel UI

- Introduce portrait rendering for players and opponents via `buildPortraits`.
- Compact and right-align gem cells with updated sizing, spacing, and smaller fonts.
- Add divider and total row displaying combined bonus and token counts.
- Increase panel background opacity and fix reserved card interactivity checks.
This commit is contained in:
Brian Fertig 2026-06-04 18:55:01 -06:00
parent 9b7bbe55c4
commit e2b28b6c0e
1 changed files with 59 additions and 13 deletions

View File

@ -13,6 +13,7 @@ import {
createInitialState, legalActions, applyAction, applyDiscard, defaultDiscards, createInitialState, legalActions, applyAction, applyDiscard, defaultDiscards,
canAfford, tokenTotal, isGameOver, finalRanking, currentPlayer, canAfford, tokenTotal, isGameOver, finalRanking, currentPlayer,
} from './SplendorLogic.js'; } from './SplendorLogic.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { HAND_LIMIT } from './SplendorData.js'; import { HAND_LIMIT } from './SplendorData.js';
import { chooseAction, nextThinkDelay } from './SplendorAI.js'; import { chooseAction, nextThinkDelay } from './SplendorAI.js';
@ -48,6 +49,7 @@ export default class SplendorGame extends Phaser.Scene {
this.selectedCard = null; // { card, source } chosen to buy/reserve this.selectedCard = null; // { card, source } chosen to buy/reserve
this.dyn = []; // dynamic objects rebuilt every render this.dyn = []; // dynamic objects rebuilt every render
this.preview = []; // hover card preview — cleared separately this.preview = []; // hover card preview — cleared separately
this.portraits = []; // one per seat, created once
} }
create() { create() {
@ -71,10 +73,32 @@ export default class SplendorGame extends Phaser.Scene {
} }
this.gs = createInitialState({ playerCount, names }); this.gs = createInitialState({ playerCount, names });
this.buildPortraits();
this.render(); this.render();
this.advance(); this.advance();
} }
// ── portraits (created once, not part of dyn) ────────────────────────────────
buildPortraits() {
const n = this.gs.players.length;
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const portraitR = 34;
this.gs.players.forEach((_, idx) => {
const panelY = 76 + idx * (h + gap);
const px = PANEL_X + 16 + portraitR;
const py = panelY + 38 + portraitR; // below 22px name + small gap
if (idx === this.humanSeat) {
this.portraits[idx] = createPlayerPortrait(this, px, py, portraitR, DEPTH.ui + 1, 'SplendorGame');
} else {
const opp = this.opponents[idx - 1];
this.portraits[idx] = createOpponentPortrait(this, opp, px, py, portraitR, DEPTH.ui + 1, { playIntro: false });
}
});
}
// ── static background / chrome ────────────────────────────────────────────── // ── static background / chrome ──────────────────────────────────────────────
buildBackground() { buildBackground() {
const pf = this.playfield; const pf = this.playfield;
@ -358,7 +382,7 @@ export default class SplendorGame extends Phaser.Scene {
const x = PANEL_X, y = 76 + idx * (h + gap), w = PANEL_W; const x = PANEL_X, y = 76 + idx * (h + gap), w = PANEL_W;
const isCurrent = idx === this.gs.current && !isGameOver(this.gs); const isCurrent = idx === this.gs.current && !isGameOver(this.gs);
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui)); const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x000000, 0.34).fillRoundedRect(x, y, w, h, 12); g.fillStyle(0x000000, 0.62).fillRoundedRect(x, y, w, h, 12);
g.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.4) g.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.4)
.strokeRoundedRect(x, y, w, h, 12); .strokeRoundedRect(x, y, w, h, 12);
@ -373,45 +397,67 @@ export default class SplendorGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1)); }).setOrigin(1, 0).setDepth(DEPTH.ui + 1));
// gem cells: cards (bonus) over tokens // gem cells: cards (bonus) over tokens — compact, right-aligned
const order = [...GEMS, GOLD]; const order = [...GEMS, GOLD];
const cellW = (w - 32) / order.length; const gemR = 14;
const gemSpacing = 36;
const gemStartX = x + w - 16 - gemR - (order.length - 1) * gemSpacing;
order.forEach((color, ci) => { order.forEach((color, ci) => {
const ccx = x + 16 + cellW * ci + cellW / 2; const ccx = gemStartX + ci * gemSpacing;
const ccy = y + 70; const ccy = y + 70;
const cards = color === GOLD ? 0 : (p.bonuses[color] ?? 0); const cards = color === GOLD ? 0 : (p.bonuses[color] ?? 0);
const toks = p.tokens[color] ?? 0; const toks = p.tokens[color] ?? 0;
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, 16); cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, gemR);
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, 16); cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, gemR);
if (color !== GOLD) { if (color !== GOLD) {
this.reg(this.add.text(ccx, ccy, String(cards), { this.reg(this.add.text(ccx, ccy, String(cards), {
fontFamily: 'Righteous', fontSize: '16px', fontFamily: 'Righteous', fontSize: '14px',
color: color === 'white' ? '#222' : '#fff', color: color === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }).setOrigin(0.5).setDepth(DEPTH.ui + 2));
} }
this.reg(this.add.text(ccx, ccy + 24, `${toks}`, { this.reg(this.add.text(ccx, ccy + 24, `${toks}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }).setOrigin(0.5).setDepth(DEPTH.ui + 2));
// discard interactivity (human, over limit) // discard interactivity (human, over limit)
const discardable = this.gs.phase === 'discard' && this.gs.current === this.humanSeat const discardable = this.gs.phase === 'discard' && this.gs.current === this.humanSeat
&& idx === this.humanSeat && toks > 0; && idx === this.humanSeat && toks > 0;
if (discardable) { if (discardable) {
const z = this.reg(this.add.zone(ccx, ccy + 8, cellW, 56) const z = this.reg(this.add.zone(ccx, ccy + 8, gemSpacing, 50)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3)); .setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
z.on('pointerdown', () => this.onDiscardClick(color)); z.on('pointerdown', () => this.onDiscardClick(color));
} }
}); });
// divider + total row (card bonuses + tokens)
const ccy = y + 70;
const divY = ccy + 38;
const totalCY = ccy + 53;
const divG = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
divG.lineStyle(1, COLORS.accent, 0.35)
.lineBetween(gemStartX - gemR - 2, divY, gemStartX + (order.length - 1) * gemSpacing + gemR + 2, divY);
order.forEach((color, ci) => {
const ccx = gemStartX + ci * gemSpacing;
const total = (color === GOLD ? 0 : (p.bonuses[color] ?? 0)) + (p.tokens[color] ?? 0);
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
tg.fillStyle(GEM_HEX[color], total > 0 ? 0.8 : 0.2).fillCircle(ccx, totalCY, 10);
tg.lineStyle(1.5, GEM_EDGE[color], 1).strokeCircle(ccx, totalCY, 10);
this.reg(this.add.text(ccx, totalCY, String(total), {
fontFamily: 'Righteous', fontSize: '12px',
color: color === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
});
// reserved cards // reserved cards
const rN = p.reserved.length; const rN = p.reserved.length;
this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, { this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1)); }).setDepth(DEPTH.ui + 1));
if (idx === this.humanSeat && rN > 0) { if (rN > 0) {
const isHuman = idx === this.humanSeat;
p.reserved.forEach((card, ri) => { p.reserved.forEach((card, ri) => {
const rx = x + 150 + ri * 116, ry = y + h - 40; const rx = x + 150 + ri * 116, ry = y + h - 40;
const canBuy = this.isHumanTurn() && canAfford(p, card); const canBuy = isHuman && this.isHumanTurn() && canAfford(p, card);
const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)); const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
mg.fillStyle(GEM_HEX[card.bonus], 0.85).fillRoundedRect(rx, ry, 104, 30, 6); mg.fillStyle(GEM_HEX[card.bonus], 0.85).fillRoundedRect(rx, ry, 104, 30, 6);
mg.lineStyle(2, canBuy ? 0x57c46a : GEM_EDGE[card.bonus], canBuy ? 1 : 0.8) mg.lineStyle(2, canBuy ? 0x57c46a : GEM_EDGE[card.bonus], canBuy ? 1 : 0.8)
@ -422,11 +468,11 @@ export default class SplendorGame extends Phaser.Scene {
color: card.bonus === 'white' ? '#222' : '#fff', color: card.bonus === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }).setOrigin(0.5).setDepth(DEPTH.ui + 2));
const z = this.reg(this.add.zone(rx + 52, ry + 15, 104, 30) const z = this.reg(this.add.zone(rx + 52, ry + 15, 104, 30)
.setInteractive(this.isHumanTurn() ? { useHandCursor: true } : {}) .setInteractive({ useHandCursor: isHuman && this.isHumanTurn() })
.setDepth(DEPTH.ui + 3)); .setDepth(DEPTH.ui + 3));
z.on('pointerover', () => this.showCardPreview(card, rx + 52, ry + 30)); z.on('pointerover', () => this.showCardPreview(card, rx + 52, ry + 30));
z.on('pointerout', () => this.clearPreview()); z.on('pointerout', () => this.clearPreview());
if (this.isHumanTurn()) { if (isHuman && this.isHumanTurn()) {
z.on('pointerdown', () => this.onCardClick(card, 'reserve')); z.on('pointerdown', () => this.onCardClick(card, 'reserve'));
} }
}); });