feat(paigow): add animated hand-return and modal comparison sequence

- Animate player's split cards back to seat with split layout on submit
- Replace showComparisons with step-by-step modal comparison overlay
- Modal shows dealer cards, then each player's cards sequentially
- Display high/low hand arrows (red=dealer wins, green=player wins)
- Show verdict text (WIN/PUSH/LOSE) with sound effects and emotions
- Animate cards and result text back to seat between players
- Clear drag highlights when hiding hand-set panel
- Swap chips/cards depth values in z-ordering constant
This commit is contained in:
Brian Fertig 2026-06-23 21:39:02 -06:00
parent 2f4a89fd3f
commit e0b48a6285
1 changed files with 379 additions and 7 deletions

View File

@ -45,7 +45,7 @@ const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
const CHIP_AMOUNTS = [5, 25, 50, 100];
const RESULT_COLORS = { win: '#5cb85c', lose: '#e05c5c', push: '#8a94a6', foul: '#e05c5c' };
const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, panel: 25, modal: 50 };
const D = { bg: -1, table: 0, chips: 10, cards: 20, ui: 30, panel: 25, modal: 50 };
// ─── Hand-setting panel geometry ──────────────────────────────────────────────
const PANEL_H = 390; // panel height
@ -463,6 +463,7 @@ export default class PaiGowPokerGame extends Phaser.Scene {
hideHandSetPanel() {
this.panelVisible = false;
this._clearDragHighlights();
for (const o of this.setPanelGroup) o.setVisible(false);
for (const c of this.setCardConts) c.destroy();
this.setCardConts = [];
@ -758,8 +759,50 @@ export default class PaiGowPokerGame extends Phaser.Scene {
const highHand = this.humanHighSlots.map(i => hand[i]);
const lowHand = this.humanLowSlots.map(i => hand[i]);
this.gs = applyHumanSplit(this.gs, highHand, lowHand);
this.animating = true;
this._animateHandReturnToSeat(() => {
// Clear panel card containers before hideHandSetPanel destroys them
for (const c of this.setCardConts) c.destroy();
this.setCardConts = [];
this.hideHandSetPanel();
this.startComparingPhase();
});
}
_animateHandReturnToSeat(onComplete) {
const pos = SEAT_POS[0];
// Split display mirrors renderSeat's comparing layout
const highCX = pos.x - 130;
const lowCX = pos.x + 130;
const splitTarget = (cx, count, slotIdx) => {
const totalW = (count - 1) * CARD_SPREAD_SPLIT;
return { x: cx - totalW / 2 + slotIdx * CARD_SPREAD_SPLIT, y: pos.y };
};
// Build ordered list: high hand cards first, then low hand
const moves = [];
this.humanHighSlots.forEach((cardIdx, slotIdx) => {
if (cardIdx !== null) moves.push({ cont: this.setCardConts[cardIdx], ...splitTarget(highCX, 5, slotIdx) });
});
this.humanLowSlots.forEach((cardIdx, slotIdx) => {
if (cardIdx !== null) moves.push({ cont: this.setCardConts[cardIdx], ...splitTarget(lowCX, 2, slotIdx) });
});
if (!moves.length) { onComplete(); return; }
let done = 0;
moves.forEach(({ cont, x, y }, i) => {
cont.setDepth(D.panel + 10);
this.tweens.add({
targets: cont,
x, y,
duration: 320,
delay: i * 45,
ease: 'Power2.Out',
onComplete: () => { if (++done === moves.length) onComplete(); },
});
});
}
// ── Chip balance ──────────────────────────────────────────────────────────
@ -1184,6 +1227,10 @@ export default class PaiGowPokerGame extends Phaser.Scene {
startComparingPhase() {
this.animating = true;
// Advance phase so renderSeat renders everyone (including the human) in split layout,
// preserving the animated split the player's cards just landed in.
this.gs = { ...this.gs, phase: 'comparing' };
// Briefly reveal all AI splits (1.5s), then reveal dealer
this.renderAll(); // shows AI split layouts
@ -1193,13 +1240,338 @@ export default class PaiGowPokerGame extends Phaser.Scene {
// Resolve round
this.gs = resolveRound(this.gs);
this.renderAll();
// Show comparison results seat by seat
this.showComparisons(() => {
// Modal comparison sequence, then chip animations
this.startModalComparisons(() => {
this.animateAllChips(() => {
this.updateServerChips();
this.time.delayedCall(800, () => {
this.animating = false;
this.showNextRoundPrompt();
});
});
});
});
});
}
// ── Modal comparison ─────────────────────────────────────────────────────────
startModalComparisons(onComplete) {
const MODAL_W = 1100;
const MODAL_H = 530;
const MODAL_X = CX - MODAL_W / 2; // 410
const MODAL_TOP = 150;
const DEALER_ROW_Y = 262;
const PLAYER_ROW_Y = 510;
const ARROW_Y = 393;
const HIGH_CX = 650;
const LOW_CX = 1270;
const RESULT_CENTER_Y = MODAL_TOP + Math.round(MODAL_H / 2); // 415
// slot x for card i in a n-card spread (CARD_SPREAD_SPLIT=10)
const slotX = (cx, count, i) => cx - ((count - 1) * CARD_SPREAD_SPLIT) / 2 + i * CARD_SPREAD_SPLIT;
this._modal = { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y,
ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, slotX };
this._buildCompareModal();
// Fade in frame elements (arrows/rank text/result have their own reveal animations)
const frameObjs = this._modal.frameObjects;
for (const o of frameObjs) o.setAlpha(0);
this.tweens.add({
targets: frameObjs, alpha: 1, duration: 250, ease: 'Linear',
onComplete: () => {
this._animateDealerIntoModal(() => {
const players = PLAY_ORDER
.map(seat => this.gs.players[seat])
.filter(p => p?.active);
const processNext = idx => {
if (idx >= players.length) {
this._destroyModal();
onComplete();
return;
}
const isLast = idx === players.length - 1;
this._comparePlayerInModal(players[idx], isLast, () => processNext(idx + 1));
};
processNext(0);
});
},
});
}
_buildCompareModal() {
const { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y,
HIGH_CX, LOW_CX, RESULT_CENTER_Y } = this._modal;
const LABEL_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#d4a017' };
const RANK_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#c0c0c0' };
const HEADER_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#6a7484' };
const bg = this.add.graphics().setDepth(D.modal);
bg.fillStyle(0x0a0f1a, 0.94);
bg.fillRoundedRect(MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, 12);
bg.lineStyle(2, 0xd4a017, 0.8);
bg.strokeRoundedRect(MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, 12);
const sep = this.add.graphics().setDepth(D.modal + 1);
sep.lineStyle(1, 0x3a4454, 0.6);
sep.lineBetween(CX, MODAL_TOP + 20, CX, MODAL_TOP + MODAL_H - 20);
const dealerLabel = this.add.text(CX, MODAL_TOP + 25, 'DEALER', LABEL_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const hiLabel = this.add.text(HIGH_CX, MODAL_TOP + 28, 'HIGH HAND', HEADER_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const loLabel = this.add.text(LOW_CX, MODAL_TOP + 28, 'LOW HAND', HEADER_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const dealerRankHi = this.add.text(HIGH_CX, DEALER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const dealerRankLo = this.add.text(LOW_CX, DEALER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const playerLabel = this.add.text(CX, PLAYER_ROW_Y - CARD_H / 2 - 22, '', LABEL_STYLE)
.setOrigin(0.5, 1).setDepth(D.modal + 1);
const playerRankHi = this.add.text(HIGH_CX, PLAYER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const playerRankLo = this.add.text(LOW_CX, PLAYER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const arrowHiG = this.add.graphics().setDepth(D.modal + 2).setAlpha(0);
const arrowLoG = this.add.graphics().setDepth(D.modal + 2).setAlpha(0);
// resultTxt lives at modal center and flies to portrait after each player
const resultTxt = this.add.text(CX, RESULT_CENTER_Y, '', {
fontFamily: '"Julius Sans One"', fontSize: '72px', fontStyle: 'bold', color: '#f5d020',
}).setOrigin(0.5).setDepth(D.modal + 3).setAlpha(0).setScale(0);
Object.assign(this._modal, {
bg, sep, dealerLabel, hiLabel, loLabel,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt,
// frameObjects: modal chrome that hides/shows between players
frameObjects: [bg, sep, dealerLabel, hiLabel, loLabel, dealerRankHi, dealerRankLo],
// dynamicObjects: per-player state reset each round
dynamicObjects: [playerLabel, playerRankHi, playerRankLo, arrowHiG, arrowLoG],
// allObjects: everything for final destroy
allObjects: [bg, sep, dealerLabel, hiLabel, loLabel,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt],
});
}
_animateDealerIntoModal(onComplete) {
const { DEALER_ROW_Y, HIGH_CX, LOW_CX, slotX } = this._modal;
const cards = this.dealerCardGraphics;
const dealer = this.gs.dealer;
const hiCount = dealer.highHand.length; // 5
const loCount = dealer.lowHand.length; // 2
this._dealerCardOrigins = cards.map(c => ({ x: c.x, y: c.y }));
for (const c of cards) c.setDepth(D.modal + 5);
let done = 0;
const total = cards.length;
cards.forEach((card, i) => {
const isHi = i < hiCount;
const idx = isHi ? i : i - hiCount;
const tx = isHi ? slotX(HIGH_CX, hiCount, idx) : slotX(LOW_CX, loCount, idx);
this.tweens.add({
targets: card, x: tx, y: DEALER_ROW_Y,
duration: 350, delay: i * 50, ease: 'Power2.Out',
onComplete: () => {
done++;
if (done === total) {
const { dealerRankHi, dealerRankLo } = this._modal;
dealerRankHi.setText(handName5(dealer.highEval));
dealerRankLo.setText(dealer.lowEval?.name ?? '');
onComplete();
}
},
});
});
}
_drawModalArrow(g, x, y, dealerWins) {
g.clear();
if (dealerWins) {
g.fillStyle(0xff3a3a, 1); // bright red
g.fillTriangle(x, y - 18, x - 14, y + 14, x + 14, y + 14); // apex up
} else {
g.fillStyle(0x00e676, 1); // bright green
g.fillTriangle(x, y + 18, x - 14, y - 14, x + 14, y - 14); // apex down
}
}
_comparePlayerInModal(player, isLast, onComplete) {
const { PLAYER_ROW_Y, HIGH_CX, LOW_CX, slotX,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt, RESULT_CENTER_Y } = this._modal;
const dealer = this.gs.dealer;
const cards = this.seatCardGraphics[player.seat] ?? [];
const hiCount = player.highHand.length; // 5
const loCount = player.lowHand.length; // 2
// Reset per-player UI
arrowHiG.setAlpha(0).setScale(1);
arrowLoG.setAlpha(0).setScale(1);
resultTxt.setAlpha(0).setScale(0).setText('').setPosition(CX, RESULT_CENTER_Y);
playerRankHi.setText('').setColor('#c0c0c0').setAlpha(1);
playerRankLo.setText('').setColor('#c0c0c0').setAlpha(1);
dealerRankHi.setColor('#c0c0c0');
dealerRankLo.setColor('#c0c0c0');
playerLabel.setText(player.name.toUpperCase()).setColor('#d4a017').setAlpha(1);
this._playerCardOrigins = cards.map(c => ({ x: c.x, y: c.y }));
for (const c of cards) c.setDepth(D.modal + 5);
let done = 0;
const total = cards.length;
if (total === 0) {
this._playerCardOrigins = [];
this._afterCardsIn(player, isLast, onComplete);
return;
}
cards.forEach((card, i) => {
const isHi = i < hiCount;
const idx = isHi ? i : i - hiCount;
const tx = isHi ? slotX(HIGH_CX, hiCount, idx) : slotX(LOW_CX, loCount, idx);
this.tweens.add({
targets: card, x: tx, y: PLAYER_ROW_Y,
duration: 280, delay: i * 45, ease: 'Power2.Out',
onComplete: () => {
done++;
if (done === total) {
playerRankHi.setText(handName5(player.highEval));
playerRankLo.setText(player.lowEval?.name ?? '');
this._afterCardsIn(player, isLast, onComplete);
}
},
});
});
}
_afterCardsIn(player, isLast, onComplete) {
const dealer = this.gs.dealer;
const { ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, arrowHiG, arrowLoG, resultTxt,
dealerRankHi, dealerRankLo, playerRankHi, playerRankLo } = this._modal;
const hiWinDealer = player.isFoul || compare5Card(player.highEval, dealer.highEval) <= 0;
const loWinDealer = player.isFoul || compare2Card(player.lowEval, dealer.lowEval) <= 0;
// 500ms pause then HIGH arrow
this.time.delayedCall(500, () => {
this._drawModalArrow(arrowHiG, HIGH_CX, ARROW_Y, hiWinDealer);
arrowHiG.setAlpha(0).setScale(0);
this.tweens.add({ targets: arrowHiG, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut' });
if (hiWinDealer) { dealerRankHi.setColor('#ffe033'); playerRankHi.setColor('#ff3a3a'); }
else { dealerRankHi.setColor('#ff3a3a'); playerRankHi.setColor('#ffe033'); }
// 1000ms pause then LOW arrow + verdict
this.time.delayedCall(1000, () => {
this._drawModalArrow(arrowLoG, LOW_CX, ARROW_Y, loWinDealer);
arrowLoG.setAlpha(0).setScale(0);
this.tweens.add({ targets: arrowLoG, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut' });
if (loWinDealer) { dealerRankLo.setColor('#ffe033'); playerRankLo.setColor('#ff3a3a'); }
else { dealerRankLo.setColor('#ff3a3a'); playerRankLo.setColor('#ffe033'); }
// 500ms pause after low arrow before verdict appears
this.time.delayedCall(500, () => {
let verdict, color;
if (player.result === 'win') { verdict = 'WIN'; color = '#ffe033'; }
else if (player.result === 'push') { verdict = 'PUSH'; color = '#a0aabb'; }
else { verdict = 'LOSE'; color = '#ff3a3a'; }
resultTxt.setText(verdict).setColor(color).setAlpha(0).setScale(0)
.setPosition(CX, RESULT_CENTER_Y);
this.tweens.add({ targets: resultTxt, alpha: 1, scaleX: 1, scaleY: 1,
duration: 250, ease: 'Back.easeOut' });
const sfx = player.result === 'win' ? SFX.CASINO_WIN
: player.result === 'push' ? SFX.SQUASH
: SFX.CASINO_LOSE;
playSound(this, sfx);
if (player.seat > 0) {
const emotion = player.result === 'win' ? 'happy' : 'upset';
this.portraits[player.seat]?.playEmotion?.(emotion);
}
});
// 500ms (low arrow) + 1200ms (verdict visible) = 1700ms before fly-back
this.time.delayedCall(1700, () => {
const cards = this.seatCardGraphics[player.seat] ?? [];
const origins = this._playerCardOrigins;
const pos = SEAT_POS[player.seat];
// Fade out modal frame + dynamic UI + dealer cards together (resultTxt stays)
const fadeTargets = [
...this._modal.frameObjects,
...this._modal.dynamicObjects,
...this.dealerCardGraphics,
];
this.tweens.add({ targets: fadeTargets, alpha: 0, duration: 250, ease: 'Linear' });
// Fly player cards back to seat simultaneously
let done = 0;
const afterFly = () => {
// Once cards are home, fly resultTxt to player's portrait
const destX = pos.portraitX ?? pos.x;
const destY = pos.portraitY ?? pos.y;
this.tweens.add({
targets: resultTxt, x: destX, y: destY,
scaleX: 0.45, scaleY: 0.45,
duration: 1500, ease: 'Power2.In',
onComplete: () => {
this.tweens.add({ targets: resultTxt, alpha: 0, duration: 200, ease: 'Linear',
onComplete: () => {
if (isLast) {
// No need to reopen — _destroyModal follows in processNext
onComplete();
return;
}
// Reopen modal frame + dealer cards for next player
const reshow = [...this._modal.frameObjects, ...this.dealerCardGraphics];
for (const o of reshow) o.setAlpha(0);
this.tweens.add({ targets: reshow, alpha: 1, duration: 250, ease: 'Linear',
onComplete: () => onComplete() });
},
});
},
});
};
if (!cards.length) {
afterFly();
return;
}
cards.forEach((card, i) => {
const { x, y } = origins[i] ?? { x: card.x, y: card.y };
this.tweens.add({
targets: card, x, y, duration: 260, delay: i * 30, ease: 'Power2.In',
onComplete: () => {
done++;
if (done === cards.length) {
for (const c of cards) c.setDepth(D.cards);
afterFly();
}
},
});
});
});
});
});
}
_destroyModal() {
if (!this._modal) return;
for (const o of this._modal.allObjects) { if (o?.destroy) o.destroy(); }
// Dealer card containers were faded out in the last player's close; destroy them so
// renderAll can recreate them cleanly for the next round.
for (const c of this.dealerCardGraphics) { if (c?.destroy) c.destroy(); }
this.dealerCardGraphics = [];
delete this._modal;
delete this._dealerCardOrigins;
delete this._playerCardOrigins;
}
showComparisons(onComplete) {