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:
parent
3022e0eb23
commit
e46cd1cc2f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue