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 { auth } from '../../services/auth.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { createInitialState, applyAsk, applyFishPick, legalRanksToAsk, canAsk, isGameOver, } from './GoFishLogic.js'; import { createMemory, observeLog, chooseAction } from './GoFishAI.js'; // ── Layout constants ──────────────────────────────────────────────────────── const CX = GAME_WIDTH / 2; const CY = GAME_HEIGHT / 2; const CARD_W = 90; const CARD_H = 126; const CARD_R = 8; const HAND_SPREAD = 70; const D = { felt: -1, board: 0, pool: 5, card: 10, highlight: 20, ui: 30, portrait: 35, chip: 40, banner: 60, modal: 80, }; const SLOTS_USED = { 2: ['bottom', 'top'], 3: ['bottom', 'left', 'right'], 4: ['bottom', 'left', 'top', 'right'], }; function slotLayout(slot) { switch (slot) { case 'bottom': { // chip bottom edge = bchipY + 22; portrait bottom aligns with chip bottom // portrait left of chip with 12px gap const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30; const bpr = 56, bpx = CX - 90 - 12 - bpr, bpy = bchipY + 22 - bpr; return { handCenter: { x: CX, y: GAME_HEIGHT - 100 }, handAxis: 'x', handFaceUp: true, portrait: { x: bpx, y: bpy, r: bpr }, nameLabel: { x: bpx, y: bpy - bpr - 14 }, chip: { x: CX, y: bchipY }, chipRotation: 0, rotateCards: 0, }; } case 'top': { // chip top edge = tchipY - 22; portrait top aligns with chip top // portrait left of chip with 12px gap const tchipY = 110 + CARD_H / 2 + 30; const tpr = 50, tpx = CX - 90 - 12 - tpr, tpy = tchipY - 22 + tpr; return { handCenter: { x: CX, y: 110 }, handAxis: 'x', handFaceUp: false, portrait: { x: tpx, y: tpy, r: tpr }, nameLabel: { x: tpx, y: tpy + tpr + 14 }, chip: { x: CX, y: tchipY }, chipRotation: 0, rotateCards: 180, }; } case 'left': { // chip at (205, CY) rotated — extends ±90 in y, ±22 in x // portrait left edge aligned with chip left edge (chipX - 22) const lchipX = 110 + CARD_H / 2 + 10 + 22; const lpr = 50, lpx = lchipX - 22 + lpr, lpy = CY - 90 - 12 - lpr; return { handCenter: { x: 110, y: CY }, handAxis: 'y', handFaceUp: false, portrait: { x: lpx, y: lpy, r: lpr }, nameLabel: { x: lpx, y: lpy - lpr - 14 }, chip: { x: lchipX, y: CY }, chipRotation: Math.PI / 2, rotateCards: 90, }; } case 'right': { // chip at (1715, CY) rotated — extends ±90 in y, ±22 in x // portrait right edge aligned with chip right edge (chipX + 22) const rchipX = GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22; const rpr = 50, rpx = rchipX + 22 - rpr, rpy = CY - 90 - 12 - rpr; return { handCenter: { x: GAME_WIDTH - 110, y: CY }, handAxis: 'y', handFaceUp: false, portrait: { x: rpx, y: rpy, r: rpr }, nameLabel: { x: rpx, y: rpy - rpr - 14 }, chip: { x: rchipX, y: CY }, chipRotation: -Math.PI / 2, rotateCards: 270, }; } default: throw new Error(`Unknown slot: ${slot}`); } } const POOL_POS = { x: CX, y: CY }; const SUIT_COLORS = { s: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' }, c: { fill: 0xf2ead8, stroke: 0x1a1208, glyph: '#1a1208' }, h: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' }, d: { fill: 0xfbe7e2, stroke: 0xc92a2a, glyph: '#c92a2a' }, }; const GOFISH_CARD_FRAME = { A: 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, T: 9, J: 10, Q: 11, K: 12 }; // ── Scene ─────────────────────────────────────────────────────────────────── export default class GoFishGame extends Phaser.Scene { constructor() { super('GoFishGame'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.cardBack = data.cardBack ?? null; this.matchVariant = data.matchVariant ?? 4; this.gs = null; this.animating = false; this.gameOver = false; this.cardObjs = new Map(); this.transientObjs = []; this.opponentPortraits = []; this.seatChips = []; this.slotForSeat = []; this.aiMemory = []; this.selectedRank = null; this.bannerText = null; this.poolCardPositions = new Map(); } create() { new MusicPlayer(this, this.cache.json.get('music').tracks); this.buildPlayfield(); this.assignSeats(); this.buildSeatAreas(); this.buildCenter(); this.buildHUD(); this.buildMatchesPanel(); this.startNewMatch(); } buildPlayfield() { const pf = this.playfield; if (pf?.key && this.textures.exists(pf.key)) { this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt); } else { const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d; this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt); } } assignSeats() { const playerCount = 1 + this.opponents.length; const slots = SLOTS_USED[playerCount]; if (!slots) throw new Error(`Go Fish needs 2..4 players, got ${playerCount}`); this.slotForSeat = slots.slice(); } buildSeatAreas() { for (let seat = 0; seat < this.slotForSeat.length; seat++) { const slot = this.slotForSeat[seat]; const layout = slotLayout(slot); if (seat === 0) { createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'GoFishGame'); this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); } else { const opp = this.opponents[seat - 1]; if (opp) { this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait); this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); } } // Pair counter chip. this.seatChips[seat] = this.makeSeatChip(layout.chip.x, layout.chip.y); this.seatChips[seat].container.setRotation(layout.chipRotation); } // Make opponent portraits clickable as ask targets (seats 1..N). for (let seat = 1; seat < this.slotForSeat.length; seat++) { const layout = slotLayout(this.slotForSeat[seat]); const hot = this.add.circle(layout.portrait.x, layout.portrait.y, layout.portrait.r + 8, 0xffffff, 0) .setDepth(D.portrait + 5) .setInteractive({ useHandCursor: true }); hot.on('pointerdown', () => this.onOpponentClick(seat)); hot.on('pointerover', () => this.highlightOpponent(seat, true)); hot.on('pointerout', () => this.highlightOpponent(seat, false)); this.transientObjs.push(hot); } } makeSeatChip(x, y) { const container = this.add.container(x, y).setDepth(D.chip); const bg = this.add.graphics(); bg.fillStyle(COLORS.panel, 0.92); bg.fillRoundedRect(-90, -22, 180, 44, 10); bg.lineStyle(2, COLORS.accent, 1); bg.strokeRoundedRect(-90, -22, 180, 44, 10); const chipLabel = this.matchVariant === 4 ? 'BOOKS' : 'PAIRS'; const label = this.add.text(-78, 0, chipLabel, { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex, }).setOrigin(0, 0.5); const count = this.add.text(78, 0, '0', { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex, }).setOrigin(1, 0.5); container.add([bg, label, count]); return { container, count }; } buildCenter() { // Last-action banner. this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false); this.bannerText = this.add.text(CX, CY + CARD_H + 80, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, align: 'center', wordWrap: { width: 900 }, }).setOrigin(0.5).setDepth(D.banner); } getCentralBounds() { const PAD = 20; let left = PAD, right = GAME_WIDTH - PAD, top = PAD, bottom = GAME_HEIGHT - PAD; for (const slot of this.slotForSeat) { if (slot === 'bottom') bottom = Math.min(bottom, (GAME_HEIGHT - 100) - CARD_H / 2 - PAD); if (slot === 'top') top = Math.max(top, 110 + CARD_H / 2 + PAD); if (slot === 'left') left = Math.max(left, 110 + CARD_H / 2 + PAD); if (slot === 'right') right = Math.min(right, (GAME_WIDTH - 110) - CARD_H / 2 - PAD); } return { left, right, top, bottom }; } scatterPool(pool) { this.poolCardPositions.clear(); const { left, right, top, bottom } = this.getCentralBounds(); const xMin = left + CARD_W / 2, xMax = right - CARD_W / 2; const yMin = top + CARD_H / 2, yMax = bottom - CARD_H / 2; for (const card of pool) { this.poolCardPositions.set(card.id, { x: xMin + Math.random() * (xMax - xMin), y: yMin + Math.random() * (yMax - yMin), rot: Math.random() * 60 - 30, }); } } buildHUD() { // Sit just right of bottom portrait (right edge = CX - 90) and just above chip top (bchipY - 22). const bchipY = GAME_HEIGHT - 100 - CARD_H / 2 - 30; this.statusBg = this.add.graphics().setDepth(D.ui - 1); this.statusText = this.add.text(CX - 90, bchipY - 22 - 14, '', { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'left', }).setOrigin(0, 1).setDepth(D.ui); new Button(this, 80, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 40, fontSize: 18, }).setDepth(D.ui); new Button(this, 80, GAME_HEIGHT - 75, 'New', () => this.startNewMatch(), { variant: 'ghost', width: 120, height: 40, fontSize: 18, }).setDepth(D.ui); } // ── Matches panel ───────────────────────────────────────────────────────── buildMatchesPanel() { const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2']; const maxPerRank = this.matchVariant === 4 ? 1 : 2; const totalPossible = 13 * maxPerRank; const panelTitle = this.matchVariant === 4 ? 'Books so far' : 'Matches so far'; const remainingPrefix = this.matchVariant === 4 ? 'Remaining Books: ' : 'Remaining Matches: '; this._matchMaxPerRank = maxPerRank; this._matchTotalPossible = totalPossible; this._matchRemainingPrefix = remainingPrefix; const px = 10, py = 10; const padX = 9, padY = 8; const titleH = 18; const rowH = 16; const panelW = 188; const panelH = padY + titleH + 5 + RANK_ORDER.length * rowH + 5 + rowH + padY; const bg = this.add.graphics().setDepth(D.ui - 0.5); bg.fillStyle(0x000000, 0.70); bg.fillRoundedRect(px, py, panelW, panelH, 7); bg.lineStyle(1, COLORS.accent, 0.35); bg.strokeRoundedRect(px, py, panelW, panelH, 7); this.add.text(px + padX, py + padY, panelTitle, { fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex, }).setDepth(D.ui); this._matchPanelRows = RANK_ORDER.map((rank, i) => { const label = rank === 'T' ? '10' : rank; const t = this.add.text( px + padX, py + padY + titleH + 5 + i * rowH, `${label.padEnd(2)} = 0 / ${maxPerRank}`, { fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex } ).setDepth(D.ui); return { rank, text: t }; }); this._matchRemainingText = this.add.text( px + padX, py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5, `${remainingPrefix}${totalPossible}`, { fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex } ).setDepth(D.ui); } updateMatchesPanel() { if (!this.gs || !this._matchPanelRows) return; const tally = new Map(this._matchPanelRows.map(({ rank }) => [rank, 0])); for (const player of this.gs.players) { for (const rank of player.pairedRanks) { tally.set(rank, (tally.get(rank) ?? 0) + 1); } } let totalScored = 0; for (const { rank, text } of this._matchPanelRows) { const scored = tally.get(rank) ?? 0; totalScored += scored; const label = rank === 'T' ? '10' : rank; text.setText(`${label.padEnd(2)} = ${scored} / ${this._matchMaxPerRank}`); text.setColor(scored === this._matchMaxPerRank ? COLORS.mutedHex : COLORS.textHex); } this._matchRemainingText.setText(`${this._matchRemainingPrefix}${this._matchTotalPossible - totalScored}`); } // ── Match lifecycle ──────────────────────────────────────────────────────── startNewMatch() { if (this.animating) return; this.gameOver = false; this.selectedRank = null; this.clearAllCardObjs(); this.hideBanner(); const playerCount = this.slotForSeat.length; const finalState = createInitialState({ playerCount, matchSize: this.matchVariant }); this.aiMemory = []; this.aiTurnExclusions = new Map(); for (let s = 0; s < playerCount; s++) { this.aiMemory[s] = createMemory(playerCount); observeLog(this.aiMemory[s], finalState, s); } playSound(this, SFX.CARD_SHUFFLE); this.animating = true; // Show the deck as the deal source and reset chip display. const deck = this.makeCardSprite( { label: '', suit: 's', suitSymbol: '' }, POOL_POS.x, POOL_POS.y, { faceUp: false } ); this.transientObjs.push(deck); for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); } // Build round-robin deal sequence. const hands = finalState.players.map(p => [...p.hand]); const maxCards = Math.max(...hands.map(h => h.length)); const sequence = []; for (let round = 0; round < maxCards; round++) { for (let seat = 0; seat < playerCount; seat++) { if (round < hands[seat].length) { sequence.push({ seat, card: hands[seat][round], handIndex: round }); } } } const STAGGER = 90; // ms between each card launch const DURATION = 200; // ms for the card to fly sequence.forEach(({ seat, card, handIndex }, idx) => { this.time.delayedCall(idx * STAGGER, () => { const layout = slotLayout(this.slotForSeat[seat]); const n = hands[seat].length; const offset = handIndex - (n - 1) / 2; const tx = layout.handAxis === 'x' ? layout.handCenter.x + offset * HAND_SPREAD : layout.handCenter.x; const ty = layout.handAxis === 'x' ? layout.handCenter.y : layout.handCenter.y + offset * HAND_SPREAD; const sprite = this.makeCardSprite(card, POOL_POS.x, POOL_POS.y, { faceUp: false, rotation: layout.rotateCards, }); sprite.setDepth(D.card + 5); this.transientObjs.push(sprite); if (idx % 4 === 0) playSound(this, SFX.CARD_DEAL); this.tweens.add({ targets: sprite, x: tx, y: ty, duration: DURATION, ease: 'Cubic.easeOut', onComplete: () => { if (!sprite.active) return; if (layout.handFaceUp) this.renderCardFace(sprite, card, true); sprite.setDepth(D.card); }, }); }); }); // Once the last card has landed, switch to live game state. const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250; this.time.delayedCall(doneAt, () => { this.scatterPool(finalState.pool); this.gs = finalState; this.renderAll(); this.updateStatus(); const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2); if (initialPairs.length > 0) { this.playInitialDealPairs(initialPairs, 0, () => { this.animating = false; this.maybeStartAITurn(); }); } else { this.animating = false; this.maybeStartAITurn(); } }); } playInitialDealPairs(pairs, idx, onComplete) { if (idx >= pairs.length) { onComplete(); return; } const { seat, pairedCards } = pairs[idx]; const name = this.opponentName(seat); const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank; const unit = this.matchVariant === 4 ? 'book' : 'pair'; const bookCount = pairedCards.length / this.matchVariant; const label = bookCount === 1 ? `${name} dealt a starting ${unit} of ${rank}s!` : `${name} dealt ${bookCount} starting ${unit}s!`; this.showBanner(label); this.animatePairedCards(seat, pairedCards, () => { this.hideBanner(); this.time.delayedCall(250, () => this.playInitialDealPairs(pairs, idx + 1, onComplete)); }); } // ── Card sprite factory ─────────────────────────────────────────────────── makeCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) { const c = this.add.container(x, y).setDepth(D.card); c.setRotation((rotation * Math.PI) / 180); c.setScale(scale); this.renderCardFace(c, card, faceUp); c.card = card; return c; } renderCardFace(container, card, faceUp) { container.removeAll(true); const x = -CARD_W / 2, y = -CARD_H / 2; const g = this.add.graphics(); if (!faceUp) { if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) { g.destroy(); container.add( this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex) .setDisplaySize(CARD_W, CARD_H) .setOrigin(0.5) ); } else { 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, COLORS.accent, 0.6); g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2); g.lineStyle(1, 0xffffff, 0.15); g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4); container.add(g); } return; } const frame = GOFISH_CARD_FRAME[card.rank]; if (frame !== undefined && this.textures.exists('gofish-cards')) { g.destroy(); container.add( this.add.image(0, 0, 'gofish-cards', frame) .setDisplaySize(CARD_W, CARD_H) .setOrigin(0.5) ); return; } const suit = SUIT_COLORS[card.suit] ?? SUIT_COLORS.s; g.fillStyle(suit.fill, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); g.lineStyle(3, suit.stroke, 1); g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1); container.add(g); const labelStyle = (sz) => ({ fontFamily: 'Righteous', fontSize: `${sz}px`, color: suit.glyph }); container.add(this.add.text(x + 8, y + 6, card.label, labelStyle(20))); container.add(this.add.text(x + 8, y + 28, card.suitSymbol, labelStyle(20))); container.add(this.add.text(0, 0, card.suitSymbol, labelStyle(54)).setOrigin(0.5)); container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, card.label, labelStyle(20)).setOrigin(1, 1)); } clearAllCardObjs() { for (const c of this.cardObjs.values()) c.destroy(); this.cardObjs.clear(); for (const o of this.transientObjs) o.destroy(); this.transientObjs = []; } // ── Rendering ───────────────────────────────────────────────────────────── renderAll() { this.clearAllCardObjs(); this.renderScatteredPool(); for (let seat = 0; seat < this.gs.players.length; seat++) { this.renderSeat(seat); } this.renderSeatChips(); this.renderTurnIndicator(); this.updateMatchesPanel(); } renderScatteredPool() { const localPickable = this.gs.phase === 'pick' && this.isLocalTurn(); for (const card of this.gs.pool) { const pos = this.poolCardPositions.get(card.id); if (!pos) continue; const c = this.makeCardSprite( { label: '', suit: 's', suitSymbol: '' }, pos.x, pos.y, { faceUp: false, rotation: pos.rot } ); c.setDepth(D.pool); this.cardObjs.set(`pool-${card.id}`, c); if (localPickable) { c.setInteractive( new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains ); c.input.cursor = 'pointer'; c.on('pointerover', () => { this.tweens.add({ targets: c, scaleX: 1.08, scaleY: 1.08, duration: 100 }); c.setDepth(D.highlight); }); c.on('pointerout', () => { this.tweens.add({ targets: c, scaleX: 1, scaleY: 1, duration: 100 }); c.setDepth(D.pool); }); c.on('pointerdown', () => this.onPoolCardClick(card.id)); } } } renderSeat(seat) { const player = this.gs.players[seat]; const slot = this.slotForSeat[seat]; const layout = slotLayout(slot); const n = player.hand.length; for (let i = 0; i < n; i++) { const card = player.hand[i]; const offset = i - (n - 1) / 2; let x, y; if (layout.handAxis === 'x') { x = layout.handCenter.x + offset * HAND_SPREAD; y = layout.handCenter.y; } else { x = layout.handCenter.x; y = layout.handCenter.y + offset * HAND_SPREAD; } const c = this.makeCardSprite(card, x, y, { faceUp: layout.handFaceUp, rotation: layout.rotateCards, }); this.cardObjs.set(`hand-${seat}-${card.id}`, c); c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains); c.input.cursor = 'pointer'; if (seat === 0) { c.on('pointerdown', () => this.onHandCardClick(card.rank)); } else { c.on('pointerdown', () => this.onOpponentClick(seat)); c.on('pointerover', () => this.highlightOpponent(seat, true)); c.on('pointerout', () => this.highlightOpponent(seat, false)); } } // Highlight selected rank for human seat. if (seat === 0 && this.selectedRank) { for (let i = 0; i < n; i++) { const card = player.hand[i]; if (card.rank !== this.selectedRank) continue; const obj = this.cardObjs.get(`hand-${seat}-${card.id}`); if (!obj) continue; const ring = this.add.graphics(); ring.lineStyle(3, COLORS.accent, 1); ring.strokeRoundedRect(-CARD_W / 2 - 3, -CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2); ring.setPosition(obj.x, obj.y); ring.setDepth(D.highlight); this.transientObjs.push(ring); obj.setDepth(D.card + 2); } } } renderSeatChips() { for (let s = 0; s < this.gs.players.length; s++) { const p = this.gs.players[s]; const chip = this.seatChips[s]; if (!chip) continue; chip.count.setText(`${p.pairs}`); } } renderTurnIndicator() { const seat = this.gs.currentPlayer; const slot = this.slotForSeat[seat]; const lay = slotLayout(slot); if (!this.turnGlow) { this.turnGlow = this.add.circle(0, 0, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1); } this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y); } highlightOpponent(seat, on) { if (!this.isLocalTurn() || !this.selectedRank) return; const slot = this.slotForSeat[seat]; const lay = slotLayout(slot); if (on) { const ring = this.add.graphics(); ring.lineStyle(4, COLORS.gold, 0.9); ring.strokeCircle(lay.portrait.x, lay.portrait.y, lay.portrait.r + 6); ring.setDepth(D.portrait - 2); this._hoverRing = ring; } else { this._hoverRing?.destroy(); this._hoverRing = null; } } // ── Status / banner ──────────────────────────────────────────────────────── updateStatus() { if (this.gameOver) { this.statusText.setText(''); this.statusBg.setVisible(false); return; } if (this.gs?.phase === 'pick') { this.statusText.setText( this.isLocalTurn() ? 'Click any face-down card from the center to draw.' : `${this.opponentName(this.gs.currentPlayer)} is drawing…` ); this.refreshStatusBg(); return; } if (this.isLocalTurn()) { if (this.selectedRank) { this.statusText.setText(`Asking for ${this.selectedRank}s — tap an opponent to ask.`); } else { this.statusText.setText('Your turn — tap a card to choose what to ask for.'); } } else { this.statusText.setText(`${this.opponentName(this.gs.currentPlayer)} is thinking…`); } this.refreshStatusBg(); } refreshStatusBg() { const t = this.statusText; const pad = 8; this.statusBg.clear(); this.statusBg.fillStyle(0x000000, 0.55); this.statusBg.fillRoundedRect(t.x - pad, t.y - t.height - pad, t.width + pad * 2, t.height + pad * 2, 6); this.statusBg.setVisible(true); } showBanner(text) { this.bannerText.setText(text); this.bannerText.setVisible(true); this.bannerBg.clear(); this.bannerBg.fillStyle(COLORS.panel, 0.85); const w = Math.max(this.bannerText.width + 60, 360); const h = this.bannerText.height + 28; this.bannerBg.fillRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10); this.bannerBg.lineStyle(2, COLORS.accent, 1); this.bannerBg.strokeRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10); this.bannerBg.setVisible(true); } hideBanner() { this.bannerText?.setVisible(false); this.bannerBg?.setVisible(false); } // ── Turn flow ────────────────────────────────────────────────────────────── isLocalTurn() { return !this.gameOver && this.gs && this.gs.currentPlayer === 0; } opponentName(seat) { if (seat === 0) return 'You'; const opp = this.opponents[seat - 1]; return opp?.name ?? `P${seat + 1}`; } onHandCardClick(rank) { if (!this.isLocalTurn() || this.animating) return; const legal = legalRanksToAsk(this.gs, 0); if (!legal.includes(rank)) return; this.selectedRank = (this.selectedRank === rank) ? null : rank; this.renderAll(); this.updateStatus(); } onOpponentClick(targetSeat) { if (!this.isLocalTurn() || this.animating) return; if (!this.selectedRank) { this.showBanner('Pick a card from your hand first.'); this.time.delayedCall(1400, () => this.hideBanner()); return; } const target = this.gs.players[targetSeat]; if (!target || target.sittingOut || target.hand.length === 0) { this.showBanner(`${this.opponentName(targetSeat)} has no cards to give.`); this.time.delayedCall(1400, () => this.hideBanner()); return; } this.executeAsk(0, targetSeat, this.selectedRank); } executeAsk(askerSeat, targetSeat, rank) { this.animating = true; const before = this.gs; const after = applyAsk(before, targetSeat, rank); if (after === before) { this.animating = false; return; } const last = after.lastAsk; this.animateAsk(askerSeat, targetSeat, rank, last, before, (liveSprites) => { // Fish with pool cards remaining — player must pick interactively. if (after.phase === 'pick') { this.gs = after; this.selectedRank = null; for (let s = 0; s < this.gs.players.length; s++) { observeLog(this.aiMemory[s], this.gs, s); } this.renderAll(); this.updateStatus(); this.animating = false; this.maybeStartAITurn(); return; } // Catch, or fish with empty pool — normal completion. if (last.result === 'catch' && askerSeat !== 0) { if (!this.aiTurnExclusions.has(askerSeat)) this.aiTurnExclusions.set(askerSeat, new Set()); this.aiTurnExclusions.get(askerSeat).add(`${targetSeat}:${rank}`); } if (last.result === 'catch' && targetSeat !== 0) { this.opponentPortraits[targetSeat]?.playEmotion('upset'); } this.showBanner(this.formatAskBanner(last)); playSound(this, last.result === 'catch' ? SFX.CARD_PLACE : SFX.CARD_DEAL); this.gs = after; this.selectedRank = null; for (let s = 0; s < this.gs.players.length; s++) { observeLog(this.aiMemory[s], this.gs, s); } if (last.newPairs > 0 && last.pairedCards?.length >= 2) { if (askerSeat !== 0) this.opponentPortraits[askerSeat]?.playEmotion('happy'); // Cards from animateAsk are already visible in position. // Do NOT call renderAll() here — it would destroy the liveSprites. // renderAll() is deferred to after the pair + refill animations complete. this.hideBanner(); this.animatePairedCards(askerSeat, last.pairedCards, () => { this.playRefillsThenFinish(() => { this.animating = false; if (isGameOver(this.gs)) { this.endGame(); return; } this.maybeStartAITurn(); }); }, liveSprites ?? null); } else { this.time.delayedCall(900, () => { this.hideBanner(); this.playRefillsThenFinish(() => { this.animating = false; if (isGameOver(this.gs)) { this.endGame(); return; } this.maybeStartAITurn(); }); }); } }); } animateAsk(askerSeat, targetSeat, rank, last, beforeState, onComplete) { const askerSlot = this.slotForSeat[askerSeat]; const askerLayout = slotLayout(askerSlot); const askerShow = this.slotShowPos(askerSlot, askerLayout); const targetSlot = this.slotForSeat[targetSeat]; const targetLayout = slotLayout(targetSlot); const targetShow = this.slotShowPos(targetSlot, targetLayout); const askerCard = beforeState.players[askerSeat].hand.find(c => c.rank === rank); if (!askerCard) { onComplete(); return; } const existingSprite = this.cardObjs.get(`hand-${askerSeat}-${askerCard.id}`); const askerSprite = existingSprite ?? this.makeCardSprite(askerCard, askerLayout.handCenter.x, askerLayout.handCenter.y, { faceUp: askerLayout.handFaceUp, rotation: 0, }); if (existingSprite) this.cardObjs.delete(`hand-${askerSeat}-${askerCard.id}`); const origX = askerSprite.x; const origY = askerSprite.y; askerSprite.setDepth(D.banner - 4); this.transientObjs.push(askerSprite); // Move asker card to show zone then flip it face-up (skip flip if already face-up). this.tweens.add({ targets: askerSprite, x: askerShow.x, y: askerShow.y, duration: 280, ease: 'Cubic.easeOut', onComplete: () => { if (!askerLayout.handFaceUp) this.flipCardFaceUp(askerSprite, askerCard); }, }); // After card is revealed, cast fishing line toward the target, then show result. this.time.delayedCall(640, () => { this.animateFishingLine(askerShow.x, askerShow.y, targetShow.x, targetShow.y, () => { if (last.result === 'catch') { const n = last.cardsTransferred.length; const isTargetVert = targetSlot === 'left' || targetSlot === 'right'; const targetSprites = last.cardsTransferred.map((card, i) => { const tx = isTargetVert ? targetShow.x : targetShow.x + (i - (n - 1) / 2) * (CARD_W + 8); const ty = isTargetVert ? targetShow.y + (i - (n - 1) / 2) * (CARD_H + 8) : targetShow.y; const existingTargetSprite = this.cardObjs.get(`hand-${targetSeat}-${card.id}`); const sprite = existingTargetSprite ?? this.makeCardSprite(card, targetLayout.handCenter.x, targetLayout.handCenter.y, { faceUp: targetLayout.handFaceUp, rotation: 0, }); if (existingTargetSprite) this.cardObjs.delete(`hand-${targetSeat}-${card.id}`); sprite.setDepth(D.banner - 4); this.transientObjs.push(sprite); this.tweens.add({ targets: sprite, x: tx, y: ty, duration: 250, delay: i * 80, ease: 'Cubic.easeOut', onComplete: () => { if (!targetLayout.handFaceUp) this.flipCardFaceUp(sprite, card); }, }); return { sprite, tx, ty }; }); // After target cards are shown, fan all sprites into a holding position // at the asker's show zone — they stay visible for the pair animation. const waitMs = 250 + (n - 1) * 80 + 380; this.time.delayedCall(waitMs, () => { const FGAP = 8; const nTotal = 1 + targetSprites.length; const isAskerVert = askerSlot === 'left' || askerSlot === 'right'; const allSprites = [ askerSprite, ...targetSprites.map(({ sprite }) => sprite), ].map((sprite, i) => { const x = isAskerVert ? askerShow.x : askerShow.x + (i - (nTotal - 1) / 2) * (CARD_W + FGAP); const y = isAskerVert ? askerShow.y + (i - (nTotal - 1) / 2) * (CARD_H + FGAP) : askerShow.y; return { sprite, x, y }; }); for (const { sprite, x, y } of allSprites) { if (!sprite.active) continue; this.tweens.add({ targets: sprite, x, y, duration: 280, ease: 'Cubic.easeOut' }); } this.time.delayedCall(320, () => onComplete(allSprites)); }); } else { // Fish — flip card back face-down (if needed) and return it to hand. if (!askerLayout.handFaceUp) this.flipCardFaceDown(askerSprite); const returnDelay = askerLayout.handFaceUp ? 80 : 340; this.time.delayedCall(returnDelay, () => { this.tweens.add({ targets: askerSprite, x: origX, y: origY, duration: 280, ease: 'Cubic.easeIn', onComplete: () => { if (askerSprite.active) askerSprite.destroy(); }, }); this.time.delayedCall(320, onComplete); }); } }); }); } slotShowPos(slot, layout) { const GAP = 10; switch (slot) { case 'bottom': return { x: layout.handCenter.x, y: layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2 }; case 'top': return { x: layout.handCenter.x, y: layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2 }; case 'left': return { x: layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2, y: layout.handCenter.y }; case 'right': return { x: layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2, y: layout.handCenter.y }; default: return { x: CX, y: CY }; } } flipCardFaceUp(container, card) { this.tweens.add({ targets: container, scaleX: 0, duration: 150, ease: 'Linear', onComplete: () => { container.setRotation(0); this.renderCardFace(container, card, true); this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' }); }, }); } flipCardFaceDown(container) { this.tweens.add({ targets: container, scaleX: 0, duration: 150, ease: 'Linear', onComplete: () => { this.renderCardFace(container, container.card, false); this.tweens.add({ targets: container, scaleX: 1, duration: 150, ease: 'Linear' }); }, }); } animatePairedCards(askerSeat, pairedCards, onComplete, preSprites = null) { if (preSprites) { // Cards are already face-up and in position from animateAsk — skip sprite creation. const sprites = preSprites.map(({ sprite, x, y }) => ({ sprite, targetX: x, targetY: y })); playSound(this, SFX.CARD_PLACE); this.time.delayedCall(200, () => { const cx = sprites.reduce((s, { targetX }) => s + targetX, 0) / sprites.length; const cy = sprites.reduce((s, { targetY }) => s + targetY, 0) / sprites.length; this.spawnFireworks(cx, cy); this.time.delayedCall(350, () => this.spawnFireworks(cx, cy)); }); this.time.delayedCall(1200, () => { for (const { sprite } of sprites) { if (!sprite.active) continue; this.tweens.add({ targets: sprite, x: CX, y: CY, alpha: 0, duration: 500, ease: 'Cubic.easeIn', onComplete: () => { if (sprite.active) sprite.destroy(); }, }); } }); this.time.delayedCall(1800, onComplete); return; } const slot = this.slotForSeat[askerSeat]; const layout = slotLayout(slot); const n = pairedCards.length; const GAP = 10; const totalW = n * CARD_W + (n - 1) * GAP; const sprites = []; for (let i = 0; i < n; i++) { let targetX, targetY; switch (slot) { case 'bottom': targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); targetY = layout.handCenter.y - CARD_H / 2 - GAP - CARD_H / 2; break; case 'top': targetX = CX - totalW / 2 + CARD_W / 2 + i * (CARD_W + GAP); targetY = layout.handCenter.y + CARD_H / 2 + GAP + CARD_H / 2; break; case 'left': { const leftmostX = layout.handCenter.x + CARD_H / 2 + GAP + CARD_W / 2; targetX = leftmostX + i * (CARD_W + GAP); targetY = CY; break; } case 'right': { const rightmostX = layout.handCenter.x - CARD_H / 2 - GAP - CARD_W / 2; targetX = rightmostX - (n - 1 - i) * (CARD_W + GAP); targetY = CY; break; } default: targetX = CX; targetY = CY; } const sprite = this.makeCardSprite(pairedCards[i], CX, CY, { faceUp: true, rotation: 0 }); sprite.setDepth(D.banner - 5); sprite.setAlpha(0); this.transientObjs.push(sprite); this.tweens.add({ targets: sprite, x: targetX, y: targetY, alpha: 1, duration: 300, ease: 'Back.easeOut', }); sprites.push({ sprite, targetX, targetY }); } playSound(this, SFX.CARD_PLACE); this.time.delayedCall(350, () => { const cx = sprites.reduce((s, { targetX: tx }) => s + tx, 0) / sprites.length; const cy = sprites.reduce((s, { targetY: ty }) => s + ty, 0) / sprites.length; this.spawnFireworks(cx, cy); this.time.delayedCall(350, () => this.spawnFireworks(cx, cy)); }); this.time.delayedCall(1600, () => { for (const { sprite } of sprites) { if (!sprite.active) continue; this.tweens.add({ targets: sprite, x: CX, y: CY, alpha: 0, duration: 500, ease: 'Cubic.easeIn', onComplete: () => { if (sprite.active) sprite.destroy(); }, }); } }); this.time.delayedCall(2200, onComplete); } // ── Refill animation ────────────────────────────────────────────────────── animateRefill(seat, drawnCards, onComplete) { if (drawnCards.length === 0) { onComplete(); return; } const slot = this.slotForSeat[seat]; const layout = slotLayout(slot); const finalHand = this.gs.players[seat].hand; const STAGGER = 130; const FLY = 340; const targetRot = (layout.rotateCards * Math.PI) / 180; const name = this.opponentName(seat); this.showBanner(`${name}'s hand was empty — drawing new cards…`); drawnCards.forEach((card, i) => { this.time.delayedCall(i * STAGGER, () => { // Re-use the existing face-down pool sprite if still in cardObjs. const poolKey = `pool-${card.id}`; const existing = this.cardObjs.get(poolKey); let sprite; if (existing) { this.cardObjs.delete(poolKey); this.transientObjs.push(existing); sprite = existing; } else { const pos = this.poolCardPositions.get(card.id) ?? POOL_POS; sprite = this.makeCardSprite(card, pos.x, pos.y, { faceUp: false }); this.transientObjs.push(sprite); } sprite.setDepth(D.card + 5); // Destination: the card's actual position in the final hand. const cardInHand = finalHand.find(c => c.id === card.id); let destX = layout.handCenter.x; let destY = layout.handCenter.y; if (cardInHand) { const n = finalHand.length; const idx = finalHand.indexOf(cardInHand); const offset = idx - (n - 1) / 2; if (layout.handAxis === 'x') destX = layout.handCenter.x + offset * HAND_SPREAD; else destY = layout.handCenter.y + offset * HAND_SPREAD; } if (i % 2 === 0) playSound(this, SFX.CARD_DEAL); this.tweens.add({ targets: sprite, x: destX, y: destY, rotation: targetRot, duration: FLY, ease: 'Cubic.easeOut', onComplete: () => { if (seat === 0 && cardInHand) this.flipCardFaceUp(sprite, card); }, }); }); }); const total = (drawnCards.length - 1) * STAGGER + FLY + (seat === 0 ? 380 : 160); this.time.delayedCall(total, () => { this.hideBanner(); onComplete(); }); } // Run each refill entry sequentially, then call renderAll + updateStatus + onDone. playRefillsThenFinish(onDone) { const refills = (this.gs.lastAsk?.refills ?? []).filter(r => r.cards.length > 0); const next = (idx) => { if (idx >= refills.length) { this.renderAll(); this.updateStatus(); onDone(); return; } const { seat, cards } = refills[idx]; this.animateRefill(seat, cards, () => next(idx + 1)); }; next(0); } spawnFireworks(cx, cy) { const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b]; const COUNT = 20; for (let i = 0; i < COUNT; i++) { const angle = (i / COUNT) * Math.PI * 2; const dist = 70 + Math.random() * 70; const color = BURST_COLORS[i % BURST_COLORS.length]; const r = 3 + Math.random() * 4; const g = this.add.graphics().setDepth(D.banner - 2); g.fillStyle(color, 1); g.fillCircle(0, 0, r); g.setPosition(cx, cy); this.transientObjs.push(g); this.tweens.add({ targets: g, x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist, alpha: 0, duration: 600 + Math.random() * 300, ease: 'Quad.easeOut', onComplete: () => { if (g.active) g.destroy(); }, }); } } onPoolCardClick(cardId) { if (!this.isLocalTurn() || this.animating || this.gs.phase !== 'pick') return; this.doFishPick(cardId); } runAIFishPick() { if (this.gameOver || this.animating) return; if (this.gs.phase !== 'pick' || this.isLocalTurn()) return; const pool = this.gs.pool; if (pool.length === 0) return; const card = pool[Math.floor(Math.random() * pool.length)]; this.doFishPick(card.id); } doFishPick(cardId) { this.animating = true; const askerSeat = this.gs.lastAsk.askerSeat; const after = applyFishPick(this.gs, cardId); if (after === this.gs) { this.animating = false; return; } const sprite = this.cardObjs.get(`pool-${cardId}`); if (sprite) this.cardObjs.delete(`pool-${cardId}`); this.poolCardPositions.delete(cardId); const continueAfterDraw = () => { const last = after.lastAsk; this.showBanner(this.formatAskBanner(last)); playSound(this, SFX.CARD_DEAL); this.gs = after; this.selectedRank = null; for (let s = 0; s < this.gs.players.length; s++) { observeLog(this.aiMemory[s], this.gs, s); } if (last.newPairs > 0 && last.pairedCards?.length >= 2) { if (askerSeat !== 0) this.opponentPortraits[askerSeat]?.playEmotion('happy'); this.time.delayedCall(600, () => { this.hideBanner(); this.animatePairedCards(askerSeat, last.pairedCards, () => { this.playRefillsThenFinish(() => { this.animating = false; if (isGameOver(this.gs)) { this.endGame(); return; } this.maybeStartAITurn(); }); }); }); } else { this.time.delayedCall(900, () => { this.hideBanner(); this.playRefillsThenFinish(() => { this.animating = false; if (isGameOver(this.gs)) { this.endGame(); return; } this.maybeStartAITurn(); }); }); } }; if (sprite) { const revealCard = askerSeat === 0 ? after.lastAsk.drawnCard : null; const drawnCard = after.lastAsk.drawnCard; const newHand = after.players[askerSeat].hand; const cardIndex = newHand.findIndex(c => c.id === drawnCard.id); const askerLayout = slotLayout(this.slotForSeat[askerSeat]); let destX, destY; if (askerLayout.handAxis === 'x') { destX = askerLayout.handCenter.x + (cardIndex - (newHand.length - 1) / 2) * HAND_SPREAD; destY = askerLayout.handCenter.y; } else { destX = askerLayout.handCenter.x; destY = askerLayout.handCenter.y + (cardIndex - (newHand.length - 1) / 2) * HAND_SPREAD; } this.animateFishDraw(sprite, revealCard, destX, destY, continueAfterDraw); } else { this.time.delayedCall(50, continueAfterDraw); } } animateFishDraw(sprite, revealCard, destX, destY, onComplete) { sprite.setDepth(D.banner - 4); this.transientObjs.push(sprite); const flyToHand = () => { this.tweens.add({ targets: sprite, x: destX, y: destY, rotation: 0, duration: 400, ease: 'Cubic.easeIn', onComplete: () => onComplete(), }); }; if (revealCard) { // Human pick: flip face-up in place, pause to show the card, then fly to hand. this.flipCardFaceUp(sprite, revealCard); this.time.delayedCall(700, flyToHand); } else { flyToHand(); } } animateFishingLine(fromX, fromY, toX, toY, onComplete) { const gfx = this.add.graphics().setDepth(D.banner - 3); const totalDx = toX - fromX, totalDy = toY - fromY; const totalLen = Math.sqrt(totalDx * totalDx + totalDy * totalDy) || 1; const ndx = totalDx / totalLen, ndy = totalDy / totalLen; const px = -ndy, py = ndx; // left-perpendicular to line direction const HOOK_R = 10; const BARB_LEN = 7; const SWEEP = (4 * Math.PI) / 3; // 240° const drawFrame = (t) => { gfx.clear(); gfx.lineStyle(2, 0xffe066, 1); const tipX = fromX + t * totalDx; const tipY = fromY + t * totalDy; gfx.beginPath(); gfx.moveTo(fromX, fromY); gfx.lineTo(tipX, tipY); gfx.strokePath(); // Hook arc offset perpendicular from tip const hookCx = tipX + px * HOOK_R; const hookCy = tipY + py * HOOK_R; const startAngle = Math.atan2(tipY - hookCy, tipX - hookCx); const endAngle = startAngle + SWEEP; gfx.beginPath(); gfx.arc(hookCx, hookCy, HOOK_R, startAngle, endAngle, false); gfx.strokePath(); // Barb at hook tip const barbX = hookCx + HOOK_R * Math.cos(endAngle); const barbY = hookCy + HOOK_R * Math.sin(endAngle); gfx.beginPath(); gfx.moveTo(barbX, barbY); gfx.lineTo(barbX - Math.sin(endAngle) * BARB_LEN, barbY + Math.cos(endAngle) * BARB_LEN); gfx.strokePath(); }; const progress = { t: 0 }; this.tweens.add({ targets: progress, t: 1, duration: 1000, ease: 'Cubic.easeOut', onUpdate: () => drawFrame(progress.t), onComplete: () => { drawFrame(1); this.time.delayedCall(150, () => { this.tweens.add({ targets: gfx, alpha: 0, duration: 250, ease: 'Linear', onComplete: () => { gfx.destroy(); onComplete(); }, }); }); }, }); } formatAskBanner(last) { const asker = this.opponentName(last.askerSeat); const target = this.opponentName(last.targetSeat); const rank = last.rank === 'T' ? '10' : last.rank; if (last.result === 'catch') { const unit = this.matchVariant === 4 ? 'book' : 'pair'; const tail = last.newPairs > 0 ? ` +${last.newPairs} ${unit}${last.newPairs > 1 ? 's' : ''}!` : ''; return `${asker} asked ${target} for ${rank}s — caught ${last.cardsTransferred.length}!${tail}`; } if (last.result === 'lucky') { return `${asker} asked ${target} for ${rank}s — Go Fish… lucky draw!`; } return `${asker} asked ${target} for ${rank}s — Go Fish.`; } maybeStartAITurn() { if (this.gameOver || this.animating) return; if (this.gs.phase === 'pick' && !this.isLocalTurn()) { this.time.delayedCall(700, () => this.runAIFishPick()); return; } if (this.isLocalTurn()) return; this.time.delayedCall(700, () => this.runAITurn()); } runAITurn() { if (this.gameOver || this.animating) return; const seat = this.gs.currentPlayer; if (seat === 0) return; // Clear exclusions when starting a fresh turn (not continuing after a catch). if (this.gs.lastAsk?.askerSeat !== seat || this.gs.lastAsk?.result !== 'catch') { this.aiTurnExclusions.set(seat, new Set()); } const exclude = this.aiTurnExclusions.get(seat) ?? new Set(); const action = chooseAction(this.gs, seat, this.aiMemory[seat], exclude); if (!action) { // Shouldn't happen — fail-safe: end the game if no action available. this.endGame(); return; } this.executeAsk(seat, action.targetSeat, action.rank); } // ── Game over ────────────────────────────────────────────────────────────── endGame() { if (this.gameOver) return; this.gameOver = true; this.hideBanner(); const rows = this.gs.players .map((p) => ({ seat: p.seat, pairs: p.pairs })) .sort((a, b) => b.pairs - a.pairs); const winners = this.gs.winnerSeats.map((s) => this.opponentName(s)).join(', '); const unit = this.matchVariant === 4 ? 'book' : 'pair'; const lines = [`Game over — winner: ${winners}`]; for (const r of rows) { lines.push(`${this.opponentName(r.seat)}: ${r.pairs} ${unit}${r.pairs === 1 ? '' : 's'}`); } playSound(this, SFX.CASINO_WIN); new Modal(this, lines.join('\n'), {}).setDepth(D.modal); } }