diff --git a/public/src/games/paigow/PaiGowPokerGame.js b/public/src/games/paigow/PaiGowPokerGame.js index f8432d0..5259234 100644 --- a/public/src/games/paigow/PaiGowPokerGame.js +++ b/public/src/games/paigow/PaiGowPokerGame.js @@ -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.hideHandSetPanel(); - this.startComparingPhase(); + 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,15 +1240,340 @@ export default class PaiGowPokerGame extends Phaser.Scene { // Resolve round this.gs = resolveRound(this.gs); this.renderAll(); - // Show comparison results seat by seat - this.showComparisons(() => { - this.animating = false; - this.showNextRoundPrompt(); + // 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) { const activePlayers = this.gs.players.filter(p => p.active); let done = 0;