feat: add Bingo game with AI opponents and physics-based drum

Introduce a new Bingo game featuring a physics-driven drum with Matter.js,
AI opponents that auto-daub and claim wins with suspenseful delays, and a
"claim race" mechanic where the human can beat AI to the punch. Includes
complete game logic, UI components (drum, card, opponent panels, called
numbers board), and server registration for the new game slug.
This commit is contained in:
Brian Fertig 2026-05-25 10:38:08 -06:00
parent 6508a80c94
commit 91dac1f2e3
6 changed files with 892 additions and 1 deletions

View File

@ -0,0 +1,33 @@
// Bingo AI — opponents auto-daub inside BingoLogic.drawBall, so the only AI
// "decision" is the claim race: how long an eligible opponent waits before
// shouting BINGO. That delay is the window in which the human can beat them.
import { hasCompletedLine } from './BingoLogic.js';
// Non-human seats that currently hold a completed line.
export function aiEligibleSeats(state) {
return state.players
.filter((p) => !p.isHuman && hasCompletedLine(p))
.map((p) => p.seat);
}
// Suspense delay (ms) before an eligible AI auto-calls Bingo. Jittered around
// ~1.5s; sharper (higher-skill) opponents react a touch faster.
export function chooseClaimDelayMs(seat, skill = 3) {
const base = 1700 - skill * 120;
return base + Math.random() * 500;
}
// When the human forfeits (advances without claiming) and ≥1 AI is eligible,
// the fastest reactor wins. Tie-break by lowest seat for determinism.
export function pickEarliestAIWinner(state) {
const seats = aiEligibleSeats(state);
if (seats.length === 0) return null;
let best = seats[0];
let bestDelay = chooseClaimDelayMs(best);
for (let i = 1; i < seats.length; i++) {
const d = chooseClaimDelayMs(seats[i]);
if (d < bestDelay) { best = seats[i]; bestDelay = d; }
}
return best;
}

View File

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

View File

@ -0,0 +1,163 @@
// Bingo — pure state engine (no Phaser). 75-ball, 5x5 card, FREE center,
// first completed line (row/column/diagonal) wins. Winner takes the whole pot.
export const LETTERS = ['B', 'I', 'N', 'G', 'O'];
// Inclusive number range per column.
export const COLUMN_RANGES = {
B: [1, 15], I: [16, 30], N: [31, 45], G: [46, 60], O: [61, 75],
};
export function letterForNumber(n) {
return LETTERS[Math.floor((n - 1) / 15)];
}
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Cards are column-major: card[col][row]. Center (col 2, row 2) is the FREE space (null).
export function generateCard() {
const card = [];
const daubed = [];
for (let col = 0; col < 5; col++) {
const [lo, hi] = COLUMN_RANGES[LETTERS[col]];
const pool = [];
for (let n = lo; n <= hi; n++) pool.push(n);
const picks = shuffle(pool).slice(0, 5);
card.push(picks);
daubed.push([false, false, false, false, false]);
}
card[2][2] = null; // FREE center
daubed[2][2] = true; // pre-marked
return { card, daubed };
}
export function createInitialState(opponents, buyIn) {
const players = [];
const human = generateCard();
players.push({
seat: 0, name: 'You', isHuman: true,
card: human.card, daubed: human.daubed, eligible: false, hasWon: false,
});
opponents.forEach((opp, i) => {
const c = generateCard();
players.push({
seat: i + 1, name: opp.name ?? `Player ${i + 1}`, isHuman: false,
card: c.card, daubed: c.daubed, eligible: false, hasWon: false,
});
});
const bag = [];
for (let n = 1; n <= 75; n++) bag.push(n);
return {
phase: 'playing',
players,
bag: shuffle(bag),
called: [],
calledSet: new Set(),
lastBall: null,
winnerSeat: null,
pot: buyIn * players.length,
};
}
// Pop the next ball from the bag, record it, and auto-daub every AI card that
// holds the number. Humans daub manually, so their cards are left untouched.
export function drawBall(state) {
if (state.bag.length === 0) return { ...state, lastBall: null };
const bag = [...state.bag];
const n = bag.pop();
const called = [...state.called, n];
const calledSet = new Set(state.calledSet);
calledSet.add(n);
const players = state.players.map((p) => {
if (p.isHuman) return p;
let changed = false;
const daubed = p.daubed.map((colArr, col) =>
colArr.map((d, row) => {
if (!d && p.card[col][row] === n) { changed = true; return true; }
return d;
}),
);
return changed ? { ...p, daubed } : p;
});
return { ...state, bag, called, calledSet, lastBall: n, players };
}
// Daub one of the human's squares. Valid only when the square's number has been
// called and isn't already daubed. Returns unchanged state otherwise.
export function markHumanSquare(state, col, row) {
const human = state.players[0];
const num = human.card[col][row];
if (num === null) return state;
if (!state.calledSet.has(num)) return state;
if (human.daubed[col][row]) return state;
const daubed = human.daubed.map((c) => [...c]);
daubed[col][row] = true;
const players = [...state.players];
players[0] = { ...human, daubed };
return { ...state, players };
}
// Return the first completed line, or null. Checks rows, then columns, then
// the two diagonals. `cells` are [col, row] pairs for highlighting.
export function detectLine(daubed) {
// Rows
for (let row = 0; row < 5; row++) {
if ([0, 1, 2, 3, 4].every((col) => daubed[col][row])) {
return { type: 'row', index: row, cells: [0, 1, 2, 3, 4].map((col) => [col, row]) };
}
}
// Columns
for (let col = 0; col < 5; col++) {
if ([0, 1, 2, 3, 4].every((row) => daubed[col][row])) {
return { type: 'col', index: col, cells: [0, 1, 2, 3, 4].map((row) => [col, row]) };
}
}
// Diagonals
if ([0, 1, 2, 3, 4].every((i) => daubed[i][i])) {
return { type: 'diag', index: 0, cells: [0, 1, 2, 3, 4].map((i) => [i, i]) };
}
if ([0, 1, 2, 3, 4].every((i) => daubed[i][4 - i])) {
return { type: 'diag', index: 1, cells: [0, 1, 2, 3, 4].map((i) => [i, 4 - i]) };
}
return null;
}
export function hasCompletedLine(player) {
return !!detectLine(player.daubed);
}
// Recompute the `eligible` flag for every player (a completed line exists).
export function recomputeEligibility(state) {
const players = state.players.map((p) => {
const eligible = hasCompletedLine(p);
return eligible === p.eligible ? p : { ...p, eligible };
});
return { ...state, players };
}
// A player claims Bingo. Valid only if they actually have a completed line.
export function resolveClaim(state, seat) {
const player = state.players[seat];
if (!player || !hasCompletedLine(player)) return state;
const players = state.players.map((p) =>
p.seat === seat ? { ...p, hasWon: true } : p,
);
return { ...state, players, winnerSeat: seat, phase: 'won' };
}
export function payout(state) {
return { winnerSeat: state.winnerSeat, amount: state.pot };
}

View File

@ -26,6 +26,7 @@ import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
import HeartsGame from './games/hearts/HeartsGame.js';
import CatanGame from './games/catan/CatanGame.js';
import NertsGame from './games/nerts/NertsGame.js';
import BingoGame from './games/bingo/BingoGame.js';
const config = {
type: Phaser.AUTO,
@ -65,6 +66,7 @@ const config = {
HeartsGame,
CatanGame,
NertsGame,
BingoGame,
],
};

View File

@ -16,7 +16,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -39,3 +39,4 @@ registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop'
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });