import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { api } from '../../services/api.js'; import { createOpponentPortrait } from '../../ui/Portrait.js'; import { playSound, playChipBet, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { createInitialState, drawBall, markHumanSquare, recomputeEligibility, resolveClaim, hasCompletedLine, letterForNumber, LETTERS, } from './BingoLogic.js'; import { aiEligibleSeats, chooseClaimDelayMs, pickEarliestAIWinner } from './BingoAI.js'; // ── Layout constants (1920×1080 "Casino Cage") ───────────────────────────────── const CX = GAME_WIDTH / 2; // 960 // Left column: physics raffle drum + reveal + buttons const DRUM_X = 235, DRUM_Y = 330, DRUM_R = 190, CAGE_TH = 26; const BALL_R = 12, BALL_COUNT = 45, SEG_COUNT = 30; const SPIN_FORCE = 0.0011; // tangential force keeping the balls circulating (tunable) const REVEAL_X = 235, REVEAL_Y = 655, REVEAL_R = 86; // Center: human card const CARD_CX = 960, CELL = 104, CGAP = 10, HEADER_Y = 100, ROW0_Y = 190; const colX = (c) => CARD_CX - (5 * CELL + 4 * CGAP) / 2 + CELL / 2 + c * (CELL + CGAP); // 732 + 114c const rowY = (r) => ROW0_Y + r * (CELL + CGAP); // 210 + 114r // Right: opponent 2×5 mini-panels const OPP_COLX = [1588, 1798]; const OPP_ROWY = [168, 358, 548, 738, 928]; const PW = 196, PH = 176; // Bottom: master called-numbers board (B-I-N-G-O × 1..15) const CB_CX0 = 590, CB_STEP_X = 58, CB_CELL_W = 44, CB_CELL_H = 44; const CB_RY = [792, 844, 896, 948, 1000]; const CB_LETTER_X = 510; const COL_COLORS = { B: 0x4a90d9, I: 0xe05c5c, N: 0xf0e8d0, G: 0x5cb85c, O: 0xf0a830 }; const COL_TEXT = (letter) => (letter === 'N' ? '#1a1208' : '#ffffff'); const D = { bg: -1, felt: 0, drum: 5, ball: 8, card: 10, board: 11, panel: 12, ui: 30, reveal: 40, modal: 50 }; export default class BingoGame extends Phaser.Scene { constructor() { super({ key: 'BingoGame', physics: { default: 'matter', matter: { gravity: { y: 0.9 }, debug: false, positionIterations: 6, velocityIterations: 4 }, }, }); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.cardBack = data.cardBack ?? null; this.activeOpponents = this.opponents.slice(0, 10); this.buyIn = 50; this.globalChips = 0; this.gs = null; this.animating = false; this.spinLevel = 0; // 0 = idle, 1 = full speed this.spinTween = null; this.spinHoldTimer = null; this.drumBalls = []; // { body, view } this.cageSegments = []; this.oppPortraits = []; this.oppPanels = []; // { seat, miniG, mx, my } this.humanCells = []; // [col][row] → { container, bg, daubMark, txt, highlight, number, glowTween } this.calledCells = {}; // number → { cont, rect, txt } this.pendingAIClaim = null; this._bingoPulse = null; this.MatterBody = null; } // ── Lifecycle ─────────────────────────────────────────────────────────────── create() { this.MatterBody = this.matter.body ?? Phaser.Physics?.Matter?.Matter?.Body ?? null; new MusicPlayer(this, this.cache.json.get('music').tracks); this.buildPlayfield(); this.buildParticleTexture(); this.buildDrumVisual(); this.buildDrum(); this.buildPlayerCardFrame(); this.buildOpponentPanels(); this.buildCalledBoard(); this.buildRevealSlot(); this.buildButtons(); this.showBuyInModal(); this.events.once('shutdown', () => this.cleanup()); } update() { if (!this.drumBalls.length || !this.MatterBody) return; const Body = this.MatterBody; const lvl = this.spinLevel; for (const ball of this.drumBalls) { const b = ball.body; if (lvl > 0.001) { const dx = b.position.x - DRUM_X; const dy = b.position.y - DRUM_Y; const len = Math.hypot(dx, dy) || 1; // Tangential force (scaled by spin level) circulates the balls like a spun cage. const fx = ((-dy / len) * SPIN_FORCE + (Math.random() - 0.5) * 0.0003) * lvl; const fy = ((dx / len) * SPIN_FORCE + (Math.random() - 0.5) * 0.0003) * lvl; Body.applyForce(b, b.position, { x: fx, y: fy }); } ball.view.x = b.position.x; ball.view.y = b.position.y; ball.view.rotation = b.angle; } } // ── Background ──────────────────────────────────────────────────────────────── buildPlayfield() { if (this.playfield?.key && this.textures.exists(this.playfield.key)) { this.add.image(CX, GAME_HEIGHT / 2, this.playfield.key) .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); } else { const g = this.add.graphics().setDepth(D.bg); g.fillStyle(0x0a1f14, 1); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); g.fillStyle(0x0d2a1c, 0.6); g.fillEllipse(CX, GAME_HEIGHT / 2, 1700, 900); } } buildParticleTexture() { const g = this.make.graphics({ x: 0, y: 0, add: false }); g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5); g.generateTexture('bingoSpark', 10, 10); g.destroy(); } // ── Drum (physics showpiece) ──────────────────────────────────────────────── buildDrumVisual() { const g = this.add.graphics().setDepth(D.drum); g.fillStyle(0x000000, 0.4); g.fillCircle(DRUM_X + 6, DRUM_Y + 10, DRUM_R + CAGE_TH); g.fillStyle(0x3d1f08, 1); g.fillCircle(DRUM_X, DRUM_Y, DRUM_R + CAGE_TH); // wood ring g.fillStyle(0x07060a, 1); g.fillCircle(DRUM_X, DRUM_Y, DRUM_R + 2); // interior const glass = this.add.graphics().setDepth(D.ball + 1); glass.lineStyle(6, COLORS.accent, 0.9); glass.strokeCircle(DRUM_X, DRUM_Y, DRUM_R); glass.fillStyle(0xffffff, 0.06); glass.fillCircle(DRUM_X - DRUM_R * 0.28, DRUM_Y - DRUM_R * 0.3, DRUM_R * 0.5); this.countText = this.add.text(DRUM_X, 548, 'Press Next to draw', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.ui); } buildDrum() { // Static cage: ring of tangential segments balls can't escape. for (let i = 0; i < SEG_COUNT; i++) { const a = (i / SEG_COUNT) * Math.PI * 2; const cx = DRUM_X + Math.cos(a) * (DRUM_R + CAGE_TH / 2); const cy = DRUM_Y + Math.sin(a) * (DRUM_R + CAGE_TH / 2); const segLen = (2 * Math.PI * (DRUM_R + CAGE_TH / 2) / SEG_COUNT) * 1.4; const seg = this.matter.add.rectangle(cx, cy, segLen, CAGE_TH, { isStatic: true, angle: a + Math.PI / 2 }); this.cageSegments.push(seg); } // Tumbling balls (decorative numbers; the real draw is relabeled on eject). for (let i = 0; i < BALL_COUNT; i++) { const ang = Math.random() * Math.PI * 2; const rad = Math.random() * (DRUM_R - 46); const x = DRUM_X + Math.cos(ang) * rad; const y = DRUM_Y + Math.sin(ang) * rad; const body = this.matter.add.circle(x, y, BALL_R, { restitution: 0.55, friction: 0.01, frictionAir: 0.02 }); const view = this.makeBall(1 + Math.floor(Math.random() * 75), BALL_R).setDepth(D.ball); view.x = x; view.y = y; this.drumBalls.push({ body, view }); } } // A classic bingo ball: colored disc, white face, number. makeBall(n, r) { const letter = letterForNumber(n); const color = COL_COLORS[letter]; const c = this.add.container(0, 0); const g = this.add.graphics(); g.fillStyle(color, 1); g.fillCircle(0, 0, r); g.lineStyle(2, 0xffffff, 0.8); g.strokeCircle(0, 0, r); g.fillStyle(0xffffff, 0.9); g.fillCircle(0, 0, r * 0.58); c.add(g); c.add(this.add.text(0, 0, String(n), { fontFamily: 'Righteous', fontSize: `${Math.round(r * 0.8)}px`, color: '#1a1208', }).setOrigin(0.5)); return c; } // ── Player card ────────────────────────────────────────────────────────────── buildPlayerCardFrame() { // Cream white background behind the card (letters + grid) const cardBg = this.add.graphics().setDepth(D.card - 1); cardBg.fillStyle(0xe4e2b3, 0.95); cardBg.fillRoundedRect(CARD_CX - 310, 400 - 330, 620, 660, 15); for (let c = 0; c < 5; c++) { this.add.text(colX(c), HEADER_Y, LETTERS[c], { fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.card); } for (let col = 0; col < 5; col++) { this.humanCells[col] = []; for (let row = 0; row < 5; row++) { const cont = this.add.container(colX(col), rowY(row)).setDepth(D.card); const bg = this.add.rectangle(0, 0, CELL, CELL, COLORS.panel).setStrokeStyle(2, 0x4a4230); const daubMark = this.add.circle(0, 0, CELL * 0.42, COLORS.gold).setAlpha(0); const txt = this.add.text(0, 0, '', { fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex, }).setOrigin(0.5); const highlight = this.add.rectangle(0, 0, CELL, CELL, 0x000000, 0).setStrokeStyle(5, 0x7cfc00).setVisible(false); cont.add([bg, daubMark, txt, highlight]); cont.setSize(CELL, CELL); cont.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(0, 0, CELL, CELL), hitAreaCallback: Phaser.Geom.Rectangle.Contains, }); cont.input.enabled = false; cont.on('pointerdown', () => this.onCellClick(col, row)); this.humanCells[col][row] = { container: cont, bg, daubMark, txt, highlight, number: null, glowTween: null }; } } } fillPlayerCard() { const human = this.gs.players[0]; for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { const cell = this.humanCells[col][row]; const num = human.card[col][row]; cell.number = num; if (num === null) { cell.txt.setText('FREE').setFontSize(24).setColor(COLORS.goldHex); cell.daubMark.setAlpha(0.6); } else { cell.txt.setText(String(num)).setFontSize(40).setColor(COLORS.textHex); cell.daubMark.setAlpha(0); } } } } setCellGlow(cell, on) { if (cell.container.input) cell.container.input.enabled = on; if (on) { if (cell.glowTween) return; cell.highlight.setVisible(true); cell.glowTween = this.tweens.add({ targets: cell.highlight, alpha: { from: 0.25, to: 1 }, duration: 480, yoyo: true, repeat: -1, }); } else { if (cell.glowTween) { cell.glowTween.stop(); cell.glowTween = null; } cell.highlight.setVisible(false).setAlpha(1); } } // Sync daub visuals and (un)light squares the human can still daub. refreshHumanMarkable() { if (!this.gs) return; const human = this.gs.players[0]; for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { const cell = this.humanCells[col][row]; if (cell.number === null) continue; const daub = human.daubed[col][row]; cell.daubMark.setAlpha(daub ? 0.6 : 0); const markable = !daub && this.gs.calledSet.has(cell.number) && this.gs.phase === 'playing'; this.setCellGlow(cell, markable); } } } onCellClick(col, row) { if (!this.gs || this.gs.phase !== 'playing') return; const before = this.gs; this.gs = markHumanSquare(this.gs, col, row); if (this.gs === before) return; playChipBet(this); this.gs = recomputeEligibility(this.gs); this.refreshHumanMarkable(); this.refreshClaimButton(); } // ── Opponent panels ──────────────────────────────────────────────────────────── buildOpponentPanels() { this.add.text(1693, 44, 'Opponents', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.ui); for (let i = 0; i < this.activeOpponents.length; i++) { const col = i % 2, row = Math.floor(i / 2); const px = OPP_COLX[col], py = OPP_ROWY[row]; const opp = this.activeOpponents[i]; this.add.rectangle(px, py, PW, PH, 0x000000, 0.6).setStrokeStyle(1, 0x8a7050).setDepth(D.panel); // Cream white background behind the mini bingo card const miniW = 90, miniH = 90; this.add.rectangle(px + 28, py + 8, miniW, miniH, 0xe4e2b3, 0.95) .setDepth(D.panel + 1); this.add.text(px, py - PH / 2 + 16, opp.name ?? `Player ${i + 1}`, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.panel + 4); this.oppPortraits.push(createOpponentPortrait(this, opp, px - 54, py + 6, 28, D.panel + 1)); const miniG = this.add.graphics().setDepth(D.panel + 4); this.oppPanels.push({ seat: i + 1, miniG, mx: px - 8, my: py - 28 }); } } renderOpponentMinis() { if (!this.gs) return; const MC = 13, STEP = 15; for (const panel of this.oppPanels) { const p = this.gs.players[panel.seat]; const g = panel.miniG; g.clear(); for (let col = 0; col < 5; col++) { for (let row = 0; row < 5; row++) { const x = panel.mx + col * STEP, y = panel.my + row * STEP; const daub = p.daubed[col][row]; g.fillStyle(daub ? COLORS.gold : 0x141414, daub ? 1 : 0.85); g.fillRect(x, y, MC, MC); g.lineStyle(1, 0x000000, 0.6); g.strokeRect(x, y, MC, MC); } } } } // ── Called-numbers board ──────────────────────────────────────────────────────── buildCalledBoard() { // Cream white background behind the called numbers board const boardBg = this.add.graphics().setDepth(D.board - 1); boardBg.fillStyle(0x000000, 0.95); boardBg.fillRoundedRect(970 - 490, 892 - 150, 980, 300, 5); this.add.text(CB_LETTER_X - 24, 762, 'Called Numbers', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0, 0.5).setDepth(D.ui); for (let r = 0; r < 5; r++) { this.add.text(CB_LETTER_X, CB_RY[r], LETTERS[r], { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.board); for (let c = 0; c < 15; c++) { const n = r * 15 + c + 1; const x = CB_CX0 + c * CB_STEP_X, y = CB_RY[r]; const cont = this.add.container(x, y).setDepth(D.board); const rect = this.add.rectangle(0, 0, CB_CELL_W, CB_CELL_H, COLORS.panel).setStrokeStyle(1, 0x3a3320); const txt = this.add.text(0, 0, String(n), { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5); cont.add([rect, txt]); this.calledCells[n] = { cont, rect, txt }; } } } highlightCalledCell(n) { const cell = this.calledCells[n]; if (!cell) return; cell.rect.setFillStyle(COLORS.gold, 1); cell.txt.setColor('#1a1208'); this.tweens.add({ targets: cell.cont, scale: { from: 1.4, to: 1 }, duration: 350, ease: 'Back.easeOut' }); } // ── Reveal slot + fireworks ────────────────────────────────────────────────────── buildRevealSlot() { this.revealContainer = this.add.container(REVEAL_X, REVEAL_Y).setDepth(D.reveal); } renderReveal(n) { this.revealContainer.removeAll(true); const letter = letterForNumber(n); const color = COL_COLORS[letter]; const tc = COL_TEXT(letter); const g = this.add.graphics(); g.fillStyle(0x000000, 0.3); g.fillCircle(4, 6, REVEAL_R); g.fillStyle(color, 1); g.fillCircle(0, 0, REVEAL_R); g.lineStyle(5, 0xffffff, 0.9); g.strokeCircle(0, 0, REVEAL_R); g.fillStyle(0xffffff, 0.18); g.fillEllipse(-REVEAL_R * 0.3, -REVEAL_R * 0.4, REVEAL_R * 0.9, REVEAL_R * 0.5); this.revealContainer.add(g); this.revealContainer.add(this.add.text(0, -REVEAL_R * 0.42, letter, { fontFamily: 'Righteous', fontSize: '40px', color: tc, stroke: '#000000', strokeThickness: 4, }).setOrigin(0.5)); this.revealContainer.add(this.add.text(0, REVEAL_R * 0.2, String(n), { fontFamily: 'Righteous', fontSize: '76px', color: tc, stroke: '#000000', strokeThickness: 5, }).setOrigin(0.5)); this.revealContainer.setScale(0.2); this.tweens.add({ targets: this.revealContainer, scale: 1, duration: 300, ease: 'Back.easeOut' }); } fireworks(x, y) { const emitter = this.add.particles(x, y, 'bingoSpark', { speed: { min: 60, max: 200 }, lifespan: 950, scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 14, frequency: -1, tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], angle: { min: 0, max: 360 }, gravityY: 60, }).setDepth(D.reveal + 1); const bursts = [ { x: x - 70, y: y - 10 }, { x: x + 70, y: y - 10 }, { x, y: y - 30 }, { x: x - 40, y: y + 30 }, { x: x + 40, y: y + 30 }, ]; bursts.forEach((b, i) => this.time.delayedCall(i * 150, () => emitter.emitParticleAt(b.x, b.y, 14))); this.time.delayedCall(2200, () => emitter.destroy()); } screenFireworks() { const pts = [ { x: 520, y: 300 }, { x: 960, y: 220 }, { x: 1400, y: 320 }, { x: 720, y: 500 }, { x: 1180, y: 480 }, { x: 960, y: 380 }, ]; pts.forEach((pt, i) => this.time.delayedCall(i * 220, () => this.fireworks(pt.x, pt.y))); } // ── Buttons ────────────────────────────────────────────────────────────────── buildButtons() { this.bingoBtn = new Button(this, DRUM_X, 838, 'BINGO!', () => this.onHumanClaim(), { width: 360, height: 60, bg: COLORS.gold, fontSize: 30, }).setDepth(D.ui).setVisible(false); this.nextBtn = new Button(this, DRUM_X, 916, 'Next Bingo Ball', () => this.onNextBall(), { width: 360, height: 64, fontSize: 26, }).setDepth(D.ui); this.nextBtn.setEnabled(false); new Button(this, DRUM_X, 998, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 200, height: 44, }).setDepth(D.ui); } refreshClaimButton() { const eligible = !!(this.gs && this.gs.phase === 'playing' && hasCompletedLine(this.gs.players[0])); this.bingoBtn.setVisible(eligible); if (eligible && !this._bingoPulse) { this._bingoPulse = this.tweens.add({ targets: this.bingoBtn, scale: { from: 1, to: 1.08 }, duration: 420, yoyo: true, repeat: -1, }); } else if (!eligible && this._bingoPulse) { this._bingoPulse.stop(); this._bingoPulse = null; this.bingoBtn.setScale(1); } } // ── Buy-in ─────────────────────────────────────────────────────────────────── async showBuyInModal() { try { const { chips } = await api.get('/profile/chips'); this.globalChips = chips; } catch { this.globalChips = 0; } const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.75).setDepth(D.modal); const panel = this.add.rectangle(CX, GAME_HEIGHT / 2, 540, 340, COLORS.panel) .setStrokeStyle(2, COLORS.accent).setDepth(D.modal); const title = this.add.text(CX, GAME_HEIGHT / 2 - 120, 'Bingo', { fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.modal); const balanceTxt = this.add.text(CX, GAME_HEIGHT / 2 - 60, `Your balance: $${this.globalChips.toLocaleString()}`, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.modal); const buyInTxt = this.add.text(CX, GAME_HEIGHT / 2 - 18, `Buy-in: $${this.buyIn} · Winner takes all`, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#ffd700', }).setOrigin(0.5).setDepth(D.modal); const modalItems = [overlay, panel, title, balanceTxt, buyInTxt]; if (this.globalChips < this.buyIn) { this.add.text(CX, GAME_HEIGHT / 2 + 50, 'Not enough chips! Return to the menu.', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex, }).setOrigin(0.5).setDepth(D.modal); new Button(this, CX, GAME_HEIGHT / 2 + 110, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 200, }).setDepth(D.modal); return; } const startBtn = new Button(this, CX, GAME_HEIGHT / 2 + 90, `Buy In — $${this.buyIn}`, async () => { startBtn.setEnabled(false); try { const { chips } = await api.post('/profile/chips/adjust', { delta: -this.buyIn }); this.globalChips = chips; for (const item of modalItems) item.destroy(); startBtn.destroy(); this.beginGame(); } catch { startBtn.setEnabled(true); } }, { width: 260 }).setDepth(D.modal); } beginGame() { this.gs = createInitialState(this.activeOpponents, this.buyIn); this.fillPlayerCard(); this.renderOpponentMinis(); this.refreshHumanMarkable(); this.refreshClaimButton(); this.nextBtn.setEnabled(true); } // ── Draw flow ──────────────────────────────────────────────────────────────── onNextBall() { if (!this.gs || this.gs.phase !== 'playing' || this.animating) return; // Forfeit gate: advancing while an opponent has an unclaimed line hands them the win. const aiElig = aiEligibleSeats(this.gs); if (aiElig.length > 0) { this.onAIWin(pickEarliestAIWinner(this.gs)); return; } if (this.gs.bag.length === 0) return; this.animating = true; this.nextBtn.setEnabled(false); this.countText.setText('Spinning…'); // Spin up to full speed (~2s), hold there for 5s, then draw a ball. this.setSpin(1, 2000, 'Quad.easeIn', () => { this.spinHoldTimer = this.time.delayedCall(5000, () => { this.spinHoldTimer = null; this.drawAndEject(); }); }); } drawAndEject() { this.gs = drawBall(this.gs); this.gs = recomputeEligibility(this.gs); const n = this.gs.lastBall; this.renderOpponentMinis(); this.countText.setText(`Ball ${this.gs.called.length} of 75`); this.ejectBall(n); } // Ramp the drum's spin level (0 idle ↔ 1 full speed). Cancels any prior ramp. setSpin(target, duration, ease, onComplete) { if (this.spinTween) { this.spinTween.stop(); this.spinTween = null; } this.spinTween = this.tweens.add({ targets: this, spinLevel: target, duration, ease, onComplete: () => { this.spinTween = null; if (onComplete) onComplete(); }, }); } ejectBall(n) { const reveal = () => { this.renderReveal(n); this.highlightCalledCell(n); playSound(this, SFX.CARD_SHOW); this.fireworks(REVEAL_X, REVEAL_Y); this.refreshHumanMarkable(); this.refreshClaimButton(); this.armAISuspense(); this.setSpin(0, 1500, 'Quad.easeOut'); // wind the drum back down to idle this.animating = false; if (this.gs.phase === 'playing') this.nextBtn.setEnabled(true); }; if (this.drumBalls.length === 0) { reveal(); return; } // Eject the ball nearest the top of the drum. let idx = 0, best = Infinity; for (let i = 0; i < this.drumBalls.length; i++) { const py = this.drumBalls[i].body.position.y; if (py < best) { best = py; idx = i; } } const ball = this.drumBalls.splice(idx, 1)[0]; this.matter.world.remove(ball.body); const sx = ball.view.x, sy = ball.view.y; ball.view.destroy(); playSound(this, SFX.DICE_ROLL); // Hand off from physics to a tween, relabeled to the real drawn number. const clone = this.makeBall(n, BALL_R).setDepth(D.reveal - 1); clone.x = sx; clone.y = sy; this.tweens.add({ targets: clone, x: REVEAL_X, y: REVEAL_Y, scale: 2.6, duration: 680, ease: 'Cubic.easeIn', onComplete: () => { clone.destroy(); reveal(); }, }); } // ── Claim race ─────────────────────────────────────────────────────────────── armAISuspense() { if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } if (this.gs.phase !== 'playing') return; const seats = aiEligibleSeats(this.gs); if (seats.length === 0) return; const winner = pickEarliestAIWinner(this.gs); this.pendingAIClaim = this.time.delayedCall(chooseClaimDelayMs(winner), () => { this.pendingAIClaim = null; if (this.gs.phase === 'playing') this.onAIWin(winner); }); } onHumanClaim() { if (!this.gs || this.gs.phase !== 'playing') return; if (!hasCompletedLine(this.gs.players[0])) return; if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } this.gs = resolveClaim(this.gs, 0); this.refreshClaimButton(); this.nextBtn.setEnabled(false); this.reactOpponents(0); this.showBanner('BINGO!'); this.screenFireworks(); playSound(this, SFX.CASINO_WIN); this.time.delayedCall(1500, () => this.showGameOver(true)); } onAIWin(seat) { if (!this.gs || this.gs.phase !== 'playing') return; if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } this.gs = resolveClaim(this.gs, seat); this.refreshClaimButton(); this.nextBtn.setEnabled(false); const name = this.gs.players[seat]?.name ?? 'Opponent'; this.reactOpponents(seat); this.showBanner(`${name} shouts BINGO!`); playSound(this, SFX.CASINO_LOSE); this.time.delayedCall(1600, () => this.showGameOver(false)); } reactOpponents(winnerSeat) { this.oppPortraits.forEach((p, i) => { const seat = i + 1; p?.playEmotion?.(seat === winnerSeat ? 'happy' : 'upset'); }); } showBanner(text) { const b = this.add.text(CARD_CX, 70, text, { fontFamily: 'Righteous', fontSize: '46px', color: '#ffd700', stroke: '#000000', strokeThickness: 6, }).setOrigin(0.5).setDepth(D.modal - 1).setAlpha(0); this.tweens.add({ targets: b, alpha: 1, scale: { from: 0.7, to: 1 }, duration: 320, ease: 'Back.easeOut' }); } // ── Game over ────────────────────────────────────────────────────────────────── async showGameOver(won) { const pot = this.gs?.pot ?? 0; if (won) { try { const { chips } = await api.post('/profile/chips/adjust', { delta: pot }); this.globalChips = chips; } catch { /* keep going */ } } const winnerSeat = this.gs?.winnerSeat; const winnerName = winnerSeat != null ? this.gs.players[winnerSeat]?.name : null; const net = won ? pot - this.buyIn : -this.buyIn; const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.8).setDepth(D.modal); const panel = this.add.rectangle(CX, GAME_HEIGHT / 2, 600, 380, COLORS.panel) .setStrokeStyle(2, COLORS.accent).setDepth(D.modal); this.add.text(CX, GAME_HEIGHT / 2 - 130, won ? 'BINGO! You win!' : `${winnerName} got Bingo!`, { fontFamily: 'Righteous', fontSize: '42px', color: won ? '#ffd700' : COLORS.textHex, }).setOrigin(0.5).setDepth(D.modal); this.add.text(CX, GAME_HEIGHT / 2 - 64, won ? `+$${net} profit` : `-$${Math.abs(net)} loss`, { fontFamily: '"Julius Sans One"', fontSize: '28px', color: won ? COLORS.accentHex : COLORS.dangerHex, }).setOrigin(0.5).setDepth(D.modal); this.add.text(CX, GAME_HEIGHT / 2 - 14, `Your balance: $${this.globalChips.toLocaleString()}`, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.modal); new Button(this, CX - 110, GAME_HEIGHT / 2 + 100, 'Play Again', () => this.scene.restart(), { width: 200, }).setDepth(D.modal); new Button(this, CX + 110, GAME_HEIGHT / 2 + 100, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 200, }).setDepth(D.modal); } // ── Cleanup ────────────────────────────────────────────────────────────────── cleanup() { if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } if (this.spinTween) { this.spinTween.stop(); this.spinTween = null; } if (this.spinHoldTimer) { this.spinHoldTimer.remove(false); this.spinHoldTimer = null; } for (const p of this.oppPortraits) p?.destroy?.(); for (const ball of this.drumBalls) { try { this.matter.world.remove(ball.body); } catch { /* noop */ } } this.drumBalls = []; } }