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 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 (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) {
|
_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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue