feat(splendor): add buy/reserve card animations and bought-card thumbnails

- Implement card fly-to-center animation with fireworks for buy and reserve actions
- Add persistent bought-card thumbnails in player panels with automatic wrapping
- Build full card containers (costs, points, gem badges) for smooth scaling transitions
- Suppress reserve pills and buy animations in UI until transitions complete
- Apply animation triggers consistently for both human and AI turns
This commit is contained in:
Brian Fertig 2026-06-04 20:02:42 -06:00
parent 5eb5c71ebf
commit 6abcbfa32b
1 changed files with 427 additions and 4 deletions

View File

@ -32,6 +32,17 @@ const CONTROL_Y = 794; // bottom context bar
const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 }; const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 };
// ── bought-card thumbnails in player panels ───────────────────────────────────
const THUMB_W = 44;
const THUMB_H = Math.round(THUMB_W * CH / CW); // ≈ 60 — maintains card aspect ratio
const THUMB_GAP = 4; // horizontal gap between cards
const THUMB_ROW_GAP = 3; // vertical gap between rows
const PORTRAIT_R = 34; // must match buildPortraits
const THUMB_AREA_REL_X = PORTRAIT_R * 2 + 16 + 8; // portrait right-edge + 8 px left padding
const THUMB_GEM_REL_X = PANEL_W - 16 - 14 - 5 * 36 - 14; // gem-row left edge, panel-relative
const THUMB_PER_ROW = Math.floor((THUMB_GEM_REL_X - THUMB_AREA_REL_X) / (THUMB_W + THUMB_GAP));
const THUMB_ROW_TOP_REL = 38; // panel-relative top (aligns with portrait top)
const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6); const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6);
export default class SplendorGame extends Phaser.Scene { export default class SplendorGame extends Phaser.Scene {
@ -50,6 +61,9 @@ export default class SplendorGame extends Phaser.Scene {
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 this.portraits = []; // one per seat, created once
this.animatingReserve = null; // { seat, cardId } suppresses that pill during animation
this.animatingBuy = null; // { seat, cardId } suppresses thumbnail during animation
this.boughtCards = {}; // seat → card[] — persists purchased cards for display
} }
create() { create() {
@ -142,6 +156,40 @@ export default class SplendorGame extends Phaser.Scene {
this.preview = []; this.preview = [];
} }
findCardBoardPos(cardId) {
for (const tier of [1, 2, 3]) {
const col = this.gs.board[tier].findIndex((c) => c?.id === cardId);
if (col !== -1) {
return { x: CARDS_X + col * (CW + CARD_GAP) + CW / 2, y: TIER_Y[tier] + CH / 2 };
}
}
return null;
}
findCard(cardId) {
for (const tier of [1, 2, 3]) {
const c = this.gs.board[tier].find((c) => c?.id === cardId);
if (c) return c;
}
for (const p of this.gs.players) {
const c = p.reserved.find((c) => c?.id === cardId);
if (c) return c;
}
return null;
}
boughtCardThumbPos(seat, idx) {
const n = this.gs.players.length;
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const panelY = 76 + seat * (h + gap);
const col = idx % THUMB_PER_ROW;
const row = Math.floor(idx / THUMB_PER_ROW);
const tx = PANEL_X + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP);
const ty = panelY + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP);
return { cx: tx + THUMB_W / 2, cy: ty + THUMB_H / 2 };
}
showCardPreview(card, barCenterX, barBottomY) { showCardPreview(card, barCenterX, barBottomY) {
this.clearPreview(); this.clearPreview();
const pw = 270, ph = 390; const pw = 270, ph = 390;
@ -468,6 +516,30 @@ export default class SplendorGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(DEPTH.ui + 2)); }).setOrigin(0.5).setDepth(DEPTH.ui + 2));
}); });
// bought card thumbnails (right of portrait, left of gems, wrapping into rows)
(this.boughtCards[idx] ?? []).forEach((bc, bi) => {
if (this.animatingBuy?.seat === idx && this.animatingBuy?.cardId === bc.id) return;
const col = bi % THUMB_PER_ROW;
const row = Math.floor(bi / THUMB_PER_ROW);
const tx = x + THUMB_AREA_REL_X + col * (THUMB_W + THUMB_GAP);
const ty = y + THUMB_ROW_TOP_REL + row * (THUMB_H + THUMB_ROW_GAP);
const tg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
tg.fillStyle(0x000000, 0.32).fillRoundedRect(tx + 2, ty + 2, THUMB_W, THUMB_H, 4);
tg.fillStyle(0x14110b, 1).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
tg.fillStyle(GEM_HEX[bc.bonus], 0.22).fillRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
if (this.hasArt) {
this.regMaskedImg(tx, ty, THUMB_W, THUMB_H, 'splendor-cards', cardFrame(bc), DEPTH.ui + 2, 4);
}
this.reg(this.add.graphics().setDepth(DEPTH.ui + 3))
.lineStyle(1, GEM_EDGE[bc.bonus], 0.9).strokeRoundedRect(tx, ty, THUMB_W, THUMB_H, 4);
if (bc.points > 0) {
this.reg(this.add.text(tx + 3, ty + 1, String(bc.points), {
fontFamily: 'Righteous', fontSize: '10px',
color: bc.bonus === 'white' ? '#333' : '#fff',
}).setDepth(DEPTH.ui + 4));
}
});
// 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}`, {
@ -476,6 +548,7 @@ export default class SplendorGame extends Phaser.Scene {
if (rN > 0) { if (rN > 0) {
const isHuman = idx === this.humanSeat; const isHuman = idx === this.humanSeat;
p.reserved.forEach((card, ri) => { p.reserved.forEach((card, ri) => {
if (this.animatingReserve?.seat === idx && this.animatingReserve?.cardId === card.id) return;
const rx = x + 150 + ri * 116, ry = y + h - 40; const rx = x + 150 + ri * 116, ry = y + h - 40;
const canBuy = isHuman && 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));
@ -679,6 +752,231 @@ export default class SplendorGame extends Phaser.Scene {
}); });
} }
// Builds a card container centred at (x,y) in world space with full overlay art.
// All draw coords are in container-local space (0,0 = card centre).
_buildCardContainer(x, y, card) {
const container = this.add.container(x, y).setDepth(DEPTH.popup + 1);
const tint = GEM_HEX[card.bonus];
// Background
const bg = this.add.graphics();
bg.fillStyle(0x14110b, 1).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
bg.fillStyle(tint, 0.22).fillRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
container.add(bg);
// Art
if (this.hasArt) {
container.add(
this.add.image(0, 0, 'splendor-cards', cardFrame(card)).setDisplaySize(CW, CH)
);
}
// Border above art
const border = this.add.graphics();
border.lineStyle(1.5, GEM_EDGE[card.bonus], 1).strokeRoundedRect(-CW / 2, -CH / 2, CW, CH, 10);
container.add(border);
// Points — top-left
if (card.points > 0) {
container.add(this.add.text(-CW / 2 + 10, -CH / 2 + 2, String(card.points), {
fontFamily: 'Righteous', fontSize: '26px',
color: card.bonus === 'white' ? '#222' : '#fff',
}));
}
// Gem bonus badge — top-right
const badgeG = this.add.graphics();
badgeG.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(CW / 2 - 22, -CH / 2 + 15, 12);
badgeG.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(CW / 2 - 22, -CH / 2 + 15, 12);
container.add(badgeG);
if (this.hasGems) {
container.add(
this.add.image(CW / 2 - 22, -CH / 2 + 15, 'splendor-gems', gemFrame(card.bonus))
.setDisplaySize(18, 18)
);
}
// Cost pips — lower-left, stacked
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
costs.forEach((c, i) => {
const pipY = CH / 2 - 22 - i * 30;
const pipG = this.add.graphics();
pipG.fillStyle(GEM_HEX[c], 1).fillCircle(-CW / 2 + 20, pipY, 12);
pipG.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(-CW / 2 + 20, pipY, 12);
container.add(pipG);
container.add(this.add.text(-CW / 2 + 20, pipY, String(card.cost[c]), {
fontFamily: 'Righteous', fontSize: '15px',
color: c === 'white' ? '#222' : '#fff',
}).setOrigin(0.5));
});
return container;
}
// ── reserve animation ───────────────────────────────────────────────────────
animReserve(seat, card, srcX, srcY, onDone) {
const PW = 270, PH = 390;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
// DOM video portraits render above the canvas; hide them for the duration
for (const p of this.portraits) p?.hide();
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000)
.setAlpha(0).setDepth(DEPTH.popup);
this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 });
const container = this._buildCardContainer(srcX, srcY, card);
// Phase 1: fly to centre and grow
this.tweens.add({
targets: container,
x: cx, y: cy,
scaleX: PW / CW, scaleY: PH / CH,
duration: 420,
ease: 'Cubic.easeOut',
onComplete: () => {
this._playReserveFireworks(cx, cy, card.bonus);
// Phase 2: hold 1.2 s, then shrink to reserve-pill slot
this.time.delayedCall(1200, () => {
const n = this.gs.players.length;
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const panelY = 76 + seat * (h + gap);
const ri = this.gs.players[seat].reserved.findIndex((c) => c.id === card.id);
const pillCX = PANEL_X + 150 + Math.max(0, ri) * 116 + 52;
const pillCY = panelY + h - 40 + 15;
this.tweens.add({ targets: overlay, alpha: 0, duration: 300,
onComplete: () => { try { overlay.destroy(); } catch { /* */ } } });
this.tweens.add({
targets: container,
x: pillCX, y: pillCY,
scale: 0.08,
alpha: 0,
duration: 360,
ease: 'Cubic.easeIn',
onComplete: () => {
for (const p of this.portraits) p?.show();
container.removeAll(true);
container.destroy();
onDone();
},
});
});
},
});
}
_playReserveFireworks(cx, cy, bonus) {
// 5 distinct bursts staggered in time, each at a random position around the card
const tint = GEM_HEX[bonus];
const edge = GEM_EDGE[bonus];
const palettes = [
[tint, edge, 0xffffff],
[0xffd700, 0xffbb00, 0xffffff],
[tint, 0xffffff, edge],
[0xff6644, 0xffaa00, 0xffd700],
[edge, tint, 0xaaffdd],
];
const BURSTS = 5;
// Card at center is 270×390; place bursts in a ring around it
const RX = 195, RY = 240; // semi-axes of the ring (just outside card edges)
for (let b = 0; b < BURSTS; b++) {
const delay = b * 190 + Math.random() * 70;
// Spread bursts evenly around the card with some jitter
const angle = (b / BURSTS) * Math.PI * 2 + (Math.random() - 0.5) * 0.7;
const rScale = 1.05 + Math.random() * 0.55;
const bx = cx + Math.cos(angle) * RX * rScale;
const by = cy + Math.sin(angle) * RY * rScale;
const palette = palettes[b % palettes.length];
const sparkCount = 16 + Math.floor(Math.random() * 8);
this.time.delayedCall(delay, () => {
// Central flash — expands and fades
const flash = this.add.circle(bx, by, 7, palette[0], 1).setDepth(DEPTH.popup + 2);
this.tweens.add({
targets: flash, scaleX: 4.5, scaleY: 4.5, alpha: 0,
duration: 260, ease: 'Cubic.easeOut',
onComplete: () => { try { flash.destroy(); } catch { /* */ } },
});
// Sparks radiate outward from the burst point
for (let i = 0; i < sparkCount; i++) {
const sparkAngle = (i / sparkCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.3;
const speed = 90 + Math.random() * 170;
const size = 2.5 + Math.random() * 5;
const color = palette[Math.floor(Math.random() * palette.length)];
const spark = this.add.circle(bx, by, size, color, 1).setDepth(DEPTH.popup + 2);
this.tweens.add({
targets: spark,
x: bx + Math.cos(sparkAngle) * speed,
y: by + Math.sin(sparkAngle) * speed + speed * 0.28, // slight gravity droop
scaleX: 0.12, scaleY: 0.12,
alpha: 0,
duration: 600 + Math.random() * 480,
ease: 'Cubic.easeOut',
onComplete: () => { try { spark.destroy(); } catch { /* */ } },
});
}
});
}
}
// ── buy animation ────────────────────────────────────────────────────────────
animBuy(seat, card, srcX, srcY, onDone) {
if (!this.hasArt) { onDone(); return; }
const PW = 270, PH = 390;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
for (const p of this.portraits) p?.hide();
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000)
.setAlpha(0).setDepth(DEPTH.popup);
this.tweens.add({ targets: overlay, alpha: 0.52, duration: 280 });
const container = this._buildCardContainer(srcX, srcY, card);
this.tweens.add({
targets: container,
x: cx, y: cy,
scaleX: PW / CW, scaleY: PH / CH,
duration: 420,
ease: 'Cubic.easeOut',
onComplete: () => {
this._playReserveFireworks(cx, cy, card.bonus);
this.time.delayedCall(1200, () => {
const bought = this.boughtCards[seat] ?? [];
const bi = bought.findIndex((c) => c.id === card.id);
const { cx: thumbCX, cy: thumbCY } = this.boughtCardThumbPos(seat, bi >= 0 ? bi : bought.length - 1);
this.tweens.add({ targets: overlay, alpha: 0, duration: 300,
onComplete: () => { try { overlay.destroy(); } catch { /* */ } } });
this.tweens.add({
targets: container,
x: thumbCX, y: thumbCY,
scaleX: THUMB_W / CW, scaleY: THUMB_H / CH,
alpha: 0,
duration: 360,
ease: 'Cubic.easeIn',
onComplete: () => {
for (const p of this.portraits) p?.show();
container.removeAll(true);
container.destroy();
onDone();
},
});
});
},
});
}
// ── human input ───────────────────────────────────────────────────────────── // ── human input ─────────────────────────────────────────────────────────────
onTokenClick(color) { onTokenClick(color) {
if (!this.isHumanTurn()) return; if (!this.isHumanTurn()) return;
@ -800,10 +1098,74 @@ export default class SplendorGame extends Phaser.Scene {
applyHuman(action) { applyHuman(action) {
this.selectedCard = null; this.selectedCard = null;
this.selection = []; this.selection = [];
if (this.hasArt && action.type === 'buy') {
const seat = this.humanSeat;
const card = this.findCard(action.cardId);
const boardPos = this.findCardBoardPos(action.cardId);
let srcX, srcY;
if (boardPos) {
srcX = boardPos.x; srcY = boardPos.y;
} else {
const n = this.gs.players.length, gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const panelY = 76 + seat * (h + gap);
const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId));
srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
}
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === seat) { this.render(); return; }
if (!this.boughtCards[seat]) this.boughtCards[seat] = [];
this.boughtCards[seat].push(card);
this.animatingBuy = { seat, cardId: card.id };
this.busy = true;
this.render();
this.animBuy(seat, card, srcX, srcY, () => {
this.animatingBuy = null;
this.busy = false;
this.render();
this.advance();
});
return;
}
if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) {
const srcPos = action.type === 'reserve'
? this.findCardBoardPos(action.cardId)
: { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 };
const srcX = srcPos?.x ?? GAME_WIDTH / 2;
const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
this.gs = applyAction(this.gs, action); this.gs = applyAction(this.gs, action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) { if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
this.render(); // hand over to the discard UI this.render();
return;
}
const reserved = this.gs.players[this.humanSeat].reserved;
const card = reserved[reserved.length - 1];
this.animatingReserve = { seat: this.humanSeat, cardId: card.id };
this.busy = true;
this.render();
this.animReserve(this.humanSeat, card, srcX, srcY, () => {
this.animatingReserve = null;
this.busy = false;
this.render();
this.advance();
});
return;
}
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
this.render();
return; return;
} }
this.render(); this.render();
@ -841,6 +1203,67 @@ export default class SplendorGame extends Phaser.Scene {
return; return;
} }
if (this.hasArt && (action.type === 'reserve' || action.type === 'reserveDeck')) {
const srcPos = action.type === 'reserve'
? this.findCardBoardPos(action.cardId)
: { x: MARKET_X + DECK_W / 2, y: TIER_Y[action.tier] + CH / 2 };
const srcX = srcPos?.x ?? GAME_WIDTH / 2;
const srcY = srcPos?.y ?? GAME_HEIGHT / 2;
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
}
const reserved = this.gs.players[seat].reserved;
const card = reserved[reserved.length - 1];
this.animatingReserve = { seat, cardId: card.id };
this.render();
this.animReserve(seat, card, srcX, srcY, () => {
this.animatingReserve = null;
this.busy = false;
this.render();
this.advance();
});
return;
}
if (this.hasArt && action.type === 'buy') {
const card = this.findCard(action.cardId);
const boardPos = this.findCardBoardPos(action.cardId);
let srcX, srcY;
if (boardPos) {
srcX = boardPos.x; srcY = boardPos.y;
} else {
const n = this.gs.players.length, gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const panelY = 76 + seat * (h + gap);
const ri = Math.max(0, this.gs.players[seat].reserved.findIndex((c) => c?.id === action.cardId));
srcX = PANEL_X + 150 + ri * 116 + 52; srcY = panelY + h - 40 + 15;
}
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
}
if (!this.boughtCards[seat]) this.boughtCards[seat] = [];
this.boughtCards[seat].push(card);
this.animatingBuy = { seat, cardId: card.id };
this.render();
this.animBuy(seat, card, srcX, srcY, () => {
this.animatingBuy = null;
this.busy = false;
this.render();
this.advance();
});
return;
}
this.gs = applyAction(this.gs, action); this.gs = applyAction(this.gs, action);
this.playActionSound(action); this.playActionSound(action);
if (this.gs.phase === 'discard') { if (this.gs.phase === 'discard') {