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:
parent
dd607a5815
commit
4e30e75987
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue