fertig-classic-games/public/src/games/bingo/BingoGame.js

709 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = [];
}
}