feat(bingo): add countdown timer, opponent match flash, and fullscreen fix

- Add a 10-second countdown timer between ball draws with a dedicated UI overlay
- Implement visual flash effect on opponent panels when their cards match the called number
- Play happy emotion animation on opponent portraits during matches
- Replace CASINO_WIN/LOSE sounds with CASINO_BLACKJACK for win/loss events
- Fix fullscreen toggle to properly handle exitFullscreen and requestFullscreen
- Add new video assets for opponent emotions (happy, idle, upset)
This commit is contained in:
Brian Fertig 2026-05-25 13:21:26 -06:00
parent 3022e0eb23
commit e46cd1cc2f
8 changed files with 140 additions and 6 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -75,6 +75,13 @@ export default class BingoGame extends Phaser.Scene {
this.pendingAIClaim = null; this.pendingAIClaim = null;
this._bingoPulse = null; this._bingoPulse = null;
this.MatterBody = null; this.MatterBody = null;
// Countdown timer
this.countdownActive = false;
this.countdownValue = 10;
this.countdownEvent = null;
this.countdownContainer = null;
this.countdownDisplay = null;
} }
// ── Lifecycle ─────────────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────────────
@ -92,6 +99,7 @@ export default class BingoGame extends Phaser.Scene {
this.buildCalledBoard(); this.buildCalledBoard();
this.buildRevealSlot(); this.buildRevealSlot();
this.buildButtons(); this.buildButtons();
this.buildCountdownWindow();
this.showBuyInModal(); this.showBuyInModal();
this.events.once('shutdown', () => this.cleanup()); this.events.once('shutdown', () => this.cleanup());
@ -306,7 +314,7 @@ export default class BingoGame extends Phaser.Scene {
const px = OPP_COLX[col], py = OPP_ROWY[row]; const px = OPP_COLX[col], py = OPP_ROWY[row];
const opp = this.activeOpponents[i]; const opp = this.activeOpponents[i];
this.add.rectangle(px, py, PW, PH, 0x000000, 0.6).setStrokeStyle(1, 0x8a7050).setDepth(D.panel); 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 // Cream white background behind the mini bingo card
const miniW = 90, miniH = 90; const miniW = 90, miniH = 90;
@ -317,10 +325,11 @@ export default class BingoGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.panel + 4); }).setOrigin(0.5).setDepth(D.panel + 4);
this.oppPortraits.push(createOpponentPortrait(this, opp, px - 54, py + 6, 28, D.panel + 1)); 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); const miniG = this.add.graphics().setDepth(D.panel + 4);
this.oppPanels.push({ seat: i + 1, miniG, mx: px - 8, my: py - 28 }); this.oppPanels.push({ seat: i + 1, miniG, mx: px - 8, my: py - 28, bgRect, portrait });
} }
} }
@ -522,6 +531,7 @@ export default class BingoGame extends Phaser.Scene {
// ── Draw flow ──────────────────────────────────────────────────────────────── // ── Draw flow ────────────────────────────────────────────────────────────────
onNextBall() { onNextBall() {
this.stopCountdown();
if (!this.gs || this.gs.phase !== 'playing' || this.animating) return; if (!this.gs || this.gs.phase !== 'playing' || this.animating) return;
// Forfeit gate: advancing while an opponent has an unclaimed line hands them the win. // Forfeit gate: advancing while an opponent has an unclaimed line hands them the win.
@ -570,9 +580,11 @@ export default class BingoGame extends Phaser.Scene {
this.refreshHumanMarkable(); this.refreshHumanMarkable();
this.refreshClaimButton(); this.refreshClaimButton();
this.armAISuspense(); this.armAISuspense();
this.flashOpponentMatches(n);
this.setSpin(0, 1500, 'Quad.easeOut'); // wind the drum back down to idle this.setSpin(0, 1500, 'Quad.easeOut'); // wind the drum back down to idle
this.animating = false; this.animating = false;
if (this.gs.phase === 'playing') this.nextBtn.setEnabled(true); if (this.gs.phase === 'playing') this.nextBtn.setEnabled(true);
this.startCountdown();
}; };
if (this.drumBalls.length === 0) { reveal(); return; } if (this.drumBalls.length === 0) { reveal(); return; }
@ -598,6 +610,117 @@ export default class BingoGame extends Phaser.Scene {
}); });
} }
// ── 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 ─────────────────────────────────────────────────────────────── // ── Claim race ───────────────────────────────────────────────────────────────
armAISuspense() { armAISuspense() {
@ -615,6 +738,7 @@ export default class BingoGame extends Phaser.Scene {
onHumanClaim() { onHumanClaim() {
if (!this.gs || this.gs.phase !== 'playing') return; if (!this.gs || this.gs.phase !== 'playing') return;
if (!hasCompletedLine(this.gs.players[0])) return; if (!hasCompletedLine(this.gs.players[0])) return;
this.stopCountdown();
if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; }
this.gs = resolveClaim(this.gs, 0); this.gs = resolveClaim(this.gs, 0);
@ -623,12 +747,13 @@ export default class BingoGame extends Phaser.Scene {
this.reactOpponents(0); this.reactOpponents(0);
this.showBanner('BINGO!'); this.showBanner('BINGO!');
this.screenFireworks(); this.screenFireworks();
playSound(this, SFX.CASINO_WIN); playSound(this, SFX.CASINO_BLACKJACK);
this.time.delayedCall(1500, () => this.showGameOver(true)); this.time.delayedCall(1500, () => this.showGameOver(true));
} }
onAIWin(seat) { onAIWin(seat) {
if (!this.gs || this.gs.phase !== 'playing') return; if (!this.gs || this.gs.phase !== 'playing') return;
this.stopCountdown();
if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; }
this.gs = resolveClaim(this.gs, seat); this.gs = resolveClaim(this.gs, seat);
@ -637,7 +762,7 @@ export default class BingoGame extends Phaser.Scene {
const name = this.gs.players[seat]?.name ?? 'Opponent'; const name = this.gs.players[seat]?.name ?? 'Opponent';
this.reactOpponents(seat); this.reactOpponents(seat);
this.showBanner(`${name} shouts BINGO!`); this.showBanner(`${name} shouts BINGO!`);
playSound(this, SFX.CASINO_LOSE); playSound(this, SFX.CASINO_BLACKJACK);
this.time.delayedCall(1600, () => this.showGameOver(false)); this.time.delayedCall(1600, () => this.showGameOver(false));
} }
@ -686,6 +811,8 @@ export default class BingoGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.modal); }).setOrigin(0.5).setDepth(D.modal);
this.stopCountdown();
new Button(this, CX - 110, GAME_HEIGHT / 2 + 100, 'Play Again', () => this.scene.restart(), { new Button(this, CX - 110, GAME_HEIGHT / 2 + 100, 'Play Again', () => this.scene.restart(), {
width: 200, width: 200,
}).setDepth(D.modal); }).setDepth(D.modal);
@ -697,6 +824,7 @@ export default class BingoGame extends Phaser.Scene {
// ── Cleanup ────────────────────────────────────────────────────────────────── // ── Cleanup ──────────────────────────────────────────────────────────────────
cleanup() { cleanup() {
this.stopCountdown();
if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; } if (this.pendingAIClaim) { this.pendingAIClaim.remove(false); this.pendingAIClaim = null; }
if (this.spinTween) { this.spinTween.stop(); this.spinTween = null; } if (this.spinTween) { this.spinTween.stop(); this.spinTween = null; }
if (this.spinHoldTimer) { this.spinHoldTimer.remove(false); this.spinHoldTimer = null; } if (this.spinHoldTimer) { this.spinHoldTimer.remove(false); this.spinHoldTimer = null; }

View File

@ -8,7 +8,13 @@ export function addFullscreenButton(scene) {
GAME_WIDTH - width / 2 - 30, GAME_WIDTH - width / 2 - 30,
52, 52,
'Toggle Fullscreen', 'Toggle Fullscreen',
() => scene.scale.toggleFullscreen(), () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
},
{ width, height: 52, fontSize: 22, variant: 'ghost' }, { width, height: 52, fontSize: 22, variant: 'ghost' },
).setDepth(100); ).setDepth(100);
} }