838 lines
33 KiB
JavaScript
838 lines
33 KiB
JavaScript
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;
|
||
|
||
// Countdown timer
|
||
this.countdownActive = false;
|
||
this.countdownValue = 10;
|
||
this.countdownEvent = null;
|
||
this.countdownContainer = null;
|
||
this.countdownDisplay = 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.buildCountdownWindow();
|
||
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;
|
||
playSound(this, SFX.CASINO_WIN);
|
||
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];
|
||
|
||
const bgRect = 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);
|
||
|
||
const portrait = createOpponentPortrait(this, opp, px - 54, py + 6, 28, D.panel + 1);
|
||
this.oppPortraits.push(portrait);
|
||
|
||
const miniG = this.add.graphics().setDepth(D.panel + 4);
|
||
this.oppPanels.push({ seat: i + 1, miniG, mx: px - 8, my: py - 28, bgRect, portrait });
|
||
}
|
||
}
|
||
|
||
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() {
|
||
this.stopCountdown();
|
||
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…');
|
||
playSound(this, SFX.BINGO_BALLS);
|
||
// 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.flashOpponentMatches(n);
|
||
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);
|
||
this.startCountdown();
|
||
};
|
||
|
||
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(); },
|
||
});
|
||
}
|
||
|
||
// ── Opponent match flash ─────────────────────────────────────────────────────
|
||
|
||
flashOpponentMatches(n) {
|
||
if (!this.gs || this.gs.phase !== 'playing') return;
|
||
|
||
const letter = letterForNumber(n);
|
||
const col = LETTERS.indexOf(letter);
|
||
const matches = [];
|
||
|
||
for (let i = 0; i < this.gs.players.length; i++) {
|
||
const p = this.gs.players[i];
|
||
if (p.isHuman) continue;
|
||
for (let row = 0; row < 5; row++) {
|
||
if (p.card[col][row] === n) {
|
||
matches.push({ seat: i });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (matches.length === 0) return;
|
||
|
||
const FLASH_DURATION = 2000;
|
||
const GAP = 200;
|
||
|
||
matches.forEach((match, index) => {
|
||
const panel = this.oppPanels.find(p => p.seat === match.seat);
|
||
if (!panel || !panel.bgRect) return;
|
||
|
||
const startDelay = index * (FLASH_DURATION + GAP);
|
||
|
||
// Flash to green + play happy video
|
||
this.time.delayedCall(startDelay, () => {
|
||
panel.bgRect.setFillStyle(0x2d7a4e, 1);
|
||
playSound(this, SFX.CASINO_LOSE);
|
||
panel.portrait?.playEmotion?.('happy');
|
||
});
|
||
|
||
// Flash back to black
|
||
this.time.delayedCall(startDelay + FLASH_DURATION, () => {
|
||
panel.bgRect.setFillStyle(0x000000, 0.6);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Countdown timer ──────────────────────────────────────────────────────────
|
||
|
||
buildCountdownWindow() {
|
||
const winW = 340, winH = 120;
|
||
const winX = DRUM_X, winY = 804;
|
||
|
||
this.countdownContainer = this.add.container(winX, winY).setDepth(D.ui);
|
||
|
||
// Black rounded background with white border
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x000000, 0.9);
|
||
bg.fillRoundedRect(-winW / 2, -winH / 2, winW, winH, 10);
|
||
bg.lineStyle(2, 0xffffff, 0.5);
|
||
bg.strokeRoundedRect(-winW / 2, -winH / 2, winW, winH, 10);
|
||
this.countdownContainer.add(bg);
|
||
|
||
// Header text
|
||
const header = this.add.text(0, -25, 'Next Ball Countdown', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5);
|
||
this.countdownContainer.add(header);
|
||
|
||
// Timer number display
|
||
this.countdownDisplay = this.add.text(0, 25, '05', {
|
||
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5);
|
||
this.countdownContainer.add(this.countdownDisplay);
|
||
|
||
this.countdownContainer.setVisible(false);
|
||
}
|
||
|
||
startCountdown() {
|
||
if (this.countdownActive) return;
|
||
if (!this.gs || this.gs.phase !== 'playing') return;
|
||
if (this.gs.bag.length === 0) return;
|
||
|
||
this.countdownActive = true;
|
||
this.countdownValue = 10;
|
||
this.countdownContainer.setVisible(true);
|
||
this.countdownDisplay.setText('05');
|
||
|
||
this.countdownEvent = this.time.addEvent({
|
||
delay: 1000,
|
||
callback: () => {
|
||
if (!this.countdownActive) return;
|
||
this.countdownValue--;
|
||
this.countdownDisplay.setText(String(this.countdownValue).padStart(2, '0'));
|
||
|
||
if (this.countdownValue <= 0) {
|
||
this.countdownActive = false;
|
||
this.countdownContainer.setVisible(false);
|
||
this.onNextBall();
|
||
}
|
||
},
|
||
repeat: 9,
|
||
});
|
||
}
|
||
|
||
stopCountdown() {
|
||
if (this.countdownEvent) {
|
||
this.countdownEvent.remove();
|
||
this.countdownEvent = null;
|
||
}
|
||
this.countdownActive = false;
|
||
this.countdownContainer?.setVisible(false);
|
||
}
|
||
|
||
// ── 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;
|
||
this.stopCountdown();
|
||
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_BLACKJACK);
|
||
this.time.delayedCall(1500, () => this.showGameOver(true));
|
||
}
|
||
|
||
onAIWin(seat) {
|
||
if (!this.gs || this.gs.phase !== 'playing') return;
|
||
this.stopCountdown();
|
||
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_BLACKJACK);
|
||
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);
|
||
|
||
this.stopCountdown();
|
||
|
||
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() {
|
||
this.stopCountdown();
|
||
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 = [];
|
||
}
|
||
}
|