Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 1196d3ba5b refactor(dominion): redesign card hover popup to show card face
- Replace text-only tooltip with a visual card preview popup
- Add background graphics and proper layout for card face and rules text
- Update popup dimensions and positioning logic
2026-05-26 19:12:39 -06:00
Brian Fertig c264cd9419 ```
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
```
2026-05-26 19:03:27 -06:00
3 changed files with 170 additions and 35 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'));
@ -498,31 +529,41 @@ export default class DominionGame extends Phaser.Scene {
showHover(def) {
this.hoverPopup.removeAll(true);
const w = 360;
const pad = 16;
const title = this.add.text(0, 0, def.name, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5, 0);
const sub = this.add.text(0, 0, `${typeLine(def)} • Cost ${def.cost}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0);
const body = this.add.text(0, 0, def.text || '—', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
align: 'center', wordWrap: { width: w - pad * 2 },
}).setOrigin(0.5, 0);
title.setY(0);
sub.setY(title.height + 6);
body.setY(sub.y + sub.height + 12);
const h = body.y + body.height + pad;
const W_CARD = 264, H_CARD = 380;
const PAD = 20, GAP = 16;
const W_POP = W_CARD + PAD * 2;
const g = this.add.graphics();
g.fillStyle(0x0d1117, 0.97); g.fillRoundedRect(-w / 2, -pad, w, h + pad, 12);
g.lineStyle(2, COLORS.accent, 0.95); g.strokeRoundedRect(-w / 2, -pad, w, h + pad, 12);
const hasText = def.text && def.text.trim().length > 0;
const rulesText = hasText
? this.add.text(0, 0, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
align: 'center', wordWrap: { width: W_CARD },
}).setOrigin(0.5, 0)
: null;
this.hoverPopup.add([g, title, sub, body]);
this.hoverPopup.setData('w', w);
this.hoverPopup.setData('h', h + pad);
const totalH = PAD + H_CARD + (hasText ? GAP + rulesText.height + PAD : PAD);
const halfH = totalH / 2;
const bg = this.add.graphics();
bg.fillStyle(0x0d1117, 0.97);
bg.fillRoundedRect(-W_POP / 2, -halfH, W_POP, totalH, 14);
bg.lineStyle(2, COLORS.accent, 0.95);
bg.strokeRoundedRect(-W_POP / 2, -halfH, W_POP, totalH, 14);
const cardFace = this.buildCardFace(W_CARD, H_CARD, def);
this.hoverPopup.add(bg);
this.hoverPopup.add(cardFace);
cardFace.setPosition(0, -halfH + PAD + H_CARD / 2);
if (rulesText) {
this.hoverPopup.add(rulesText);
rulesText.setPosition(0, -halfH + PAD + H_CARD + GAP);
}
this.hoverPopup.setData('w', W_POP);
this.hoverPopup.setData('h', totalH);
this.hoverVisible = true;
this.hoverPopup.setVisible(true);
const p = this.lastPointer ?? { x: CX, y: GAME_HEIGHT / 2 };
@ -545,6 +586,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 +679,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 +994,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);
}
}
}