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 ──────────────────────────────────────────────────────────────
|
// ── token bank ──────────────────────────────────────────────────────────────
|
||||||
drawBank() {
|
drawBank() {
|
||||||
const order = [...GEMS, GOLD];
|
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) => {
|
order.forEach((color, i) => {
|
||||||
const cx = BANK_X, cy = BANK_Y0 + i * BANK_STEP;
|
const cx = BANK_X, cy = BANK_Y0 + i * BANK_STEP;
|
||||||
const n = this.gs.bank[color];
|
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))
|
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));
|
.setDisplaySize(56, 56).setAlpha(n > 0 ? 0.9 : 0.35).setDepth(DEPTH.card + 1));
|
||||||
}
|
}
|
||||||
this.reg(this.add.text(cx, cy, String(n), {
|
const textX = cx + TOKEN_R + 12;
|
||||||
fontFamily: 'Righteous', fontSize: '26px',
|
this.reg(this.add.text(textX, cy, String(n), {
|
||||||
color: color === 'white' || color === 'gold' ? '#222' : '#fff',
|
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
|
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 2));
|
||||||
this.reg(this.add.text(cx + TOKEN_R + 14, cy, GEM_NAME[color], {
|
this.reg(this.add.text(textX + 32, cy, GEM_NAME[color], {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
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
|
// selection count overlay
|
||||||
const sel = this.selection.filter((c) => c === color).length;
|
const sel = this.selection.filter((c) => c === color).length;
|
||||||
if (sel > 0) {
|
if (sel > 0) {
|
||||||
this.reg(this.add.text(cx - TOKEN_R - 6, cy - TOKEN_R, `+${sel}`, {
|
this.reg(this.add.text(cx - TOKEN_R - 6, cy - TOKEN_R, `+${sel}`, {
|
||||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
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) {
|
if (this.isHumanTurn() && color !== GOLD && n > 0) {
|
||||||
const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2)
|
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));
|
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}`; }
|
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 ─────────────────────────────────────────────────────────────
|
// ── human input ─────────────────────────────────────────────────────────────
|
||||||
onTokenClick(color) {
|
onTokenClick(color) {
|
||||||
if (!this.isHumanTurn()) return;
|
if (!this.isHumanTurn()) return;
|
||||||
this.selectedCard = null;
|
this.selectedCard = null;
|
||||||
const sel = this.selection;
|
const sel = this.selection;
|
||||||
const sameCount = sel.filter((c) => c === color).length;
|
const sameCount = sel.filter((c) => c === color).length;
|
||||||
|
const lenBefore = sel.length;
|
||||||
|
|
||||||
if (sel.length === 0) { sel.push(color); }
|
if (sel.length === 0) { sel.push(color); }
|
||||||
else if (sel.length === 1 && sel[0] === 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);
|
if (this.gs.bank[color] >= TAKE_SAME_MIN) sel.push(color);
|
||||||
} else if (sel.length < 3 && sameCount === 0) {
|
} 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];
|
const isTake2 = sel.length === 2 && sel[0] === sel[1];
|
||||||
if (!isTake2) sel.push(color);
|
if (!isTake2) sel.push(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const destIdx = sel.length - 1;
|
||||||
|
const added = sel.length > lenBefore;
|
||||||
this.render();
|
this.render();
|
||||||
|
if (added) this.animGemToSelection(color, destIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmTake() {
|
confirmTake() {
|
||||||
|
|
@ -550,10 +639,59 @@ export default class SplendorGame extends Phaser.Scene {
|
||||||
const action = (sel.length === 2 && sel[0] === sel[1])
|
const action = (sel.length === 2 && sel[0] === sel[1])
|
||||||
? { type: 'take2', color: sel[0] }
|
? { type: 'take2', color: sel[0] }
|
||||||
: { type: 'take3', colors: [...new Set(sel)] };
|
: { type: 'take3', colors: [...new Set(sel)] };
|
||||||
// guard against an illegal combo
|
|
||||||
const ok = legalActions(this.gs).some((a) => this.sameAction(a, action));
|
const ok = legalActions(this.gs).some((a) => this.sameAction(a, action));
|
||||||
if (!ok) { this.selection = []; this.render(); return; }
|
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) {
|
sameAction(a, b) {
|
||||||
|
|
@ -629,6 +767,12 @@ export default class SplendorGame extends Phaser.Scene {
|
||||||
this.showTurnBanner(`${this.pname(seat)}'s turn`);
|
this.showTurnBanner(`${this.pname(seat)}'s turn`);
|
||||||
this.time.delayedCall(nextThinkDelay(skill), () => {
|
this.time.delayedCall(nextThinkDelay(skill), () => {
|
||||||
const action = chooseAction(this.gs, 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.gs = applyAction(this.gs, action);
|
||||||
this.playActionSound(action);
|
this.playActionSound(action);
|
||||||
if (this.gs.phase === 'discard') {
|
if (this.gs.phase === 'discard') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue