```
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:
parent
fccd8722c8
commit
c264cd9419
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue