feat(dominion): add animated deck/discard counters and refine turn SFX

- Track deck/discard pile counts during animations for real-time UI updates
- Implement animated discard pile countdown when shuffling
- Dynamically show/hide deck and discard piles based on animation state
- Replace generic card SFX with specific sounds for buying treasures, purchasing, and dealing
- Update Dominion card assets to align with new UI/audio feedback
This commit is contained in:
Brian Fertig 2026-05-27 18:53:30 -06:00
parent 0536a261f6
commit ca38548700
6 changed files with 121 additions and 15 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

View File

@ -101,6 +101,14 @@ export default class DominionGame extends Phaser.Scene {
this._handFxGraphics = [];
this._handFxTweens = [];
this._supplyFxEmitters = [];
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this._liveDeckPile = null;
this._liveDeckBadge = null;
this._liveDeckCountText = null;
this._liveDiscardPile = null;
this._liveDiscardBadge = null;
this._liveDiscardCountText = null;
}
create() {
@ -153,6 +161,8 @@ export default class DominionGame extends Phaser.Scene {
};
this.gs = preDeal;
this.render();
this._deckAnimDisplay = preDeal.players[0].deck.length; // 10 before deal
this._discardAnimDisplay = 0;
this._animDrawCards(p0.hand, initialState);
}
@ -683,39 +693,52 @@ export default class DominionGame extends Phaser.Scene {
renderCounts() {
const gs = this.gs;
const displayDeck = this._deckAnimDisplay ?? gs.players[0].deck.length;
const displayDiscard = this._discardAnimDisplay ?? gs.players[0].discard.length;
// Human deck — face-down card pile with count badge
const deckPile = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
deckPile.setPosition(DECK_PILE_X, DECK_PILE_Y).setDepth(D.hand);
deckPile.setVisible(displayDeck > 0);
this.dynamicLayer.add(deckPile);
const deckCount = gs.players[0].deck.length;
const dcBg = this.add.graphics();
dcBg.fillStyle(0x000000, 0.72);
dcBg.fillCircle(DECK_PILE_X, DECK_PILE_Y, 22);
dcBg.lineStyle(2, COLORS.accent, 1);
dcBg.strokeCircle(DECK_PILE_X, DECK_PILE_Y, 22);
dcBg.setDepth(D.hand + 1);
const dcText = this.add.text(DECK_PILE_X, DECK_PILE_Y, `${deckCount}`, {
dcBg.setVisible(displayDeck > 0);
const dcText = this.add.text(DECK_PILE_X, DECK_PILE_Y, `${displayDeck}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
dcText.setVisible(displayDeck > 0);
this.dynamicLayer.add(dcBg);
this.dynamicLayer.add(dcText);
this._liveDeckPile = deckPile;
this._liveDeckBadge = dcBg;
this._liveDeckCountText = dcText;
// Human discard pile — face-down card with count badge
const discardPile = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
discardPile.setPosition(DISCARD_PILE_X, DISCARD_PILE_Y).setDepth(D.hand);
discardPile.setVisible(displayDiscard > 0);
this.dynamicLayer.add(discardPile);
const discardCount = gs.players[0].discard.length;
const dpBg = this.add.graphics();
dpBg.fillStyle(0x000000, 0.72);
dpBg.fillCircle(DISCARD_PILE_X, DISCARD_PILE_Y, 22);
dpBg.lineStyle(2, COLORS.accent, 1);
dpBg.strokeCircle(DISCARD_PILE_X, DISCARD_PILE_Y, 22);
dpBg.setDepth(D.hand + 1);
const dpText = this.add.text(DISCARD_PILE_X, DISCARD_PILE_Y, `${discardCount}`, {
dpBg.setVisible(displayDiscard > 0);
const dpText = this.add.text(DISCARD_PILE_X, DISCARD_PILE_Y, `${displayDiscard}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
dpText.setVisible(displayDiscard > 0);
this.dynamicLayer.add(dpBg);
this.dynamicLayer.add(dpText);
this._liveDiscardPile = discardPile;
this._liveDiscardBadge = dpBg;
this._liveDiscardCountText = dpText;
this.opponents.forEach((opp, i) => {
const seat = i + 1;
@ -1017,7 +1040,13 @@ export default class DominionGame extends Phaser.Scene {
this._suppressTurnUi = true;
}
const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : [];
this._animDiscardThenDraw(allDiscarded, draws, s);
const prevMe = prev.players[0];
const hadShuffle = newLog.some(e => e.kind === 'shuffle' && e.seat === 0);
this._deckAnimDisplay = prevMe.deck.length;
this._discardAnimDisplay = prevMe.discard.length;
this._animDiscardThenDraw(allDiscarded, draws, s, {
hadShuffle, oldDeckCount: prevMe.deck.length, oldDiscardCount: prevMe.discard.length,
});
return;
}
@ -1034,7 +1063,13 @@ export default class DominionGame extends Phaser.Scene {
}
if (drawnCards.length > 0 && deckChanged) {
this._animDrawCards(drawnCards, s);
const prevMe2 = prev.players[0];
const hadShuffle2 = newLog.some(e => e.kind === 'shuffle' && e.seat === 0);
this._deckAnimDisplay = prevMe2.deck.length;
this._discardAnimDisplay = prevMe2.discard.length;
this._animDrawCards(drawnCards, s, {
hadShuffle: hadShuffle2, oldDeckCount: prevMe2.deck.length, oldDiscardCount: prevMe2.discard.length,
});
return;
}
@ -1134,12 +1169,13 @@ export default class DominionGame extends Phaser.Scene {
if (gs.phase === 'buy') {
if (gs.players[seat].hand.some((c) => isType(c.id, 'treasure'))) {
playSound(this, SFX.COINS);
this.setState(playAllTreasures(gs));
return;
}
const buy = AI.chooseBuy(gs, seat, skill);
if (buy) {
playSound(this, SFX.CARD_SHOW);
playSound(this, SFX.PURCHASE);
if (buy === 'province') this.portraits[seat]?.playEmotion?.('happy');
this.setState(buyCard(gs, buy));
} else {
@ -1159,19 +1195,20 @@ export default class DominionGame extends Phaser.Scene {
humanPlayTreasure(iid) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.COINS);
this.setState(playTreasure(this.gs, iid));
}
humanPlayTreasures() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.CARD_PLACE);
playSound(this, SFX.COINS);
this.setState(playAllTreasures(this.gs));
}
humanBuy(id) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this._boughtThisTurn = true;
playSound(this, SFX.CARD_SHOW);
playSound(this, SFX.PURCHASE);
this.setState(buyCard(this.gs, id));
}
@ -1577,6 +1614,7 @@ export default class DominionGame extends Phaser.Scene {
const fu = this.buildCardFace(PLAY_W, PLAY_H, def);
fu.setPosition(src.x, src.y).setScale(OPP_W / PLAY_W, OPP_H / PLAY_H);
this.animLayer.add(fu);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fu, x: tx, y: ty, scaleX: 1, scaleY: 1,
duration: 350, ease: 'Cubic.easeOut',
@ -1619,6 +1657,7 @@ export default class DominionGame extends Phaser.Scene {
targets: mini, x: tx, y: ty, duration: 320, ease: 'Cubic.easeOut',
onComplete: () => {
mini.destroy();
playSound(this, SFX.CARD_DEAL);
if (tgt?.face) tgt.face.setAlpha(1);
next();
},
@ -1713,7 +1752,7 @@ export default class DominionGame extends Phaser.Scene {
// ── Draw animations ───────────────────────────────────────────────────────────
_animDiscardThenDraw(discardedCards, drawnCards, newState) {
_animDiscardThenDraw(discardedCards, drawnCards, newState, shuffleInfo = {}) {
this._animating = true;
// Capture positions + face sprite refs BEFORE any render wipes the arrays.
@ -1731,6 +1770,14 @@ export default class DominionGame extends Phaser.Scene {
let idx = 0;
const animateNext = () => {
// Each call after the first means the previous card just landed in the discard pile.
if (idx > 0) {
this._discardAnimDisplay = (this._discardAnimDisplay ?? 0) + 1;
if (this._liveDiscardCountText?.active)
this._liveDiscardCountText.setText(`${this._discardAnimDisplay}`);
if (this._liveDiscardPile?.active) this._liveDiscardPile.setVisible(true);
if (this._liveDiscardBadge?.active) this._liveDiscardBadge.setVisible(true);
}
if (idx >= sources.length) {
// All cards have landed. Now render the intermediate/final state.
const p0 = newState.players[0];
@ -1747,8 +1794,13 @@ export default class DominionGame extends Phaser.Scene {
this.render();
if (drawnCards.length > 0) {
this._animDrawCards(drawnCards, newState);
this._animDrawCards(drawnCards, newState, {
...shuffleInfo,
oldDiscardCount: this._discardAnimDisplay ?? 0,
});
} else {
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this._suppressTurnUi = false;
this.updateTurnUi();
this._animating = false;
@ -1784,6 +1836,7 @@ export default class DominionGame extends Phaser.Scene {
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(tx, ty).setScale(0, 1);
this.animLayer.add(fdCard);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fdCard, scaleX: 1, duration: foldDuration, ease: 'Sine.easeOut',
onComplete: () => { fdCard.destroy(); onComplete(); },
@ -1939,23 +1992,69 @@ export default class DominionGame extends Phaser.Scene {
this._dragDropLabel = null;
}
_animDrawCards(drawnCards, newState) {
_animDrawCards(drawnCards, newState, shuffleInfo = {}) {
const { hadShuffle = false, oldDeckCount = 0 } = shuffleInfo;
this._animating = true;
this._animatingIids = new Set(drawnCards.map(c => c.iid));
this.gs = newState;
this.clearPrompt();
this.render();
this.render(); // uses _deckAnimDisplay / _discardAnimDisplay overrides
const updateDeckDisplay = () => {
if (this._liveDeckCountText?.active)
this._liveDeckCountText.setText(`${this._deckAnimDisplay}`);
const empty = this._deckAnimDisplay <= 0;
if (this._liveDeckPile?.active) this._liveDeckPile.setVisible(!empty);
if (this._liveDeckBadge?.active) this._liveDeckBadge.setVisible(!empty);
};
const doShuffle = (callback) => {
this.time.delayedCall(400, () => {
playSound(this, SFX.CARD_SHUFFLE);
const startVal = this._discardAnimDisplay ?? 0;
const counter = { v: startVal };
this.tweens.add({
targets: counter, v: 0, duration: 600, ease: 'Cubic.Out',
onUpdate: () => {
this._discardAnimDisplay = Math.round(counter.v);
if (this._liveDiscardCountText?.active)
this._liveDiscardCountText.setText(`${this._discardAnimDisplay}`);
},
onComplete: () => {
const postDeck = (drawnCards.length - drawnSoFar) + newState.players[0].deck.length;
this._deckAnimDisplay = postDeck;
updateDeckDisplay();
callback();
},
});
});
};
let idx = 0;
let drawnSoFar = 0;
let shuffleDone = false;
const animateNext = () => {
if (hadShuffle && !shuffleDone && drawnSoFar === oldDeckCount) {
shuffleDone = true;
doShuffle(animateNext);
return;
}
if (idx >= drawnCards.length) { this._finishDrawAnim(); return; }
const card = drawnCards[idx++];
const card = drawnCards[idx++];
const sprite = this.handSprites.find(s => s.iid === card.iid);
const tx = sprite?.x ?? CX;
const tx = sprite?.x ?? CX;
this._animateOneCard(card, tx, 968, () => {
drawnSoFar++;
playSound(this, SFX.CARD_DEAL);
const s2 = this.handSprites.find(s => s.iid === card.iid);
if (s2) s2.face.setAlpha(1);
this._animatingIids.delete(card.iid);
this._deckAnimDisplay = (this._deckAnimDisplay ?? 1) - 1;
updateDeckDisplay();
animateNext();
});
};
@ -1976,6 +2075,7 @@ export default class DominionGame extends Phaser.Scene {
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(DECK_PILE_X, DECK_PILE_Y).setScale(0, 1);
this.animLayer.add(fuCard);
playSound(this, SFX.CARD_SHOW);
this.tweens.add({
targets: fuCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
@ -1991,6 +2091,8 @@ export default class DominionGame extends Phaser.Scene {
}
_finishDrawAnim() {
this._deckAnimDisplay = null;
this._discardAnimDisplay = null;
this.handSprites.forEach(s => s.face.setAlpha(1));
this._animatingIids.clear();
this._animating = false;

View File

@ -51,6 +51,8 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');
this.load.audio('sfx-card-show', '/assets/fx/card-show.mp3');
this.load.audio('sfx-card-shuffle', '/assets/fx/card-shuffle.mp3');
this.load.audio('sfx-coins', '/assets/fx/coins.mp3');
this.load.audio('sfx-purchase', '/assets/fx/purchase.mp3');
this.load.audio('sfx-casino-blackjack', '/assets/fx/casino-blackjack.mp3');
this.load.audio('sfx-casino-lose', '/assets/fx/casino-lose.mp3');
this.load.audio('sfx-casino-win', '/assets/fx/casino-win.mp3');

View File

@ -4,6 +4,8 @@ export const SFX = {
CARD_PLACE: 'sfx-card-place',
CARD_SHOW: 'sfx-card-show',
CARD_SHUFFLE: 'sfx-card-shuffle',
COINS: 'sfx-coins',
PURCHASE: 'sfx-purchase',
CASINO_BLACKJACK: 'sfx-casino-blackjack',
CASINO_LOSE: 'sfx-casino-lose',
CASINO_WIN: 'sfx-casino-win',