feat: add best-of-3 match mode, countdown sequences, and new audio

Genius Square:
- Best-of-3 match mode with round tracking and score pips
- Inter-round overlay with "Next Round" button
- Match-end overlay with Play Again / Menu options
- Round display showing "ROUND X OF 3" at top

Wordle:
- 3-2-1-GO countdown before AI starts
- Adjusted AI delay timings (slower across all skill levels)
- Added victory/round win sound feedback
- Changed tile flip sound to UI_ACTIVATE

Audio:
- New countdown tick and go sound effects
- 3 new music tracks (Bryan Adams, The Midnight, Khruangbin)

Boggle:
- Minor button position adjustment
This commit is contained in:
Brian Fertig 2026-06-20 10:42:28 -06:00
parent 02fda99f87
commit d22f6ff902
12 changed files with 192 additions and 32 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -64,6 +64,21 @@
"file": "track13.mp3", "file": "track13.mp3",
"artist": "Dance All Night", "artist": "Dance All Night",
"title": "Michael Jackson" "title": "Michael Jackson"
},
{
"file": "track14.mp3",
"artist": "Growin' Up in the Heartland",
"title": "Bryan Adams"
},
{
"file": "track15.mp3",
"artist": "Synthetic Highway",
"title": "The Midnight"
},
{
"file": "track16.mp3",
"artist": "Surf Sundown",
"title": "Khruangbin"
} }
] ]
} }

View File

@ -538,7 +538,7 @@ export default class BoggleGame extends Phaser.Scene {
} }
buildControls() { buildControls() {
const y = ORIGIN_Y + SPAN + 150; const y = ORIGIN_Y + SPAN + 110;
const submit = new Button(this, TRAY_CX + 120, y, 'Submit Word', const submit = new Button(this, TRAY_CX + 120, y, 'Submit Word',
() => this.submitWord(), { width: 220, height: 64, fontSize: 26 }); () => this.submitWord(), { width: 220, height: 64, fontSize: 26 });
submit.setDepth(D.ui); submit.setDepth(D.ui);

View File

@ -69,6 +69,10 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.skill = data.opponents?.[0]?.skill ?? 3; this.skill = data.opponents?.[0]?.skill ?? 3;
this.playfield = data.playfield ?? null; this.playfield = data.playfield ?? null;
this.humanWins = data.humanWins ?? 0;
this.aiWins = data.aiWins ?? 0;
this.roundNum = data.roundNum ?? 1;
this.blockers = null; this.blockers = null;
this.humanBoard = null; this.humanBoard = null;
this.aiBoard = null; this.aiBoard = null;
@ -113,6 +117,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
this._buildGridGraphics(); this._buildGridGraphics();
this._buildLabels(); this._buildLabels();
this._buildPortraits(); this._buildPortraits();
this._buildRoundDisplay();
this._buildTray(); this._buildTray();
this._buildHints(); this._buildHints();
@ -250,6 +255,35 @@ export default class GeniusSquareGame extends Phaser.Scene {
} }
} }
_buildRoundDisplay() {
const cx = GAME_WIDTH / 2;
const py = 74;
this.add.text(cx, py, `ROUND ${this.roundNum} OF 3`, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
// Human win pips (left of center)
for (let i = 0; i < 2; i++) {
const filled = i < this.humanWins;
const gfx = this.add.graphics().setDepth(D.ui);
gfx.fillStyle(filled ? 0x45d17a : 0x2a2a4a, 1);
gfx.fillCircle(700 + i * 24, py, 8);
gfx.lineStyle(2, filled ? 0x45d17a : 0x444466, 1);
gfx.strokeCircle(700 + i * 24, py, 8);
}
// AI win pips (right of center)
for (let i = 0; i < 2; i++) {
const filled = i < this.aiWins;
const gfx = this.add.graphics().setDepth(D.ui);
gfx.fillStyle(filled ? COLORS.danger : 0x2a2a4a, 1);
gfx.fillCircle(1196 + i * 24, py, 8);
gfx.lineStyle(2, filled ? COLORS.danger : 0x444466, 1);
gfx.strokeCircle(1196 + i * 24, py, 8);
}
}
_buildTray() { _buildTray() {
const trayLeft = HCX - (SLOT_W * 1.5); // left edge of 3-column tray const trayLeft = HCX - (SLOT_W * 1.5); // left edge of 3-column tray
@ -675,6 +709,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
} }
label.setText(steps[i]).setScale(1.6).setAlpha(1); label.setText(steps[i]).setScale(1.6).setAlpha(1);
playSound(this, steps[i] === 'GO!' ? SFX.COUNTDOWN_GO : SFX.COUNTDOWN_TICK);
this.tweens.add({ this.tweens.add({
targets: label, scaleX: 1, scaleY: 1, targets: label, scaleX: 1, scaleY: 1,
@ -732,59 +767,116 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.gameOver = true; this.gameOver = true;
this._stopAI(); this._stopAI();
this.opponentPortrait?.playEmotion('upset'); this.opponentPortrait?.playEmotion('upset');
playSound(this, SFX.UI_CHIME); this._endRound(true);
playSound(this, SFX.VICTORY_SHORT);
this._recordResult('win');
this._showOverlay(true);
} }
_onAIWin() { _onAIWin() {
if (this.gameOver) return; if (this.gameOver) return;
this.gameOver = true; this.gameOver = true;
this._stopAI();
this.opponentPortrait?.playEmotion('happy'); this.opponentPortrait?.playEmotion('happy');
this._recordResult('loss'); this._endRound(false);
this._showOverlay(false);
} }
_recordResult(result) { _endRound(humanWon) {
api.post('/history/single-player', { if (humanWon) this.humanWins++;
slug: 'geniussquare', score: result === 'win' ? 1 : 0, result, else this.aiWins++;
}).catch(() => {});
const matchOver = this.humanWins >= 2 || this.aiWins >= 2;
if (matchOver) {
const playerWon = this.humanWins > this.aiWins;
playSound(this, playerWon ? SFX.VICTORY_SHORT : SFX.SCIFI_RISER);
api.post('/history/single-player', {
slug: 'geniussquare', score: playerWon ? 1 : 0, result: playerWon ? 'win' : 'loss',
}).catch(() => {});
this._showMatchOverlay(playerWon);
} else {
playSound(this, humanWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this._showInterRoundOverlay(humanWon);
}
} }
_showOverlay(humanWon) { _showInterRoundOverlay(humanWon) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const borderColor = humanWon ? 0x45d17a : COLORS.danger; const borderColor = humanWon ? 0x45d17a : COLORS.danger;
const headline = humanWon ? 'You Win!' : 'Opponent Wins!'; const headline = humanWon ? 'Round Win!' : 'Round Loss!';
const headColor = humanWon ? '#45d17a' : COLORS.dangerHex; const headColor = humanWon ? '#45d17a' : COLORS.dangerHex;
const subline = humanWon ? 'You filled the grid first!' : 'The opponent finished first.'; const subline = humanWon ? 'You filled the grid first!' : 'The opponent finished first.';
// Dim background
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(D.overlay).setInteractive(); .setDepth(D.overlay).setInteractive();
// Panel
const panel = this.add.graphics().setDepth(D.overlay + 1); const panel = this.add.graphics().setDepth(D.overlay + 1);
panel.fillStyle(0x1a1828, 0.97); panel.fillStyle(0x1a1828, 0.97);
panel.fillRoundedRect(cx - 340, cy - 190, 680, 380, 18); panel.fillRoundedRect(cx - 340, cy - 220, 680, 440, 18);
panel.lineStyle(3, borderColor, 1); panel.lineStyle(3, borderColor, 1);
panel.strokeRoundedRect(cx - 340, cy - 190, 680, 380, 18); panel.strokeRoundedRect(cx - 340, cy - 220, 680, 440, 18);
this.add.text(cx, cy - 110, headline, { this.add.text(cx, cy - 150, headline, {
fontFamily: 'Righteous', fontSize: '68px', color: headColor, fontFamily: 'Righteous', fontSize: '68px', color: headColor,
}).setOrigin(0.5).setDepth(D.overlayUI); }).setOrigin(0.5).setDepth(D.overlayUI);
this.add.text(cx, cy - 26, subline, { this.add.text(cx, cy - 70, subline, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI); }).setOrigin(0.5).setDepth(D.overlayUI);
const data = { game: this.gameDef, opponents: this.opponents, playfield: this.playfield }; this.add.text(cx, cy - 12, `${this.humanWins}${this.aiWins}`, {
new Button(this, cx - 170, cy + 100, 'Play Again', fontFamily: 'Righteous', fontSize: '56px', color: COLORS.textHex,
() => this.scene.restart(data), }).setOrigin(0.5).setDepth(D.overlayUI);
this.add.text(cx, cy + 50, `ROUND ${this.roundNum + 1} NEXT`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const nextData = {
game: this.gameDef, opponents: this.opponents, playfield: this.playfield,
humanWins: this.humanWins, aiWins: this.aiWins, roundNum: this.roundNum + 1,
};
new Button(this, cx, cy + 130, 'Next Round',
() => this.scene.restart(nextData),
{ width: 280, height: 58, fontSize: 22 }
).setDepth(D.overlayUI);
}
_showMatchOverlay(playerWon) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const borderColor = playerWon ? 0x45d17a : COLORS.danger;
const headline = playerWon ? 'You Win!' : 'Opponent Wins!';
const headColor = playerWon ? '#45d17a' : COLORS.dangerHex;
const subline = playerWon ? 'You won the best of 3!' : 'The opponent won the best of 3.';
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay + 1);
panel.fillStyle(0x1a1828, 0.97);
panel.fillRoundedRect(cx - 340, cy - 220, 680, 440, 18);
panel.lineStyle(3, borderColor, 1);
panel.strokeRoundedRect(cx - 340, cy - 220, 680, 440, 18);
this.add.text(cx, cy - 150, headline, {
fontFamily: 'Righteous', fontSize: '68px', color: headColor,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.add.text(cx, cy - 70, subline, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.add.text(cx, cy - 12, `${this.humanWins}${this.aiWins}`, {
fontFamily: 'Righteous', fontSize: '56px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.add.text(cx, cy + 50, 'BEST OF 3', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const freshData = { game: this.gameDef, opponents: this.opponents, playfield: this.playfield };
new Button(this, cx - 170, cy + 130, 'Play Again',
() => this.scene.restart(freshData),
{ width: 280, height: 58, fontSize: 22 } { width: 280, height: 58, fontSize: 22 }
).setDepth(D.overlayUI); ).setDepth(D.overlayUI);
new Button(this, cx + 170, cy + 100, 'Menu', new Button(this, cx + 170, cy + 130, 'Menu',
() => this.scene.start('GameMenu'), () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 58, fontSize: 22 } { variant: 'ghost', width: 280, height: 58, fontSize: 22 }
).setDepth(D.overlayUI); ).setDepth(D.overlayUI);

View File

@ -17,11 +17,11 @@
import { evaluateGuess } from './WordleLogic.js'; import { evaluateGuess } from './WordleLogic.js';
const SKILL_PROFILES = { const SKILL_PROFILES = {
1: { openerTier: 0, useFilter: true, blunder: 0.85, maxCandidates: 0, delay: [3800, 6500] }, 1: { openerTier: 0, useFilter: true, blunder: 0.85, maxCandidates: 0, delay: [5500, 9000] },
2: { openerTier: 1, useFilter: true, blunder: 0.52, maxCandidates: 0, delay: [3000, 5000] }, 2: { openerTier: 1, useFilter: true, blunder: 0.52, maxCandidates: 0, delay: [4500, 7000] },
3: { openerTier: 2, useFilter: true, blunder: 0.22, maxCandidates: 10, delay: [2000, 3600] }, 3: { openerTier: 2, useFilter: true, blunder: 0.22, maxCandidates: 10, delay: [3200, 5500] },
4: { openerTier: 3, useFilter: true, blunder: 0.08, maxCandidates: 35, delay: [1100, 2000] }, 4: { openerTier: 3, useFilter: true, blunder: 0.08, maxCandidates: 35, delay: [2000, 3500] },
5: { openerTier: 4, useFilter: true, blunder: 0.03, maxCandidates: 150, delay: [450, 950] }, 5: { openerTier: 4, useFilter: true, blunder: 0.03, maxCandidates: 150, delay: [1200, 2200] },
}; };
// Opener tiers — ordered by expected information gain. // Opener tiers — ordered by expected information gain.

View File

@ -110,9 +110,8 @@ export default class WordleGame extends Phaser.Scene {
this.buildStatusText(); this.buildStatusText();
this.buildScoreDisplay(); this.buildScoreDisplay();
this.buildControls(); this.buildControls();
this.setupInput();
this.scheduleAITurn(); this._startCountdown();
} }
// ── Build ────────────────────────────────────────────────────────────────── // ── Build ──────────────────────────────────────────────────────────────────
@ -279,6 +278,53 @@ export default class WordleGame extends Phaser.Scene {
}).setDepth(DEPTH.ui); }).setDepth(DEPTH.ui);
} }
// ── Countdown ─────────────────────────────────────────────────────────────
_startCountdown() {
const steps = ['3', '2', '1', 'GO!'];
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const OVERLAY_D = DEPTH.ui + 5;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.50)
.setDepth(OVERLAY_D);
const label = this.add.text(cx, cy, '', {
fontFamily: 'Righteous',
fontSize: '240px',
color: '#f7c948',
stroke: '#b08800',
strokeThickness: 8,
}).setOrigin(0.5).setDepth(OVERLAY_D + 1).setAlpha(0);
const runStep = (i) => {
if (i >= steps.length) {
dim.destroy();
label.destroy();
this.setupInput();
this.scheduleAITurn();
return;
}
label.setText(steps[i]).setScale(1.6).setAlpha(1);
playSound(this, steps[i] === 'GO!' ? SFX.COUNTDOWN_GO : SFX.COUNTDOWN_TICK);
this.tweens.add({
targets: label, scaleX: 1, scaleY: 1,
duration: 260, ease: 'Back.easeOut',
});
this.time.delayedCall(steps[i] === 'GO!' ? 560 : 740, () => {
this.tweens.add({
targets: label, alpha: 0, duration: 180,
onComplete: () => runStep(i + 1),
});
});
};
runStep(0);
}
// ── Input ────────────────────────────────────────────────────────────────── // ── Input ──────────────────────────────────────────────────────────────────
setupInput() { setupInput() {
@ -336,7 +382,7 @@ export default class WordleGame extends Phaser.Scene {
await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation); await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation);
this.updateKeyboardColors(this.gs); this.updateKeyboardColors(this.gs);
playSound(this, SFX.PENCIL_WRITE); playSound(this, SFX.UI_ACTIVATE);
const { over } = isGameOver(this.gs); const { over } = isGameOver(this.gs);
if (over) { if (over) {
@ -513,8 +559,11 @@ export default class WordleGame extends Phaser.Scene {
this.time.delayedCall(700, () => { this.time.delayedCall(700, () => {
if (this.playerWins >= 3 || this.aiWins >= 3) { if (this.playerWins >= 3 || this.aiWins >= 3) {
this.showVictoryScreen(this.playerWins >= 3); const playerWon = this.playerWins >= 3;
playSound(this, playerWon ? SFX.VICTORY_SHORT : SFX.SCIFI_RISER);
this.showVictoryScreen(playerWon);
} else { } else {
if (!isDraw) playSound(this, roundWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
this.showRoundResult(roundWon, isDraw); this.showRoundResult(roundWon, isDraw);
} }
}); });

View File

@ -125,6 +125,8 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3'); this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3');
this.load.spritesheet('jello-items', '/assets/images/jello-items.png', { frameWidth: 132, frameHeight: 132 }); this.load.spritesheet('jello-items', '/assets/images/jello-items.png', { frameWidth: 132, frameHeight: 132 });
this.load.audio('sfx-jello-monsters','/assets/fx/jello-monsters.mp3'); this.load.audio('sfx-jello-monsters','/assets/fx/jello-monsters.mp3');
this.load.audio('sfx-countdown-tick', '/assets/fx/countdown-01.mp3');
this.load.audio('sfx-countdown-go', '/assets/fx/countdown-02.mp3');
this.load.audio('sfx-ui-pick', '/assets/fx/ui-pick.mp3'); this.load.audio('sfx-ui-pick', '/assets/fx/ui-pick.mp3');
this.load.audio('sfx-ui-activate', '/assets/fx/ui-activate.mp3'); this.load.audio('sfx-ui-activate', '/assets/fx/ui-activate.mp3');
this.load.audio('sfx-ui-flip', '/assets/fx/ui-flip.mp3'); this.load.audio('sfx-ui-flip', '/assets/fx/ui-flip.mp3');

View File

@ -48,6 +48,8 @@ export const SFX = {
SQUASH: 'sfx-squash', SQUASH: 'sfx-squash',
WOOSH: 'sfx-woosh', WOOSH: 'sfx-woosh',
JELLO_MONSTERS: 'sfx-jello-monsters', JELLO_MONSTERS: 'sfx-jello-monsters',
COUNTDOWN_TICK: 'sfx-countdown-tick',
COUNTDOWN_GO: 'sfx-countdown-go',
UI_PICK: 'sfx-ui-pick', UI_PICK: 'sfx-ui-pick',
UI_ACTIVATE: 'sfx-ui-activate', UI_ACTIVATE: 'sfx-ui-activate',
UI_FLIP: 'sfx-ui-flip', UI_FLIP: 'sfx-ui-flip',