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:
parent
e6db79afbb
commit
7e3c7d8e3d
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 3.6 MiB |
|
|
@ -23,6 +23,7 @@ const HAND_W = 132, HAND_H = 190;
|
|||
const PLAY_W = 78, PLAY_H = 112;
|
||||
|
||||
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_PENDING_MS = 520;
|
||||
|
|
@ -68,6 +69,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this._animating = false;
|
||||
this._pendingAnimState = null;
|
||||
this._animatingIids = new Set();
|
||||
this.inPlaySprites = [];
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -140,13 +142,10 @@ export default class DominionGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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,
|
||||
}).setDepth(D.hud).setVisible(false);
|
||||
this.btnPlayTreasure = new Button(this, 1640, 778, 'Play Treasures', () => this.humanPlayTreasures(), {
|
||||
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(), {
|
||||
this.btnEndTurn = new Button(this, CX, 830, 'End Turn', () => this.humanEndTurn(), {
|
||||
width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold,
|
||||
textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
|
||||
}).setDepth(D.hud).setVisible(false);
|
||||
|
|
@ -174,6 +173,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.dynamicLayer = this.add.container(0, 0);
|
||||
this.handSprites = [];
|
||||
this.supplySprites = [];
|
||||
this.inPlaySprites = [];
|
||||
|
||||
this.renderSupply();
|
||||
this.renderInPlay();
|
||||
|
|
@ -270,6 +270,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
.setDepth(D.inplay + 1);
|
||||
this.dynamicLayer.add(hit);
|
||||
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`, {
|
||||
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(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));
|
||||
// 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);
|
||||
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) => {
|
||||
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 action = humanTurn && gs.phase === 'action';
|
||||
const buy = humanTurn && gs.phase === 'buy';
|
||||
const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure'));
|
||||
this.btnEndAction.setVisible(action);
|
||||
this.btnPlayTreasure.setVisible(buy && hasTreasure);
|
||||
this.btnEndTurn.setVisible(buy);
|
||||
}
|
||||
|
||||
|
|
@ -602,16 +613,59 @@ export default class DominionGame extends Phaser.Scene {
|
|||
// ── Turn driver ───────────────────────────────────────────────────────────────
|
||||
|
||||
setState(s) {
|
||||
if (this._animating) {
|
||||
this._pendingAnimState = s;
|
||||
if (this._animating) { this._pendingAnimState = s; return; }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (gainEvent) {
|
||||
const prevDeck = prev.players[0].deck;
|
||||
const gainedCard = gainEvent.dest === 'discard'
|
||||
? newDiscard.find(c => !prev.players[0].discard.find(d => d.iid === c.iid))
|
||||
: 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) {
|
||||
this._animDrawCards(drawnCards, s);
|
||||
|
|
@ -1013,6 +1067,108 @@ export default class DominionGame extends Phaser.Scene {
|
|||
|
||||
// ── 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 (0–500ms): fly face-up to target
|
||||
this.tweens.add({
|
||||
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
// Phase 2 (500–750ms): fold to edge
|
||||
this.tweens.add({
|
||||
targets: fuCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
|
||||
onComplete: () => {
|
||||
fuCard.destroy();
|
||||
// Phase 3 (750–1000ms): 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) {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue