feat(dominion): enhance discard pile UI and improve card animation flow

- Add visual discard pile with card sprite and count badge
- Center action buttons and remove redundant Play Treasures button
- Track in-play cards for accurate animation source positions
- Refactor `setState` to handle discard, gain, and draw animations sequentially
- Add multi-phase discard animation (fly face-up, fold, unfold face-down)
- Add gain card animation to deck or discard pile
- Improve animation queue handling with `_animating` and `_pendingAnimState`
This commit is contained in:
Brian Fertig 2026-05-26 20:22:08 -06:00
parent e6db79afbb
commit 7e3c7d8e3d
3 changed files with 174 additions and 18 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@ -23,6 +23,7 @@ const HAND_W = 132, HAND_H = 190;
const PLAY_W = 78, PLAY_H = 112; const PLAY_W = 78, PLAY_H = 112;
const DECK_PILE_X = 240, DECK_PILE_Y = 968; const DECK_PILE_X = 240, DECK_PILE_Y = 968;
const DISCARD_PILE_X = 1704, DISCARD_PILE_Y = 968; // right edge aligns with opponent portrait center (x=1770)
const AI_STEP_MS = 420; const AI_STEP_MS = 420;
const AI_PENDING_MS = 520; const AI_PENDING_MS = 520;
@ -68,6 +69,7 @@ export default class DominionGame extends Phaser.Scene {
this._animating = false; this._animating = false;
this._pendingAnimState = null; this._pendingAnimState = null;
this._animatingIids = new Set(); this._animatingIids = new Set();
this.inPlaySprites = [];
} }
create() { create() {
@ -140,13 +142,10 @@ export default class DominionGame extends Phaser.Scene {
} }
buildButtons() { buildButtons() {
this.btnEndAction = new Button(this, 1640, 778, 'End Action Phase', () => this.humanEndAction(), { this.btnEndAction = new Button(this, CX, 830, 'End Action Phase', () => this.humanEndAction(), {
width: 240, height: 48, fontSize: 20, width: 240, height: 48, fontSize: 20,
}).setDepth(D.hud).setVisible(false); }).setDepth(D.hud).setVisible(false);
this.btnPlayTreasure = new Button(this, 1640, 778, 'Play Treasures', () => this.humanPlayTreasures(), { this.btnEndTurn = new Button(this, CX, 830, 'End Turn', () => this.humanEndTurn(), {
width: 240, height: 48, fontSize: 20, bg: COLORS.panel, bgHover: COLORS.gold,
}).setDepth(D.hud).setVisible(false);
this.btnEndTurn = new Button(this, 1640, 836, 'End Turn', () => this.humanEndTurn(), {
width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold, width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold,
textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
}).setDepth(D.hud).setVisible(false); }).setDepth(D.hud).setVisible(false);
@ -174,6 +173,7 @@ export default class DominionGame extends Phaser.Scene {
this.dynamicLayer = this.add.container(0, 0); this.dynamicLayer = this.add.container(0, 0);
this.handSprites = []; this.handSprites = [];
this.supplySprites = []; this.supplySprites = [];
this.inPlaySprites = [];
this.renderSupply(); this.renderSupply();
this.renderInPlay(); this.renderInPlay();
@ -270,6 +270,7 @@ export default class DominionGame extends Phaser.Scene {
.setDepth(D.inplay + 1); .setDepth(D.inplay + 1);
this.dynamicLayer.add(hit); this.dynamicLayer.add(hit);
this.attachHover(hit, def); this.attachHover(hit, def);
this.inPlaySprites.push({ iid: c.iid, id: c.id, x: startX + i * (PLAY_W + gap), y: 610, face });
}); });
const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, { const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
@ -339,10 +340,22 @@ export default class DominionGame extends Phaser.Scene {
this.dynamicLayer.add(dcBg); this.dynamicLayer.add(dcBg);
this.dynamicLayer.add(dcText); this.dynamicLayer.add(dcText);
// Human discard // Human discard pile — face-down card with count badge
this.dynamicLayer.add(this.add.text(1500, 968, `Discard\n${gs.players[0].discard.length}`, { const discardPile = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center', discardPile.setPosition(DISCARD_PILE_X, DISCARD_PILE_Y).setDepth(D.hand);
}).setOrigin(0.5).setDepth(D.hand)); 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}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hand + 1);
this.dynamicLayer.add(dpBg);
this.dynamicLayer.add(dpText);
this.opponents.forEach((opp, i) => { this.opponents.forEach((opp, i) => {
const seat = i + 1; const seat = i + 1;
@ -402,9 +415,7 @@ export default class DominionGame extends Phaser.Scene {
const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver && !this._animating; const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver && !this._animating;
const action = humanTurn && gs.phase === 'action'; const action = humanTurn && gs.phase === 'action';
const buy = humanTurn && gs.phase === 'buy'; const buy = humanTurn && gs.phase === 'buy';
const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure'));
this.btnEndAction.setVisible(action); this.btnEndAction.setVisible(action);
this.btnPlayTreasure.setVisible(buy && hasTreasure);
this.btnEndTurn.setVisible(buy); this.btnEndTurn.setVisible(buy);
} }
@ -602,16 +613,59 @@ export default class DominionGame extends Phaser.Scene {
// ── Turn driver ─────────────────────────────────────────────────────────────── // ── Turn driver ───────────────────────────────────────────────────────────────
setState(s) { setState(s) {
if (this._animating) { if (this._animating) { this._pendingAnimState = s; return; }
this._pendingAnimState = s;
const prev = this.gs;
if (!prev) {
this.gs = s; this.clearPrompt(); this.render(); this.scheduleAdvance(10); return;
}
const newLog = s.log.slice(prev.log.length);
const prevHand = prev.players[0].hand;
const prevInPlay = prev.players[0].inPlay;
const newHand = s.players[0].hand;
const newDiscard = s.players[0].discard;
// Cards that left hand and landed in discard (not played to inPlay)
const handDiscarded = prevHand.filter(c =>
!newHand.find(h => h.iid === c.iid) &&
!s.players[0].inPlay.find(ip => ip.iid === c.iid) &&
newDiscard.find(d => d.iid === c.iid)
);
// Cards that left inPlay and landed in discard (end-of-turn cleanup)
const inPlayDiscarded = prevInPlay.filter(c =>
!s.players[0].inPlay.find(ip => ip.iid === c.iid) &&
newDiscard.find(d => d.iid === c.iid)
);
const allDiscarded = [...inPlayDiscarded, ...handDiscarded];
// Cards newly drawn into hand from deck
const drawnCards = newHand.filter(c => !prevHand.find(h => h.iid === c.iid));
const deckChanged = s.players[0].deck.length !== prev.players[0].deck.length
|| s.players[0].discard.length !== prev.players[0].discard.length;
// Gain event for seat 0 targeting discard or deck (not hand)
const gainEvent = newLog.find(e => e.kind === 'gain' && e.seat === 0 && e.dest !== 'hand');
if (allDiscarded.length > 0) {
const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : [];
this._animDiscardThenDraw(allDiscarded, draws, s);
return; return;
} }
const prevHand = this.gs?.players[0].hand ?? []; if (gainEvent) {
const newHand = s.players[0].hand; const prevDeck = prev.players[0].deck;
const drawnCards = newHand.filter(c => !prevHand.find(h => h.iid === c.iid)); const gainedCard = gainEvent.dest === 'discard'
const deckChanged = s.players[0].deck.length !== (this.gs?.players[0].deck.length ?? -1) ? newDiscard.find(c => !prev.players[0].discard.find(d => d.iid === c.iid))
|| s.players[0].discard.length !== (this.gs?.players[0].discard.length ?? -1); : s.players[0].deck.find(c => !prevDeck.find(d => d.iid === c.iid));
if (gainedCard) {
const sp = this.supplySprites.find(sp => sp.id === gainEvent.id);
this._animGainCard(gainedCard, sp?.x ?? CX, sp?.y ?? 300, gainEvent.dest, s);
return;
}
}
if (drawnCards.length > 0 && deckChanged) { if (drawnCards.length > 0 && deckChanged) {
this._animDrawCards(drawnCards, s); this._animDrawCards(drawnCards, s);
@ -1013,6 +1067,108 @@ export default class DominionGame extends Phaser.Scene {
// ── Draw animations ─────────────────────────────────────────────────────────── // ── Draw animations ───────────────────────────────────────────────────────────
_animDiscardThenDraw(discardedCards, drawnCards, newState) {
this._animating = true;
// Capture positions + face sprite refs BEFORE any render wipes the arrays.
// The face ref lets us hide each card exactly when its animation begins.
const sources = discardedCards.map(card => {
const hs = this.handSprites.find(s => s.iid === card.iid);
if (hs) return { iid: card.iid, id: card.id, x: hs.x, y: hs.baseY, face: hs.face };
const ip = this.inPlaySprites.find(s => s.iid === card.iid);
if (ip) return { iid: card.iid, id: card.id, x: ip.x, y: ip.y, face: ip.face };
return { iid: card.iid, id: card.id, x: CX, y: 610, face: null };
});
// Don't render yet — keep existing sprites visible until each card starts animating.
this.clearPrompt();
let idx = 0;
const animateNext = () => {
if (idx >= sources.length) {
// All cards have landed. Now render the intermediate/final state.
const p0 = newState.players[0];
if (drawnCards.length > 0) {
this.gs = {
...newState,
players: newState.players.map((p, i) =>
i === 0 ? { ...p, hand: [], deck: [...drawnCards, ...p0.deck] } : p
),
};
} else {
this.gs = newState;
}
this.render();
if (drawnCards.length > 0) {
this._animDrawCards(drawnCards, newState);
} else {
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
} else {
this.scheduleAdvance(10);
}
}
return;
}
const src = sources[idx++];
// Hide the existing sprite at the moment the animation sprite takes over.
if (src.face) src.face.setAlpha(0);
this._animDiscardCard(src, DISCARD_PILE_X, DISCARD_PILE_Y, animateNext);
};
animateNext();
}
_animDiscardCard(src, tx, ty, onComplete) {
const def = getCard(src.id);
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(src.x, src.y);
this.animLayer.add(fuCard);
// Phase 1 (0500ms): fly face-up to target
this.tweens.add({
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeIn',
onComplete: () => {
// Phase 2 (500750ms): fold to edge
this.tweens.add({
targets: fuCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
onComplete: () => {
fuCard.destroy();
// Phase 3 (7501000ms): unfold face-down
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(tx, ty).setScale(0, 1);
this.animLayer.add(fdCard);
this.tweens.add({
targets: fdCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
onComplete: () => { fdCard.destroy(); onComplete(); },
});
},
});
},
});
}
_animGainCard(gainedCard, srcX, srcY, dest, newState) {
this._animating = true;
this.gs = newState;
this.clearPrompt();
this.render();
const tx = dest === 'deck' ? DECK_PILE_X : DISCARD_PILE_X;
const ty = dest === 'deck' ? DECK_PILE_Y : DISCARD_PILE_Y;
const src = { iid: gainedCard.iid, id: gainedCard.id, x: srcX, y: srcY };
this._animDiscardCard(src, tx, ty, () => {
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
} else {
this.scheduleAdvance(10);
}
});
}
_calcHandPositions(hand) { _calcHandPositions(hand) {
const gap = Math.min(18, (1300 - hand.length * HAND_W) / Math.max(1, hand.length - 1)); 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 step = HAND_W + Math.max(-HAND_W * 0.45, gap);