import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { Modal } from '../../ui/Modal.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { api } from '../../services/api.js'; import { playSound, playChipBet, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { buildShoe, handValue, isBlackjack, isBust, canDouble, canSplit, createInitialState, prepareRound, applyBet, dealAllCards, applyInsurance, resolveInsurance, applyHit, applyStand, applyDouble, applySplit, runDealerTurn, resolveRound, isPlayerDone, } from './BlackjackLogic.js'; import { chooseBet, chooseAction, chooseInsurance } from './BlackjackAI.js'; // ─── Layout ─────────────────────────────────────────────────────────────────── const CX = GAME_WIDTH / 2; const CARD_W = 90; const CARD_H = 126; const CARD_R = 8; const CARD_SPREAD = 28; // horizontal offset between stacked cards const DEALER_X = CX; const DEALER_Y = 420; // ~1/3 down the table ellipse (top ≈190, bottom ≈890) // 7 seats fanned around the oval: seat 0 (human) anchored bottom-centre, // 6 opponents fanned 3-per-side (filled outward-alternating so the table stays // balanced when fewer than 6 opponents are chosen). const SEAT_POS = [ { x: CX, y: 782, portraitR: 72, portraitX: CX - 230, portraitY: 860, betX: 1110, betY: 770 }, // 0 Human (bottom centre) { x: 1380, y: 710, portraitR: 58, portraitX: 1600, portraitY: 800 }, // 1 right-bottom { x: 540, y: 710, portraitR: 58, portraitX: 320, portraitY: 800 }, // 2 left-bottom { x: 1560, y: 588, portraitR: 58, portraitX: 1805, portraitY: 610 }, // 3 right-mid { x: 360, y: 588, portraitR: 58, portraitX: 115, portraitY: 610 }, // 4 left-mid { x: 1470, y: 410, portraitR: 58, portraitX: 1705, portraitY: 335, labelDX: 10 }, // 5 right-top { x: 450, y: 410, portraitR: 58, portraitX: 215, portraitY: 335, labelDX: -10 }, // 6 left-top ]; // Turn order: start at the top-right seat and proceed clockwise around the table. const PLAY_ORDER = [5, 3, 1, 0, 2, 4, 6]; // Large per-action callout shown between a player and their cards. const ACTION_LABELS = { hit: 'Hit', stand: 'Stand', double: 'Double', split: 'Split' }; const ACTION_COLORS = { hit: '#3cc6c0', stand: '#f5d020', double: '#ff8c00', split: '#b07cd6' }; const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c }; const CHIP_TEXT_COLORS = { 5: '#ffffff', 25: '#ffffff', 50: '#ffffff', 100: '#ffffff' }; const CHIP_AMOUNTS = [5, 25, 50, 100]; const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, modal: 50 }; const RESULT_COLORS = { win: '#5cb85c', lose: '#e05c5c', push: '#8a94a6', blackjack: '#f5d020', bust: '#e05c5c', }; // ─── Scene ──────────────────────────────────────────────────────────────────── export default class BlackjackGame extends Phaser.Scene { constructor() { super('BlackjackGame'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.cardBack = data.cardBack ?? null; } async create() { new MusicPlayer(this, this.cache.json.get('music').tracks); this.shoe = buildShoe(); this.gs = null; this.animating = false; this.pendingBet = 0; this.portraits = []; this.cardGraphics = {}; // seat → array of Phaser.Container this.dealerCardGraphics = []; this.betGraphics = {}; // seat → Phaser.Container this.bustedSeats = new Set(); this.actionBtns = []; this.bettingUIGroup = []; this.chipBtnGraphics = []; this.betDisplayText = null; this.balanceText = null; this.scoreTxts = {}; this.dealerScoreTxt = null; this.statusBadges = {}; this.nameTxts = {}; this.chipTxts = {}; this.bettingPromptGroup = []; this.bettingPromptTimer = null; this.chipPulseTweens = []; this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg); this.buildPlayfield(); this.buildTable(); this.buildTableMarkings(); this.buildDealerArea(); this.buildSeats(); this.buildBettingUI(); new Button(this, 80, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 140, fontSize: 20, }); await this.loadPlayerChips(); this.initGame(); } // ── Playfield background ───────────────────────────────────────────────── buildPlayfield() { const pf = this.playfield; if (pf?.key && this.textures.exists(pf.key)) { this.add.image(CX, GAME_HEIGHT / 2, pf.key) .setDisplaySize(GAME_WIDTH, GAME_HEIGHT) .setDepth(D.bg + 1); } } // ── Table ───────────────────────────────────────────────────────────────── buildTable() { const g = this.add.graphics().setDepth(D.table); g.fillStyle(0x1a5c2a, 1); g.fillEllipse(CX, 540, 1500, 700); g.lineStyle(8, 0x2e7d32, 1); g.strokeEllipse(CX, 540, 1500, 700); // Inner stripe g.lineStyle(3, 0x4caf50, 0.4); g.strokeEllipse(CX, 540, 1440, 640); } // ── Traditional felt markings ─────────────────────────────────────────────── // White card-deal outlines + bet circles for every seat (drawn for all 7 so // unoccupied seats still show open spots), plus curved felt lettering. buildTableMarkings() { const g = this.add.graphics().setDepth(D.table + 1); for (let seat = 0; seat < SEAT_POS.length; seat++) { const pos = SEAT_POS[seat]; g.lineStyle(2, 0xffffff, 0.5); g.strokeRoundedRect( pos.x - CARD_W / 2 - 4, pos.y - CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, CARD_R + 2, ); const { x: bx, y: by } = this.betCirclePos(seat); g.lineStyle(2, 0xffffff, 0.55); g.strokeCircle(bx, by, 30); } // Curved insurance line + label (concentric with the text arc) const insBaseY = 512, insRadius = 820; const insCx = CX, insCy = insBaseY - insRadius; const insSpan = 0.34; g.lineStyle(3, 0xffffff, 0.45); g.beginPath(); g.arc(insCx, insCy, insRadius + 16, Math.PI / 2 - insSpan, Math.PI / 2 + insSpan, false); g.strokePath(); this.drawArcText('INSURANCE PAYS 2 TO 1', insCx, insBaseY, insRadius, { fontSize: 20, color: COLORS.textHex, advanceFactor: 0.95, }); this.drawArcText('BLACKJACK PAYS 3 TO 2', CX, 610, 760, { fontSize: 30, color: COLORS.goldHex, bold: true, advanceFactor: 0.98, }); this.drawArcText('DEALER MUST STAND ON ALL 17s · DRAW TO 16', CX, 668, 720, { fontSize: 19, color: COLORS.mutedHex, advanceFactor: 0.86, }); } // Draws a string along a downward-bulging arc, glyph by glyph (Phaser has no // native curved text). (centerX, baseY) is where the middle of the text sits; // the line curves upward toward both ends. drawArcText(text, centerX, baseY, radius, opts = {}) { const fontSize = opts.fontSize ?? 24; const anglePer = ((opts.advanceFactor ?? 0.92) * fontSize) / radius; const cy = baseY - radius; // circle centre, above the text const start = -anglePer * (text.length - 1) / 2; const style = { fontFamily: opts.fontFamily ?? '"Julius Sans One"', fontSize: `${fontSize}px`, color: opts.color ?? COLORS.textHex, ...(opts.bold ? { fontStyle: 'bold' } : {}), }; const depth = opts.depth ?? (D.table + 1); for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === ' ') continue; const a = start + i * anglePer; // offset from straight-down this.add.text(centerX + radius * Math.sin(a), cy + radius * Math.cos(a), ch, style) .setOrigin(0.5) .setRotation(-a) .setDepth(depth); } } // ── Dealer area ─────────────────────────────────────────────────────────── buildDealerArea() { this.add.text(CX, 60, 'Blackjack', { fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); this.dealerScoreTxt = this.add.text(CX, DEALER_Y - CARD_H / 2 - 22, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); } // ── Seat labels & portraits ─────────────────────────────────────────────── buildSeats() { for (let seat = 0; seat < SEAT_POS.length; seat++) { const pos = SEAT_POS[seat]; const player = seat === 0 ? { name: 'You', isHuman: true, active: true } : { name: this.opponents[seat - 1]?.name ?? '', isHuman: false, active: seat <= this.opponents.length }; if (!player.active) continue; // Portrait centre const px = pos.portraitX ?? pos.x; const py = pos.portraitY ?? (seat === 0 ? pos.y : pos.y - CARD_H / 2 - pos.portraitR - 68); // Name + bankroll stacked, centred under the portrait const labelX = px + (pos.labelDX ?? 0); const nameY = py + pos.portraitR + 18; const chipY = nameY + 24; this.nameTxts[seat] = this.add.text(labelX, nameY, player.name, { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); this.chipTxts[seat] = this.add.text(labelX, chipY, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.ui); // Semi-transparent backing behind both text lines, depth below portrait images/videos { const maxTW = Math.max(this.nameTxts[seat].width, 130); const rectW = maxTW + 20; const rectH = (chipY - nameY) + 36; const bg = this.add.graphics().setDepth(D.ui - 1); bg.fillStyle(0x000000, 0.60); bg.fillRoundedRect(labelX - rectW / 2, (nameY + chipY) / 2 - rectH / 2, rectW, rectH, 6); } this.scoreTxts[seat] = this.add.text(pos.x, pos.y - CARD_H / 2 - 11, '', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); // Portraits if (seat === 0) { this.portraits[seat] = createPlayerPortrait(this, px, py, pos.portraitR, D.ui, 'BlackjackGame'); } else { this.portraits[seat] = createOpponentPortrait(this, this.opponents[seat - 1], px, py, pos.portraitR, D.ui); } } } // ── Betting UI (human only) ─────────────────────────────────────────────── buildBettingUI() { const y = GAME_HEIGHT - 80; const cx = CX + 60; // shift right slightly to avoid portrait // Solid window behind all the betting controls (shown between rounds) const panel = this.add.graphics().setDepth(D.ui); panel.fillStyle(0x000000, 1); panel.fillRoundedRect(cx - 174, y - 69, 726, 116, 18); panel.lineStyle(3, COLORS.accent, 1); panel.strokeRoundedRect(cx - 174, y - 69, 726, 116, 18); this.bettingUIGroup.push(panel); // Chip buttons CHIP_AMOUNTS.forEach((amt, i) => { const bx = cx - 120 + i * 80; const container = this.add.container(bx, y).setDepth(D.ui + 1); const g = this.add.graphics(); g.lineStyle(3, 0xffffff, 0.4); g.strokeCircle(0, 0, 28); g.fillStyle(CHIP_COLORS[amt], 1); g.fillCircle(0, 0, 28); container.add(g); container.setInteractive(new Phaser.Geom.Circle(0, 0, 28), Phaser.Geom.Circle.Contains); container.on('pointerdown', () => this.onChipClick(amt)); container.on('pointerover', () => container.setAlpha(0.8)); container.on('pointerout', () => container.setAlpha(1)); const t = this.add.text(bx, y, `$${amt}`, { fontFamily: '"Julius Sans One"', fontSize: '13px', color: CHIP_TEXT_COLORS[amt], fontStyle: 'bold', }).setOrigin(0.5).setDepth(D.ui + 2); this.bettingUIGroup.push(g, t); this.chipBtnGraphics.push(g); }); this.betDisplayText = this.add.text(cx + 170, y, 'Bet: $0', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(D.ui + 1); this.balanceText = this.add.text(cx + 170, y - 32, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(0, 0.5).setDepth(D.ui + 1); const clearBtn = new Button(this, cx + 360, y, 'Clear', () => this.onClearBet(), { width: 100, height: 50, fontSize: 18, variant: 'ghost', }); this.dealBtn = new Button(this, cx + 470, y, 'Deal', () => this.onDealClick(), { width: 110, height: 50, fontSize: 20, }); this.dealBtn.setEnabled(false); // Keep the buttons above the betting window panel clearBtn.setDepth(D.ui + 1); this.dealBtn.setDepth(D.ui + 1); this.bettingUIGroup.push(this.betDisplayText, this.balanceText, clearBtn, this.dealBtn); this.hideBettingUI(); } showBettingUI() { for (const o of this.bettingUIGroup) o.setVisible?.(true) || (o.visible = true); this.chipBtnGraphics.forEach(g => g.setDepth(D.ui + 1)); this.startBettingPrompts(); } hideBettingUI() { for (const o of this.bettingUIGroup) o.setVisible?.(false) || (o.visible = false); this.hideBettingPrompts(); } startBettingPrompts() { this.hideBettingPrompts(); // Wait 5 seconds before starting any animations this.bettingPromptTimer = this.time.delayedCall(5000, () => { if (!this.scene.isActive('BlackjackGame')) return; if (this.pendingBet > 0) return; // Don't start if already betting // Start subtle radius pulsing animation this.chipBtnGraphics.forEach((chip, index) => { const delay = index * 200; const tween = this.tweens.add({ targets: chip, scaleX: 1.25, scaleY: 1.25, duration: 1200, ease: 'Sine.easeInOut', yoyo: true, repeat: -1, delay: delay, }); this.chipPulseTweens.push(tween); }); // Show prompt text this.showBettingPrompt(); }); } showBettingPrompt() { if (this.pendingBet > 0) return; // Don't show if already betting const y = GAME_HEIGHT - 160; const cx = CX; // Create semi-transparent background const bg = this.add.rectangle(cx + 100, y, 420, 60, 0x000000, 0.7) .setOrigin(0.5, 0.5) .setDepth(D.ui + 10) .setAlpha(0); // Create prompt text const text = this.add.text(cx + 100, y, 'Choose an amount to bet and click Deal to begin', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffffff', align: 'center', }) .setOrigin(0.5, 0.5) .setDepth(D.ui + 11) .setAlpha(0); this.bettingPromptGroup = [bg, text]; // Fade in this.tweens.add({ targets: bg, alpha: 0.7, duration: 300, ease: 'Power2', }); this.tweens.add({ targets: text, alpha: 1, duration: 300, ease: 'Power2', delay: 100, }); } hideBettingPrompts() { // Fade out prompt if visible if (this.bettingPromptGroup && this.bettingPromptGroup.length > 0) { this.tweens.add({ targets: this.bettingPromptGroup, alpha: 0, duration: 200, ease: 'Power2', onComplete: () => { this.bettingPromptGroup.forEach(obj => obj.destroy()); this.bettingPromptGroup = []; } }); } else if (this.bettingPromptGroup.length > 0) { this.bettingPromptGroup.forEach(obj => obj.destroy()); this.bettingPromptGroup = []; } // Stop chip pulsing this.chipPulseTweens.forEach(tween => tween.destroy()); this.chipPulseTweens = []; this.chipBtnGraphics.forEach(chip => { chip.setScale(1, 1); chip.setAlpha(1); }); // Clear timer if (this.bettingPromptTimer) { this.bettingPromptTimer.remove(); this.bettingPromptTimer = null; } } startBettingPromptsIfNoBet() { // Only start prompts if no bet has been placed yet if (this.pendingBet === 0) { this.startBettingPrompts(); } } hideBettingUI() { for (const o of this.bettingUIGroup) { o.setVisible?.(false) || (o.visible = false); } this.hideBettingPrompts(); // Also hide chip containers from the betting UI this.chipBtnGraphics.forEach(g => g.setVisible?.(false) || (g.visible = false)); this.chipBtnGraphics.forEach(g => g.setDepth(-100)); } // ── Chip balance ────────────────────────────────────────────────────────── async loadPlayerChips() { try { const { profile } = await api.get('/profile'); this._playerChips = profile.chips ?? 2000; } catch { this._playerChips = 2000; } } // ── Game init ───────────────────────────────────────────────────────────── initGame() { this.gs = createInitialState(this.opponents, this._playerChips); this.startNewRound(); } startNewRound() { if (this.shoe.length < 52) this.shoe = buildShoe(); playSound(this, SFX.CARD_SHUFFLE); this.pendingBet = 0; this.bustedSeats = new Set(); this.gs = prepareRound(this.gs); this.clearCardGraphics(); this.clearActiveHighlight(); this.renderAll(); this.showBettingUI(); this.updateBetDisplay(); } // ── Render ──────────────────────────────────────────────────────────────── renderAll() { this.renderDealer(); for (let seat = 0; seat < SEAT_POS.length; seat++) { const p = this.gs.players[seat]; if (!p.active) continue; this.renderSeatCards(seat); this.renderSeatInfo(seat); } this.renderBetAreas(); } renderDealer() { // Destroy old dealer cards for (const c of this.dealerCardGraphics) c.destroy(); this.dealerCardGraphics = []; const hand = this.gs.dealer.hand; if (hand.length === 0) { this.dealerScoreTxt.setText(''); return; } hand.forEach((card, i) => { const x = this.cardX(DEALER_X, i, hand.length); const cont = this.add.container(x, DEALER_Y).setDepth(D.cards); const faceUp = i === 0 || this.gs.dealer.revealed; this.drawCard(cont, faceUp ? card : null, faceUp); this.dealerCardGraphics.push(cont); }); const revealed = this.gs.dealer.revealed; const { score, soft } = handValue(revealed ? hand : [hand[0]]); const bust = revealed && score > 21; this.dealerScoreTxt.setText( bust ? 'BUST' : `${soft && score < 21 ? 'Soft ' : ''}${score}` ); this.dealerScoreTxt.setColor(bust ? COLORS.dangerHex : COLORS.textHex); } renderSeatCards(seat) { if (this.cardGraphics[seat]) { for (const c of this.cardGraphics[seat]) c.destroy(); } this.cardGraphics[seat] = []; const p = this.gs.players[seat]; const pos = SEAT_POS[seat]; // Main hand const h1Offset = p.hand2 ? -80 : 0; p.hand.forEach((card, i) => { const x = this.cardX(pos.x + h1Offset, i, p.hand.length); const cont = this.add.container(x, pos.y).setDepth(D.cards); this.drawCard(cont, card, true); this.cardGraphics[seat].push(cont); }); // Split hand if (p.hand2) { p.hand2.forEach((card, i) => { const x = this.cardX(pos.x + 80, i, p.hand2.length); const cont = this.add.container(x, pos.y).setDepth(D.cards); this.drawCard(cont, card, true); this.cardGraphics[seat].push(cont); }); } // Score const { score, soft } = handValue(p.hand); const bust = score > 21; const bj = isBlackjack(p.hand); let scoreStr = bj ? 'BJ' : (bust ? 'BUST' : `${soft && score < 21 ? 'soft ' : ''}${score}`); if (p.hand2) { const { score: s2, soft: sf2 } = handValue(p.hand2); const b2 = s2 > 21; const bj2 = isBlackjack(p.hand2); scoreStr += ` / ${bj2 ? 'BJ' : (b2 ? 'BUST' : `${sf2 && s2 < 21 ? 'soft ' : ''}${s2}`)}`; } if (this.scoreTxts[seat]) this.scoreTxts[seat].setText(p.hand.length ? scoreStr : ''); // Keep busted cards faded across re-renders if (this.bustedSeats?.has(seat)) { for (const c of this.cardGraphics[seat]) c.setAlpha(0.3); } } renderSeatInfo(seat) { const p = this.gs.players[seat]; if (this.chipTxts[seat]) this.chipTxts[seat].setText(`$${p.chips.toLocaleString()}`); // Highlight current player if (this.nameTxts[seat]) { this.nameTxts[seat].setColor(p.seat === this.gs.currentSeat && this.gs.phase === 'player_turn' ? COLORS.accentHex : COLORS.textHex); } } renderBetAreas() { for (const g of Object.values(this.betGraphics)) g.destroy(); this.betGraphics = {}; for (let seat = 0; seat < SEAT_POS.length; seat++) { const p = this.gs.players[seat]; if (!p.active || p.bet === 0) continue; const { x: betX, y: betY } = this.betCirclePos(seat); const cont = this.add.container(betX, betY).setDepth(D.chips); const circle = this.add.graphics(); circle.fillStyle(0x2a2a2a, 1); circle.fillCircle(0, 0, 28); circle.lineStyle(2, 0xf0e8d0, 0.8); circle.strokeCircle(0, 0, 28); const txt = this.add.text(0, 0, `$${p.bet}`, { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#f0e8d0', }).setOrigin(0.5); cont.add([circle, txt]); this.betGraphics[seat] = cont; } } clearCardGraphics() { for (const cards of Object.values(this.cardGraphics)) { for (const c of cards) c.destroy(); } this.cardGraphics = {}; for (const c of this.dealerCardGraphics) c.destroy(); this.dealerCardGraphics = []; for (const g of Object.values(this.betGraphics)) g.destroy(); this.betGraphics = {}; } // Returns the {x,y} of a seat's bet chip circle, placed just inboard of the // card spot (toward the table centre at CX, 540). betCirclePos(seat) { const pos = SEAT_POS[seat]; if (pos.betX !== undefined && pos.betY !== undefined) { return { x: pos.betX, y: pos.betY }; } const t = 0.22; return { x: Math.round(pos.x + t * (CX - pos.x)), y: Math.round(pos.y + t * (540 - pos.y)), }; } // ── Card drawing ────────────────────────────────────────────────────────── addCardBackToContainer(container) { if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) { container.add( this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex) .setDisplaySize(CARD_W, CARD_H) .setOrigin(0.5) ); } else { const g = this.add.graphics(); this.drawCardBack(g, -CARD_W / 2, -CARD_H / 2); container.add(g); } } cardX(centerX, idx, total) { const totalW = total * CARD_SPREAD + (CARD_W - CARD_SPREAD); return centerX - totalW / 2 + CARD_W / 2 + idx * CARD_SPREAD; } drawCard(container, card, faceUp) { container.removeAll(true); const x = -CARD_W / 2, y = -CARD_H / 2; if (faceUp && card) { const g = this.add.graphics(); g.fillStyle(0xffffff, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); g.lineStyle(1, 0xcccccc, 1); g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R); container.add(g); const color = card.isRed ? '#c0392b' : '#1a1a2e'; const style = (sz, bold = false) => ({ fontFamily: '"Julius Sans One"', fontSize: `${sz}px`, color, ...(bold ? { fontStyle: 'bold' } : {}), }); container.add(this.add.text(x + 7, y + 5, card.label, style(17, true))); container.add(this.add.text(x + 7, y + 23, card.suitSymbol, style(13))); container.add(this.add.text(0, 4, card.suitSymbol, style(40)).setOrigin(0.5)); container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, card.label, style(17, true)).setOrigin(1, 1)); container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 22, card.suitSymbol, style(13)).setOrigin(1, 1)); } else { this.addCardBackToContainer(container); } } drawCardBack(g, x, y) { const color = this.cardBack?.fallbackColor ? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b; g.fillStyle(color, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); g.lineStyle(2, 0xffffff, 0.25); g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2); g.lineStyle(1, 0xffffff, 0.1); g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4); } // ── Betting phase ───────────────────────────────────────────────────────── onChipClick(amount) { if (this.animating) return; const human = this.gs.players[0]; if (this.pendingBet + amount > 100) return; if (this.pendingBet + amount > human.chips) return; this.pendingBet += amount; this.updateBetDisplay(); this.hideBettingPrompts(); } onClearBet() { this.pendingBet = 0; this.updateBetDisplay(); this.startBettingPromptsIfNoBet(); } updateBetDisplay() { if (this.betDisplayText) this.betDisplayText.setText(`Bet: $${this.pendingBet}`); if (this.dealBtn) this.dealBtn.setEnabled(this.pendingBet >= 5); const human = this.gs?.players[0]; if (this.balanceText && human) this.balanceText.setText(`Balance: $${human.chips.toLocaleString()}`); } onDealClick() { if (this.animating || this.pendingBet < 5) return; this.animating = true; this.hideBettingUI(); this.hideBettingPrompts(); // Commit human bet this.gs = applyBet(this.gs, 0, this.pendingBet); // AI bets for (let seat = 1; seat < SEAT_POS.length; seat++) { const p = this.gs.players[seat]; if (!p.active) continue; const bet = chooseBet(p); this.gs = applyBet(this.gs, seat, Math.max(5, bet)); } // Deal all cards this.gs = dealAllCards(this.gs, this.shoe); // Animate bets then deal this.animateBets(() => { this.animateDeal(() => { this.animating = false; this.renderAll(); this.checkInsuranceOrStartPlay(); }); }); } // ── Animations ──────────────────────────────────────────────────────────── animateBets(onComplete) { this.renderBetAreas(); playChipBet(this); let done = 0; const active = this.gs.players.filter(p => p.active); if (active.length === 0) { onComplete(); return; } for (const p of active) { const pos = SEAT_POS[p.seat]; const { x: betX, y: betY } = this.betCirclePos(p.seat); const chip = this.add.graphics().setDepth(D.chips + 5); chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 16); chip.x = pos.x; chip.y = pos.y; this.tweens.add({ targets: chip, x: betX, y: betY, duration: 280, ease: 'Power2', onComplete: () => { chip.destroy(); done++; if (done >= active.length) onComplete(); }, }); } } animateDeal(onComplete) { // Deal in play order (top-right, clockwise): each player card 1, dealer upcard, // each player card 2, dealer hole. const seats = PLAY_ORDER.filter(seat => this.gs.players[seat]?.active); const dealSeq = []; for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 0 }); dealSeq.push({ type: 'dealer', cardIdx: 0 }); // upcard for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 1 }); dealSeq.push({ type: 'dealer', cardIdx: 1 }); // hole card // Hide all cards initially for (const cards of Object.values(this.cardGraphics)) { for (const c of cards) c.setAlpha(0); } for (const c of this.dealerCardGraphics) c.setAlpha(0); let idx = 0; const DECK_X = CX + 200, DECK_Y = 50; const stagger = 90; const dealNext = () => { if (idx >= dealSeq.length) { onComplete(); return; } const entry = dealSeq[idx++]; // Flying card-back const flying = this.add.container(DECK_X, DECK_Y).setDepth(D.cards + 10); this.addCardBackToContainer(flying); let tx, ty; if (entry.type === 'dealer') { const total = this.gs.dealer.hand.length; tx = this.cardX(DEALER_X, entry.cardIdx, total); ty = DEALER_Y; } else { const pos = SEAT_POS[entry.seat]; const p = this.gs.players[entry.seat]; const h1Offset = p.hand2 ? -80 : 0; const total = p.hand.length; tx = this.cardX(pos.x + h1Offset, entry.cardIdx, total); ty = pos.y; } this.tweens.add({ targets: flying, x: tx, y: ty, duration: 180, ease: 'Power2', onComplete: () => { flying.destroy(); playSound(this, SFX.CARD_DEAL); // Reveal the card at destination if (entry.type === 'dealer') { const cont = this.dealerCardGraphics[entry.cardIdx]; if (cont) cont.setAlpha(1); } else { const cardConts = this.cardGraphics[entry.seat] ?? []; if (cardConts[entry.cardIdx]) cardConts[entry.cardIdx].setAlpha(1); } this.time.delayedCall(stagger, dealNext); }, }); }; dealNext(); } animateDealerReveal(onComplete) { const holeCard = this.dealerCardGraphics[1]; if (!holeCard) { this.gs.dealer.revealed = true; this.renderAll(); onComplete(); return; } playSound(this, SFX.CARD_SHOW); // Flip: scale X 1→0, redraw face-up, scale X 0→1 this.tweens.add({ targets: holeCard, scaleX: 0, duration: 120, ease: 'Linear', onComplete: () => { this.gs = { ...this.gs, dealer: { ...this.gs.dealer, revealed: true } }; this.drawCard(holeCard, this.gs.dealer.hand[1], true); this.tweens.add({ targets: holeCard, scaleX: 1, duration: 120, ease: 'Linear', onComplete: () => { this.renderDealer(); onComplete(); }, }); }, }); } animateSingleCard(seat, card, onComplete) { const pos = SEAT_POS[seat]; const p = this.gs.players[seat]; const isH2 = p.activeHand === 1; const hand = isH2 ? p.hand2 : p.hand; const total = hand.length; const cardIdx = total - 1; const tx = this.cardX(pos.x, cardIdx, total); const ty = pos.y; // Flying card-back dealt from the dealer's area const flying = this.add.container(DEALER_X, DEALER_Y).setDepth(D.cards + 10); this.addCardBackToContainer(flying); this.tweens.add({ targets: flying, x: tx, y: ty, duration: 200, ease: 'Power2', onComplete: () => { flying.destroy(); playSound(this, SFX.CARD_PLACE); // Reveal the card face-up at the destination const cardConts = this.cardGraphics[seat] ?? []; const targetCont = cardConts[cardIdx]; if (targetCont) { targetCont.setAlpha(1); this.drawCard(targetCont, card, true); } onComplete(); }, }); } // ── Per-turn action callout ────────────────────────────────────────────── // Large colored word (Hit/Stand/Double/Split) shown between the player and // their cards; lingers >= 1s before fading. Fire-and-forget (no flow gating). animateActionText(seat, action) { const label = ACTION_LABELS[action]; if (!label) return; const pos = SEAT_POS[seat]; const px = pos.portraitX ?? pos.x; const py = pos.portraitY ?? pos.y; const tx = (px + pos.x) / 2; const ty = (py + pos.y) / 2; // Rendered as a DOM element with a very high depth so it sits above // everything, including the opponent portrait videos (which are DOM and // therefore always drawn above the Phaser canvas). const el = document.createElement('div'); el.textContent = label; el.style.cssText = [ 'font-family:"Julius Sans One",sans-serif', 'font-size:44px', 'font-weight:bold', `color:${ACTION_COLORS[action] ?? '#ffffff'}`, 'text-shadow:1px 1px 0 #000,-1px 1px 0 #000,1px -1px 0 #000,-1px -1px 0 #000,2px 0 0 #000,-2px 0 0 #000,0 2px 0 #000,0 -2px 0 #000,0 0 6px rgba(0,0,0,0.85)', 'white-space:nowrap', 'pointer-events:none', 'user-select:none', ].join(';'); const dom = this.add.dom(tx, ty, el).setDepth(D.modal + 100).setAlpha(0).setScale(1.4); this.tweens.add({ targets: dom, alpha: 1, scaleX: 1, scaleY: 1, duration: 180, ease: 'Back.Out', }); this.time.delayedCall(1000, () => { this.tweens.add({ targets: dom, alpha: 0, y: ty - 26, duration: 350, ease: 'Power2', onComplete: () => dom.destroy(), }); }); } // ── Active-player turn highlight (pulsing ring around the portrait) ──────── highlightActiveSeat(seat) { const pos = SEAT_POS[seat]; const px = pos.portraitX ?? pos.x; const py = pos.portraitY ?? pos.y; if (!this.activeRing) { this.activeRing = this.add.graphics().setDepth(D.ui - 1); } this.activeRing.clear(); this.activeRing.lineStyle(5, COLORS.accent, 0.9); this.activeRing.strokeCircle(0, 0, pos.portraitR + 8); this.activeRing.setPosition(px, py).setVisible(true).setScale(1).setAlpha(1); this.activeRingTween?.stop(); this.activeRingTween = this.tweens.add({ targets: this.activeRing, scaleX: 1.12, scaleY: 1.12, alpha: 0.45, duration: 650, ease: 'Sine.easeInOut', yoyo: true, repeat: -1, }); } clearActiveHighlight() { this.activeRingTween?.stop(); this.activeRingTween = null; if (this.activeRing) this.activeRing.setVisible(false); } // ── Per-seat result animation (text + chips + fireworks) ───────────────── animateSeatResult(seat, onComplete) { const p = this.gs.players[seat]; if (!p?.active || !p.result) { onComplete(); return; } const result = p.result; const pos = SEAT_POS[seat]; const isWin = result === 'win' || result === 'blackjack'; const isLose = result === 'lose' || result === 'bust'; if (result === 'blackjack') playSound(this, SFX.CASINO_BLACKJACK); else if (result === 'win') playSound(this, SFX.CASINO_WIN); else if (result === 'lose' || result === 'bust') playSound(this, SFX.CASINO_LOSE); if (seat > 0 && this.portraits[seat]) { this.portraits[seat].playEmotion?.(isWin ? 'happy' : 'upset'); } const LABELS = { win: 'Win!', blackjack: 'Blackjack!', push: 'Push', lose: 'Lose', bust: 'Bust' }; const COLORS = { win: '#f5d020', blackjack: '#ffe066', push: '#9aa5b4', lose: '#e05c5c', bust: '#e05c5c' }; const SIZES = { win: '54px', blackjack: '58px', push: '34px', lose: '48px', bust: '48px' }; const linger = isWin ? 1500 : isLose ? 1100 : 700; const textY = pos.y - CARD_H / 2 - 60; const badge = this.add.text(pos.x, textY, LABELS[result] ?? result, { fontFamily: '"Julius Sans One"', fontSize: SIZES[result] ?? '48px', color: COLORS[result] ?? '#ffffff', fontStyle: 'bold', stroke: '#000000', strokeThickness: 5, shadow: isWin ? { offsetX: 0, offsetY: 0, color: COLORS[result], blur: 24, fill: true } : undefined, }).setOrigin(0.5).setAlpha(0).setScale(1.4).setDepth(D.modal); // Pop-in this.tweens.add({ targets: badge, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.Out', }); // Linger then fade up this.time.delayedCall(linger, () => { this.tweens.add({ targets: badge, alpha: 0, y: textY - 30, duration: 380, ease: 'Power2', onComplete: () => { badge.destroy(); onComplete(); }, }); }); // Chip movement (runs in parallel with text) if (isWin) { this.animateChipsFromDealer(seat, p.chipsWon ?? p.bet); this.animateFireworks(pos.x, textY); } else if (isLose) { this.animateChipsToDealer(seat); } else { // Push — bet returns to player this.animateChipReturn(seat); } } animateChipsToDealer(seat) { const { x: betX, y: betY } = this.betCirclePos(seat); for (let i = 0; i < 4; i++) { const chip = this.add.graphics().setDepth(D.chips + 5); chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14); chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14); chip.x = betX + (Math.random() - 0.5) * 22; chip.y = betY; this.tweens.add({ targets: chip, x: DEALER_X, y: DEALER_Y, duration: 440, delay: i * 55, ease: 'Power2', onComplete: () => chip.destroy(), }); } } animateChipsFromDealer(seat, amount) { if (!amount || amount <= 0) return; const pos = SEAT_POS[seat]; const count = Math.min(Math.ceil(amount / 25), 7); for (let i = 0; i < count; i++) { const chip = this.add.graphics().setDepth(D.chips + 5); chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14); chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14); chip.x = DEALER_X + (Math.random() - 0.5) * 50; chip.y = DEALER_Y; this.tweens.add({ targets: chip, x: pos.x + (Math.random() - 0.5) * 28, y: pos.y, duration: 460, delay: i * 55, ease: 'Power2', onComplete: () => chip.destroy(), }); } } animateChipReturn(seat) { const pos = SEAT_POS[seat]; const { x: betX, y: betY } = this.betCirclePos(seat); for (let i = 0; i < 3; i++) { const chip = this.add.graphics().setDepth(D.chips + 5); chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14); chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14); chip.x = betX + (Math.random() - 0.5) * 20; chip.y = betY; this.tweens.add({ targets: chip, x: pos.x + (Math.random() - 0.5) * 20, y: pos.y, duration: 380, delay: i * 50, ease: 'Power2', onComplete: () => chip.destroy(), }); } } animateFireworks(cx, cy) { const palette = [0xf5d020, 0xff8c00, 0x5cb85c, 0x4a90d9, 0xff69b4, 0xffffff, 0x00e5ff]; for (let burst = 0; burst < 3; burst++) { this.time.delayedCall(burst * 380, () => { if (!this.scene.isActive('BlackjackGame')) return; const bx = cx + (Math.random() - 0.5) * 140; const by = cy - 10 + (Math.random() - 0.5) * 80; for (let i = 0; i < 10; i++) { const angle = (i / 10) * Math.PI * 2 + Math.random() * 0.3; const dist = 65 + Math.random() * 65; const color = palette[Math.floor(Math.random() * palette.length)]; const dot = this.add.graphics().setDepth(D.modal + 5); dot.fillStyle(color, 1); dot.fillCircle(0, 0, 4 + Math.random() * 3); dot.x = bx; dot.y = by; this.tweens.add({ targets: dot, x: bx + Math.cos(angle) * dist, y: by + Math.sin(angle) * dist, alpha: 0, scaleX: 0.1, scaleY: 0.1, duration: 700 + Math.random() * 400, delay: Math.random() * 100, ease: 'Power2', onComplete: () => dot.destroy(), }); } }); } } // ── Insurance ───────────────────────────────────────────────────────────── checkInsuranceOrStartPlay() { if (this.gs.phase === 'insurance') { this.offerInsurance(); } else { this.checkImmediateDealerBJ(); } } offerInsurance() { // AI declines immediately for (let seat = 1; seat < SEAT_POS.length; seat++) { const p = this.gs.players[seat]; if (p.active) this.gs = applyInsurance(this.gs, seat, false); } // Offer human const modal = this.add.container(CX, GAME_HEIGHT / 2).setDepth(D.modal); const bg = this.add.rectangle(0, 0, 560, 200, COLORS.panel).setStrokeStyle(2, COLORS.accent); const txt = this.add.text(0, -55, 'Dealer shows Ace\nTake insurance? (pays 2:1)', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', }).setOrigin(0.5); modal.add([bg, txt]); const yesBtn = new Button(this, CX - 120, GAME_HEIGHT / 2 + 30, 'Yes', () => { modal.destroy(); yesBtn.destroy(); noBtn.destroy(); this.gs = applyInsurance(this.gs, 0, true); this.checkImmediateDealerBJ(); }, { width: 120, height: 52, fontSize: 20 }); yesBtn.setDepth(D.modal + 1); const noBtn = new Button(this, CX + 120, GAME_HEIGHT / 2 + 30, 'No', () => { modal.destroy(); yesBtn.destroy(); noBtn.destroy(); this.gs = applyInsurance(this.gs, 0, false); this.checkImmediateDealerBJ(); }, { width: 120, height: 52, fontSize: 20, variant: 'ghost' }); noBtn.setDepth(D.modal + 1); } checkImmediateDealerBJ() { if (isBlackjack(this.gs.dealer.hand)) { // Dealer blackjack — resolve insurance then show result this.gs = resolveInsurance(this.gs); this.animateDealerReveal(() => { this.gs = resolveRound(this.gs); this.renderAll(); this.showAllResults(() => this.showNextRoundPrompt()); }); } else { this.gs = resolveInsurance(this.gs); this.startPlayerTurns(); } } // ── Player turns ────────────────────────────────────────────────────────── // First seat to act, walking PLAY_ORDER (clockwise from top-right). firstPlaySeat() { for (const seat of PLAY_ORDER) { if (this.gs.players[seat]?.active) return seat; } return 0; } // Next seat in PLAY_ORDER after the current one that still needs to act. nextPlaySeat() { const start = PLAY_ORDER.indexOf(this.gs.currentSeat); for (let i = start + 1; i < PLAY_ORDER.length; i++) { const p = this.gs.players[PLAY_ORDER[i]]; if (p?.active && (p.status === 'playing' || p.status === 'waiting')) return PLAY_ORDER[i]; } return null; } startPlayerTurns() { this.gs = { ...this.gs, phase: 'player_turn' }; const seat = this.firstPlaySeat(); this.gs = { ...this.gs, currentSeat: seat }; this.advanceToPlayer(seat); } advanceToPlayer(seat) { this.renderAll(); const p = this.gs.players[seat]; if (!p || !p.active) { this.startDealerTurn(); return; } // Skip if already determined (blackjack on deal) if (isPlayerDone(p)) { const next = this.nextPlaySeat(); if (next === null) { this.startDealerTurn(); return; } this.gs = { ...this.gs, currentSeat: next }; this.advanceToPlayer(next); return; } this.highlightActiveSeat(seat); if (p.isHuman) { this.showActionButtons(); } else { this.time.delayedCall(900, () => this.runAITurn(seat)); } } showActionButtons() { this.hideActionButtons(); const seat = this.gs.currentSeat; const p = this.gs.players[seat]; const pos = SEAT_POS[seat]; const by = 918; // just under the table felt let bx = pos.portraitX + pos.portraitR + 80; // first button, just right of the portrait const btns = [ new Button(this, bx, by, 'Hit', () => this.onHit(), { width: 120, height: 52, fontSize: 20 }), ]; bx += 130; btns.push(new Button(this, bx, by, 'Stand', () => this.onStand(), { width: 120, height: 52, fontSize: 20 })); if (canDouble(p)) { bx += 130; btns.push(new Button(this, bx, by, 'Double', () => this.onDouble(), { width: 130, height: 52, fontSize: 20 })); } if (canSplit(p) && p.chips >= p.bet) { bx += 140; btns.push(new Button(this, bx, by, 'Split', () => this.onSplit(), { width: 120, height: 52, fontSize: 20 })); } btns.forEach(btn => btn.setDepth(D.ui + 5)); this.actionBtns = btns; } hideActionButtons() { for (const b of this.actionBtns) b.destroy?.(); this.actionBtns = []; } onPlayerBust(seat) { this.bustedSeats.add(seat); // Fade current card graphics (new renders will stay faded via bustedSeats) for (const c of this.cardGraphics[seat] ?? []) { this.tweens.add({ targets: c, alpha: 0.3, duration: 280, ease: 'Power2' }); } // Chips fly to dealer immediately this.animateChipsToDealer(seat); // Opponent portrait: play upset once, Portrait.js auto-returns to idle on ended if (seat > 0 && this.portraits[seat]) { this.portraits[seat].playEmotion?.('upset'); } } onHit() { this.hideActionButtons(); this.animateActionText(this.gs.currentSeat, 'hit'); this.animating = true; this.gs = applyHit(this.gs, this.gs.currentSeat, this.shoe); const p = this.gs.players[this.gs.currentSeat]; const newCard = p.activeHand === 1 ? p.hand2[p.hand2.length - 1] : p.hand[p.hand.length - 1]; this.animateSingleCard(this.gs.currentSeat, newCard, () => { this.renderSeatCards(this.gs.currentSeat); this.time.delayedCall(200, () => { this.animating = false; if (isBust(p.activeHand === 1 ? p.hand2 : p.hand)) { this.onPlayerBust(this.gs.currentSeat); this.time.delayedCall(380, () => this.playerDone(this.gs.currentSeat)); } else { this.showActionButtons(); } }); }); } onStand() { this.hideActionButtons(); const seat = this.gs.currentSeat; this.animateActionText(seat, 'stand'); this.gs = applyStand(this.gs, seat); const p = this.gs.players[seat]; // If split and now playing hand 2, stay on same seat if (p.activeHand === 1 && p.status2 === null) { this.showActionButtons(); return; } this.playerDone(seat); } onDouble() { this.hideActionButtons(); this.animateActionText(this.gs.currentSeat, 'double'); this.animating = true; this.gs = applyDouble(this.gs, this.gs.currentSeat, this.shoe); this.renderSeatCards(this.gs.currentSeat); this.renderBetAreas(); this.time.delayedCall(300, () => { this.animating = false; const p = this.gs.players[this.gs.currentSeat]; if (p.status === 'bust' || p.status2 === 'bust') { this.onPlayerBust(this.gs.currentSeat); this.time.delayedCall(380, () => this.playerDone(this.gs.currentSeat)); } else { this.playerDone(this.gs.currentSeat); } }); } onSplit() { this.hideActionButtons(); this.animateActionText(this.gs.currentSeat, 'split'); this.animating = true; this.gs = applySplit(this.gs, this.gs.currentSeat, this.shoe); this.renderSeatCards(this.gs.currentSeat); this.renderBetAreas(); this.time.delayedCall(400, () => { this.animating = false; const p = this.gs.players[this.gs.currentSeat]; // If ace split, both hands are immediately standing if (isPlayerDone(p)) { this.playerDone(this.gs.currentSeat); } else { this.showActionButtons(); } }); } playerDone(seat) { const p = this.gs.players[seat]; // If split: check if hand 2 needs to be played if (p.hand2 !== null && p.activeHand === 0 && !isPlayerDone(p)) { this.gs = applyStand(this.gs, seat); // moves to hand 2 this.showActionButtons(); return; } const next = this.nextPlaySeat(); if (next === null) { this.startDealerTurn(); } else { this.gs = { ...this.gs, currentSeat: next }; this.advanceToPlayer(next); } } // ── AI turn ─────────────────────────────────────────────────────────────── runAITurn(seat) { const p = this.gs.players[seat]; if (!p || !p.active || isPlayerDone(p)) { this.playerDone(seat); return; } const action = chooseAction(p, this.gs.dealer.hand[0]); this.animateActionText(seat, action); this.animating = true; const doAction = () => { switch (action) { case 'hit': this.gs = applyHit(this.gs, seat, this.shoe); const pp = this.gs.players[seat]; const aiNewCard = pp.activeHand === 1 ? pp.hand2[pp.hand2.length - 1] : pp.hand[pp.hand.length - 1]; this.animateSingleCard(seat, aiNewCard, () => { this.renderSeatCards(seat); this.time.delayedCall(400, () => { this.animating = false; const pp2 = this.gs.players[seat]; if (isBust(pp2.activeHand === 1 ? pp2.hand2 : pp2.hand)) { this.onPlayerBust(seat); this.time.delayedCall(400, () => this.playerDone(seat)); } else { this.time.delayedCall(600, () => this.runAITurn(seat)); } }); }); break; case 'double': this.gs = applyDouble(this.gs, seat, this.shoe); this.renderSeatCards(seat); this.renderBetAreas(); this.time.delayedCall(600, () => { this.animating = false; const pp = this.gs.players[seat]; if (pp.status === 'bust' || pp.status2 === 'bust') { this.onPlayerBust(seat); this.time.delayedCall(400, () => this.playerDone(seat)); } else { this.playerDone(seat); } }); break; case 'split': this.gs = applySplit(this.gs, seat, this.shoe); this.renderSeatCards(seat); this.renderBetAreas(); this.time.delayedCall(600, () => { this.animating = false; // Play each hand in turn this.time.delayedCall(700, () => this.runAITurn(seat)); }); break; case 'stand': default: this.gs = applyStand(this.gs, seat); this.time.delayedCall(400, () => { this.animating = false; // Handle split hand 2 const pp = this.gs.players[seat]; if (pp.hand2 !== null && pp.activeHand === 1 && pp.status2 === null) { this.time.delayedCall(600, () => this.runAITurn(seat)); } else { this.playerDone(seat); } }); break; } }; doAction(); } // ── Dealer turn ─────────────────────────────────────────────────────────── startDealerTurn() { this.clearActiveHighlight(); this.gs = { ...this.gs, phase: 'dealer_turn', currentSeat: -1 }; this.renderAll(); this.time.delayedCall(400, () => { this.animateDealerReveal(() => { this.runDealerHits(); }); }); } runDealerHits() { const { score } = handValue(this.gs.dealer.hand); if (score >= 17) { this.finishRound(); return; } const newCard = this.shoe.pop(); this.gs = { ...this.gs, dealer: { ...this.gs.dealer, hand: [...this.gs.dealer.hand, newCard] } }; this.renderDealer(); this.time.delayedCall(700, () => this.runDealerHits()); } // ── Resolve ─────────────────────────────────────────────────────────────── finishRound() { this.gs = resolveRound(this.gs); this.renderAll(); this.showAllResults(() => { this.updateServerChips(); this.showNextRoundPrompt(); }); } showAllResults(onComplete) { // Reveal top-right then clockwise around the table (PLAY_ORDER). const seats = PLAY_ORDER.filter(seat => this.gs.players[seat]?.active); if (seats.length === 0) { onComplete(); return; } let pending = seats.length; const done = () => { if (--pending <= 0) onComplete(); }; seats.forEach((seat, i) => { this.time.delayedCall(i * 480, () => this.animateSeatResult(seat, done)); }); } async updateServerChips() { const human = this.gs.players[0]; const delta = human.chipsWon ?? 0; if (delta === 0) return; try { await api.post('/profile/chips/adjust', { delta }); } catch { /* balance will resync next load */ } } showNextRoundPrompt() { const human = this.gs.players[0]; this.renderAll(); if (human.chips <= 0) { new Modal(this, 'Out of chips! Visit your profile to request a reset.', { autoCloseMs: 4000, }); this.time.delayedCall(4200, () => this.scene.start('GameMenu')); return; } const btn = new Button(this, CX, 925, 'Next Round', () => { btn.destroy(); this.startNewRound(); }, { width: 220, height: 60, fontSize: 24 }); } }