feat(dominion): add card draw animations and visual deck pile

- Implement animation system for drawing cards from deck to hand
- Add input blocking and state queuing during animations to prevent race conditions
- Replace text-only deck counter with a visual card pile and count badge
- Support face-down card rendering for animation start positions
- Ensure all human actions respect animation state
```
This commit is contained in:
Brian Fertig 2026-05-26 19:03:27 -06:00
parent fccd8722c8
commit c264cd9419
3 changed files with 138 additions and 13 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -22,11 +22,14 @@ const SUPPLY_W = 100, SUPPLY_H = 144;
const HAND_W = 132, HAND_H = 190;
const PLAY_W = 78, PLAY_H = 112;
const DECK_PILE_X = 240, DECK_PILE_Y = 968;
const AI_STEP_MS = 420;
const AI_PENDING_MS = 520;
// Card-face colour styling by type.
function typeStyle(def) {
if (!def) return { art: 0x2a2a40, border: 0x554f38, band: 0x14110b };
if (isType(def.id, 'curse')) return { art: 0x6a3d8f, border: 0x9b59b6, band: 0x1c1226 };
if (isType(def.id, 'treasure')) {
const art = def.id === 'gold' ? 0xd4af37 : def.id === 'silver' ? 0xb8bcc2 : 0xb87333;
@ -62,6 +65,9 @@ export default class DominionGame extends Phaser.Scene {
this.hoverTimer = null;
this.promptObjs = [];
this.selection = new Set();
this._animating = false;
this._pendingAnimState = null;
this._animatingIids = new Set();
}
create() {
@ -70,6 +76,7 @@ export default class DominionGame extends Phaser.Scene {
this.buildPortraits();
this.buildHoverPopup();
this.buildButtons();
this.animLayer = this.add.container(0, 0).setDepth(D.hand + 50);
this.input.on('pointermove', (p) => {
this.lastPointer = { x: p.x, y: p.y };
@ -80,13 +87,22 @@ export default class DominionGame extends Phaser.Scene {
this.portraits.forEach((pt) => pt?.destroy?.());
});
this.gs = createInitialState({
const initialState = createInitialState({
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
playerCount: this.playerCount,
deckMode: this.deckMode,
});
// Show an empty hand first, then animate the deal.
const p0 = initialState.players[0];
const preDeal = {
...initialState,
players: initialState.players.map((p, i) =>
i === 0 ? { ...p, hand: [], deck: [...p0.hand, ...p0.deck] } : p
),
};
this.gs = preDeal;
this.render();
this.scheduleAdvance(300);
this._animDrawCards(p0.hand, initialState);
}
// ── Static scaffolding ─────────────────────────────────────────────────────
@ -279,6 +295,7 @@ export default class DominionGame extends Phaser.Scene {
const x = startX + i * step;
const face = this.buildCardFace(HAND_W, HAND_H, def);
face.setPosition(x, baseY).setDepth(D.hand + i);
if (this._animatingIids.has(c.iid)) face.setAlpha(0);
this.dynamicLayer.add(face);
const hit = this.add.rectangle(x, baseY, HAND_W, HAND_H, 0x000000, 0).setDepth(D.hand + i + 1);
this.dynamicLayer.add(hit);
@ -305,10 +322,24 @@ export default class DominionGame extends Phaser.Scene {
renderCounts() {
const gs = this.gs;
// Human deck/discard
this.dynamicLayer.add(this.add.text(1380, 968, `Deck\n${gs.players[0].deck.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.hand));
// 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);
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}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
this.dynamicLayer.add(dcBg);
this.dynamicLayer.add(dcText);
// Human discard
this.dynamicLayer.add(this.add.text(1500, 968, `Discard\n${gs.players[0].discard.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5).setDepth(D.hand));
@ -352,7 +383,7 @@ export default class DominionGame extends Phaser.Scene {
updateControls() {
const gs = this.gs;
const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver;
const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver && !this._animating;
const action = humanTurn && gs.phase === 'action';
const buy = humanTurn && gs.phase === 'buy';
const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure'));
@ -545,6 +576,22 @@ export default class DominionGame extends Phaser.Scene {
// ── Turn driver ───────────────────────────────────────────────────────────────
setState(s) {
if (this._animating) {
this._pendingAnimState = s;
return;
}
const prevHand = this.gs?.players[0].hand ?? [];
const newHand = s.players[0].hand;
const drawnCards = newHand.filter(c => !prevHand.find(h => h.iid === c.iid));
const deckChanged = s.players[0].deck.length !== (this.gs?.players[0].deck.length ?? -1)
|| s.players[0].discard.length !== (this.gs?.players[0].discard.length ?? -1);
if (drawnCards.length > 0 && deckChanged) {
this._animDrawCards(drawnCards, s);
return;
}
this.gs = s;
this.clearPrompt();
this.render();
@ -622,35 +669,35 @@ export default class DominionGame extends Phaser.Scene {
// ── Human actions (normal flow) ────────────────────────────────────────────
humanPlayAction(iid) {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.CARD_PLACE);
this.setState(playAction(this.gs, iid));
}
humanPlayTreasure(iid) {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this.setState(playTreasure(this.gs, iid));
}
humanPlayTreasures() {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.CARD_PLACE);
this.setState(playAllTreasures(this.gs));
}
humanBuy(id) {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
playSound(this, SFX.CARD_SHOW);
this.setState(buyCard(this.gs, id));
}
humanEndAction() {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this.setState(endActionPhase(this.gs));
}
humanEndTurn() {
if (this.gs.pending || this.gs.turn !== 0) return;
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this.setState(endTurn(this.gs));
}
@ -937,4 +984,82 @@ export default class DominionGame extends Phaser.Scene {
});
} catch (_) { /* offline / not signed in */ }
}
// ── Draw animations ───────────────────────────────────────────────────────────
_calcHandPositions(hand) {
const gap = Math.min(18, (1300 - hand.length * HAND_W) / Math.max(1, hand.length - 1));
const step = HAND_W + Math.max(-HAND_W * 0.45, gap);
const total = (hand.length - 1) * step + HAND_W;
const startX = CX - total / 2 + HAND_W / 2;
return hand.map((_, i) => startX + i * step);
}
_animDrawCards(drawnCards, newState) {
this._animating = true;
this._animatingIids = new Set(drawnCards.map(c => c.iid));
this.gs = newState;
this.clearPrompt();
this.render();
const hand = newState.players[0].hand;
const positions = this._calcHandPositions(hand);
let idx = 0;
const animateNext = () => {
if (idx >= drawnCards.length) { this._finishDrawAnim(); return; }
const card = drawnCards[idx++];
const handIdx = hand.findIndex(c => c.iid === card.iid);
const tx = positions[handIdx];
this._animateOneCard(card, tx, 968, () => {
const sprite = this.handSprites.find(s => s.iid === card.iid);
if (sprite) sprite.face.setAlpha(1);
this._animatingIids.delete(card.iid);
animateNext();
});
};
animateNext();
}
_animateOneCard(card, tx, ty, onComplete) {
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(DECK_PILE_X, DECK_PILE_Y);
this.animLayer.add(fdCard);
this.tweens.add({
targets: fdCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
onComplete: () => {
fdCard.destroy();
const def = getCard(card.id);
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(DECK_PILE_X, DECK_PILE_Y).setScale(0, 1);
this.animLayer.add(fuCard);
this.tweens.add({
targets: fuCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
onComplete: () => {
this.tweens.add({
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeOut',
onComplete: () => { fuCard.destroy(); onComplete(); },
});
},
});
},
});
}
_finishDrawAnim() {
this.handSprites.forEach(s => s.face.setAlpha(1));
this._animatingIids.clear();
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState;
this._pendingAnimState = null;
this.setState(s);
} else {
this.scheduleAdvance(10);
}
}
}