feat(splendor): animate gem takes and refine bank UI layout

- Add a dark background panel behind the token bank for better visual contrast.
- Introduce `animGemToSelection` and `animAITake` to animate gems flying from the bank to the selection bar and player panels.
- Update `onTokenClick` and `confirmTake` to trigger gem animations for human players.
- Integrate gem take animations into the AI turn sequence.
- Add `playerGemPos`/`humanGemPos` helpers for dynamic gem coordinate calculation.
- Adjust token count text, alignment, and depth layers in `drawBank` for cleaner rendering.
This commit is contained in:
Brian Fertig 2026-06-02 22:18:34 -06:00
parent dd607a5815
commit 4e30e75987
1 changed files with 156 additions and 12 deletions

View File

@ -308,6 +308,15 @@ export default class SplendorGame extends Phaser.Scene {
// ── token bank ──────────────────────────────────────────────────────────────
drawBank() {
const order = [...GEMS, GOLD];
const pad = 16;
const bgX = BANK_X - TOKEN_R - pad;
const bgY = BANK_Y0 - TOKEN_R - pad;
const bgW = PANEL_X - bgX - pad;
const bgH = (order.length - 1) * BANK_STEP + TOKEN_R * 2 + pad * 2;
const bg = this.reg(this.add.graphics().setDepth(DEPTH.card - 1));
bg.fillStyle(0x000000, 0.72).fillRoundedRect(bgX, bgY, bgW, bgH, 12);
bg.lineStyle(1, COLORS.accent, 0.5).strokeRoundedRect(bgX, bgY, bgW, bgH, 12);
order.forEach((color, i) => {
const cx = BANK_X, cy = BANK_Y0 + i * BANK_STEP;
const n = this.gs.bank[color];
@ -318,23 +327,23 @@ export default class SplendorGame extends Phaser.Scene {
this.reg(this.add.image(cx, cy, 'splendor-gems', gemFrame(color))
.setDisplaySize(56, 56).setAlpha(n > 0 ? 0.9 : 0.35).setDepth(DEPTH.card + 1));
}
this.reg(this.add.text(cx, cy, String(n), {
fontFamily: 'Righteous', fontSize: '26px',
color: color === 'white' || color === 'gold' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
this.reg(this.add.text(cx + TOKEN_R + 14, cy, GEM_NAME[color], {
const textX = cx + TOKEN_R + 12;
this.reg(this.add.text(textX, cy, String(n), {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 2));
this.reg(this.add.text(textX + 32, cy, GEM_NAME[color], {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 1));
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 2));
// selection count overlay
const sel = this.selection.filter((c) => c === color).length;
if (sel > 0) {
this.reg(this.add.text(cx - TOKEN_R - 6, cy - TOKEN_R, `+${sel}`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(1, 0).setDepth(DEPTH.card + 2));
}).setOrigin(1, 0).setDepth(DEPTH.card + 3));
}
if (this.isHumanTurn() && color !== GOLD && n > 0) {
const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 2));
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 3));
zone.on('pointerdown', () => this.onTokenClick(color));
}
});
@ -525,23 +534,103 @@ export default class SplendorGame extends Phaser.Scene {
}
pname(seat) { return this.gs.players[seat]?.name ?? `Player ${seat}`; }
// Returns the screen centre of any player's gem cell for a given colour.
playerGemPos(seat, color) {
const n = this.gs.players.length;
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
const order = [...GEMS, GOLD];
const cellW = (PANEL_W - 32) / order.length;
const ci = order.indexOf(color);
return { x: PANEL_X + 16 + cellW * ci + cellW / 2, y: 76 + seat * (h + gap) + 70 };
}
humanGemPos(color) { return this.playerGemPos(this.humanSeat, color); }
// Animate gems taken by an AI player from the bank to their panel.
animAITake(seat, action) {
const taken = action.type === 'take2'
? [action.color, action.color]
: action.colors;
const preTokens = { ...this.gs.players[seat].tokens };
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
}
const postTokens = this.gs.players[seat].tokens;
// Render with pre-take token counts so the panel doesn't jump yet
this.gs.players[seat].tokens = preTokens;
this.render();
this.gs.players[seat].tokens = postTokens;
const gemOrder = [...GEMS, GOLD];
let pending = taken.length;
taken.forEach((color, i) => {
const srcX = BANK_X, srcY = BANK_Y0 + gemOrder.indexOf(color) * BANK_STEP;
const dest = this.playerGemPos(seat, color);
const gem = this.add.image(srcX, srcY, 'splendor-gems', gemFrame(color))
.setDisplaySize(56, 56).setDepth(DEPTH.popup);
this.tweens.add({
targets: gem,
x: dest.x, y: dest.y,
displayWidth: 26, displayHeight: 26,
duration: 380,
delay: i * 90,
ease: 'Cubic.easeOut',
onComplete: () => {
gem.destroy();
if (--pending === 0) {
this.busy = false;
this.render();
this.advance();
}
},
});
});
}
// Animates a gem icon from its bank position to the Take-bar selection slot.
animGemToSelection(color, destIdx) {
if (!this.hasGems) return;
const order = [...GEMS, GOLD];
const srcX = BANK_X, srcY = BANK_Y0 + order.indexOf(color) * BANK_STEP;
const dstX = MARKET_X + 160 + destIdx * 44;
const dstY = CONTROL_Y + 26;
const gem = this.add.image(srcX, srcY, 'splendor-gems', gemFrame(color))
.setDisplaySize(56, 56).setDepth(DEPTH.popup);
this.tweens.add({
targets: gem,
x: dstX, y: dstY,
displayWidth: 32, displayHeight: 32,
duration: 300,
ease: 'Cubic.easeOut',
onComplete: () => gem.destroy(),
});
}
// ── human input ─────────────────────────────────────────────────────────────
onTokenClick(color) {
if (!this.isHumanTurn()) return;
this.selectedCard = null;
const sel = this.selection;
const sameCount = sel.filter((c) => c === color).length;
const lenBefore = sel.length;
if (sel.length === 0) { sel.push(color); }
else if (sel.length === 1 && sel[0] === color) {
// second of the same → take2 (needs a pile of ≥4)
if (this.gs.bank[color] >= TAKE_SAME_MIN) sel.push(color);
} else if (sel.length < 3 && sameCount === 0) {
// a different colour, only if we aren't already on a take2
const isTake2 = sel.length === 2 && sel[0] === sel[1];
if (!isTake2) sel.push(color);
}
const destIdx = sel.length - 1;
const added = sel.length > lenBefore;
this.render();
if (added) this.animGemToSelection(color, destIdx);
}
confirmTake() {
@ -550,10 +639,59 @@ export default class SplendorGame extends Phaser.Scene {
const action = (sel.length === 2 && sel[0] === sel[1])
? { type: 'take2', color: sel[0] }
: { type: 'take3', colors: [...new Set(sel)] };
// guard against an illegal combo
const ok = legalActions(this.gs).some((a) => this.sameAction(a, action));
if (!ok) { this.selection = []; this.render(); return; }
this.applyHuman(action);
// Capture gem sources before clearing selection
const sources = sel.map((color, i) => ({
color, x: MARKET_X + 160 + i * 44, y: CONTROL_Y + 26,
}));
this.selectedCard = null;
this.selection = [];
// Save token state before and after applying the action
const preTokens = { ...this.gs.players[this.humanSeat].tokens };
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
const postTokens = this.gs.players[this.humanSeat].tokens;
// Skip animation for edge cases (discard required, game over, or no gem art)
if (!this.hasGems || this.gs.phase === 'discard' || isGameOver(this.gs)) {
this.render();
this.advance();
return;
}
// Render with pre-take token counts so the panel doesn't jump yet
this.busy = true;
this.gs.players[this.humanSeat].tokens = preTokens;
this.render();
this.gs.players[this.humanSeat].tokens = postTokens;
// Animate each gem from its Take-bar slot to the player panel
let pending = sources.length;
sources.forEach(({ color, x: sx, y: sy }, i) => {
const dest = this.humanGemPos(color);
const gem = this.add.image(sx, sy, 'splendor-gems', gemFrame(color))
.setDisplaySize(32, 32).setDepth(DEPTH.popup);
this.tweens.add({
targets: gem,
x: dest.x, y: dest.y,
displayWidth: 26, displayHeight: 26,
duration: 380,
delay: i * 90,
ease: 'Cubic.easeOut',
onComplete: () => {
gem.destroy();
if (--pending === 0) {
this.busy = false;
this.render();
this.advance();
}
},
});
});
}
sameAction(a, b) {
@ -629,6 +767,12 @@ export default class SplendorGame extends Phaser.Scene {
this.showTurnBanner(`${this.pname(seat)}'s turn`);
this.time.delayedCall(nextThinkDelay(skill), () => {
const action = chooseAction(this.gs, skill);
if (this.hasGems && (action.type === 'take2' || action.type === 'take3')) {
this.animAITake(seat, action);
return;
}
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard') {