@ -0,0 +1,692 @@
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 = 140 , ROW0 _Y = 210 ;
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 = 90 , CB _CELL _W = 84 , CB _CELL _H = 44 ;
const CB _RY = [ 792 , 844 , 896 , 948 , 1000 ] ;
const CB _LETTER _X = 540 ;
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 ( ) {
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 ) ;
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 ( ) {
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 = [ ] ;
}
}