Merge pull request 'Add animated background video and still backdrop to Spelling Bee' (#11) from Game-Polish into main

Reviewed-on: #11
This commit is contained in:
brianfertig 2026-09-03 02:57:38 +00:00
commit 71a919e0cb
4 changed files with 144 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

View File

@ -256,6 +256,9 @@ export const MANIFEST = {
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'), image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
], ],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')], bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
// Still gameplay background for Spelling Bee — the intro clip is swapped for
// this once a puzzle starts (SpellingBeeGame.showImageBackground).
spellingbee: [image('spellingbee-bg', 'assets/images/background-spellingbee.png')],
rushhour: [ rushhour: [
// Carries its own title, so the level-select screen draws no heading. // Carries its own title, so the level-select screen draws no heading.
// In-play art is procedural (see games/rushhour/RushHourArt.js). // In-play art is procedural (see games/rushhour/RushHourArt.js).

View File

@ -17,6 +17,28 @@ const INK_DARK = '#1a1208'; // dark letter on the honey center hex
const TITLE_GOLD = '#f2c14e'; const TITLE_GOLD = '#f2c14e';
const PAPER = 0x1e1a12; // panels const PAPER = 0x1e1a12; // panels
// ── Background video ─────────────────────────────────────────────────────────
// Animated bee-on-a-flower clip used as a live backdrop behind the board.
// Loaded on demand (not in the asset manifest) and muted — the game has its
// own soundtrack and SFX, so the clip is purely visual. Source resolution is
// used to scale until the real texture decodes (a Video's own width/height
// report a square placeholder before its first frame).
const BG_VIDEO_KEY = 'spellingbee-bg-video';
const BG_VIDEO_PATH = 'assets/videos/games/spellingBee.mp4';
const BG_VIDEO_SRC_W = 864;
const BG_VIDEO_SRC_H = 480;
// The clip is bright, so dim it a touch to keep the title and board readable
// without killing the color.
const BG_DIM_ALPHA = 0.38;
// Fallback for revealing the start panel: if the intro clip never finishes its
// first loop (stalled download, unsupported codec, ...), bring the panel up
// anyway. Just past the clip's ~15 s runtime.
const INTRO_REVEAL_FALLBACK_MS = 20000;
// Still gameplay backdrop, preloaded by GameRoomScene via data/assetManifest.js.
// Replaces the intro clip the moment a puzzle actually starts.
const BG_IMAGE_KEY = 'spellingbee-bg';
const BG_IMAGE_PATH = 'assets/images/background-spellingbee.png';
const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 }; const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
// ── Honeycomb geometry ───────────────────────────────────────────────────────── // ── Honeycomb geometry ─────────────────────────────────────────────────────────
@ -48,6 +70,9 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.curLetters = []; // per-char text objects for the current word this.curLetters = []; // per-char text objects for the current word
this.foundTexts = []; this.foundTexts = [];
this.hexes = {}; // letter -> { container, gfx, isCenter } this.hexes = {}; // letter -> { container, gfx, isCenter }
this.bgVideo = null;
this.bgImage = null; // still gameplay background (replaces the video)
this.panelRevealed = false; // difficulty panel shown (intro finished)
} }
create() { create() {
@ -57,6 +82,8 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT) this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
.setDepth(DEPTH.bg); .setDepth(DEPTH.bg);
this.buildVideoBackground();
this.add.text(40, 36, 'SPELLING BEE', { this.add.text(40, 36, 'SPELLING BEE', {
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD, fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
}).setDepth(DEPTH.ui); }).setDepth(DEPTH.ui);
@ -70,6 +97,89 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.input.keyboard.off('keydown', this.keyHandler); this.input.keyboard.off('keydown', this.keyHandler);
this.keyHandler = null; this.keyHandler = null;
} }
// The video may already be gone (e.g. the error handler destroyed it).
if (this.bgVideo?.scene) this.bgVideo.destroy();
this.bgVideo = null;
if (this.bgImage?.scene) this.bgImage.destroy();
this.bgImage = null;
}
// ── Background video ───────────────────────────────────────────────────────
// Bring up the looping background clip on top of the felt base. In this
// Phaser version a VideoFile "load" is a synchronous no-op — it only records
// the URL in the video cache (the real fetch happens when the Video object
// is created, in showBackgroundVideo) — so there is no async window to wait
// out: queue it if needed and show it immediately. A fetch/playback failure
// is handled by the video's 'error' event, leaving the felt base visible.
buildVideoBackground() {
if (!this.cache.video?.exists(BG_VIDEO_KEY)) {
this.load.video(BG_VIDEO_KEY, BG_VIDEO_PATH, true); // noAudio — visual only
if (!this.load.isLoading()) this.load.start();
}
this.showBackgroundVideo();
}
showBackgroundVideo() {
const v = this.add.video(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_VIDEO_KEY)
.setDepth(DEPTH.bg);
v.setLoop(true);
v.setMute(true);
// Cover, not contain: fill edge-to-edge. The clip's aspect (864x480) is
// within a hair of the game's (1920x1080), so the crop is imperceptible.
const fit = () => {
const w = v.videoTexture ? v.width : BG_VIDEO_SRC_W;
const h = v.videoTexture ? v.height : BG_VIDEO_SRC_H;
v.setScale(Math.max(GAME_WIDTH / w, GAME_HEIGHT / h));
};
fit();
v.on('created', fit);
v.on('playing', fit);
// A load failure mid-playback (or a browser refusing the element) falls
// back to the felt rectangle already underneath — and reveals the start
// panel, which otherwise waits for the intro loop.
v.once('error', () => {
this.revealStartPanel();
if (v.scene) v.destroy();
});
this.bgVideo = v;
v.setAlpha(0);
v.play(true);
this.tweens.add({ targets: v, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
// Dim layer on top of the footage so the title and board stay readable.
// Fixed alpha: it only darkens the felt base while the video fades in, so
// the footage never flashes bright before settling. (It also dims the
// still gameplay backdrop once the video is swapped out — see
// showImageBackground.)
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT,
0x000000, BG_DIM_ALPHA).setDepth(DEPTH.bg + 1);
}
// Swap the intro clip for the still gameplay backdrop (honeycomb PNG).
// Preloaded via the asset manifest, so the texture is almost always ready;
// the else branch covers a scene started without that preload. The felt
// rectangle stays underneath as the ultimate fallback.
showImageBackground() {
if (this.bgImage) return;
if (!this.textures.exists(BG_IMAGE_KEY)) {
this.load.image(BG_IMAGE_KEY, BG_IMAGE_PATH);
if (!this.load.isLoading()) this.load.start();
}
if (this.textures.exists(BG_IMAGE_KEY)) {
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
this.load.once('filecomplete', (type, key) => {
if (type !== 'texture' || key !== BG_IMAGE_KEY) return;
if (!this.sys.isActive() || this.bgImage) return;
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
});
}
} }
// ── Start panel (opponent-select is skipped for this solo game) ───────────── // ── Start panel (opponent-select is skipped for this solo game) ─────────────
@ -107,6 +217,31 @@ export default class SpellingBeeGame extends Phaser.Scene {
b.setDepth(DEPTH.ui); b.setDepth(DEPTH.ui);
this.startObjs.push(b); this.startObjs.push(b);
}); });
// Hidden until the intro clip has played a full loop (see revealStartPanel).
this.startObjs.forEach((o) => o.setAlpha(0));
// The first 'loop' event of the background video means the clip has just
// played through once — the natural moment to surface the difficulty
// choice. The safety timer covers a clip that never gets that far; the
// video's 'error' handler (showBackgroundVideo) does the same immediately.
const reveal = () => this.revealStartPanel();
this.bgVideo?.once(Phaser.GameObjects.Events.VIDEO_LOOP, reveal);
this.time.delayedCall(INTRO_REVEAL_FALLBACK_MS, reveal);
}
// Fade the difficulty panel in. Whichever trigger fires first (intro loop,
// video error, fallback timer) wins; the rest are no-ops.
revealStartPanel() {
if (this.panelRevealed) return;
// Panel not built yet — bail without consuming the reveal; a later trigger
// (loop timer / fallback) will fire once it exists.
if (!this.startObjs.length) return;
this.panelRevealed = true;
this.startObjs.forEach((o) => {
if (!o.scene) return;
this.tweens.add({ targets: o, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
});
} }
async startPuzzle(difficulty) { async startPuzzle(difficulty) {
@ -130,6 +265,12 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.maxScore = data.maxScore ?? 0; this.maxScore = data.maxScore ?? 0;
this.tiers = buildTiers(this.maxScore, difficulty); this.tiers = buildTiers(this.maxScore, difficulty);
// Difficulty screen is over: retire the intro clip and bring up the still
// honeycomb backdrop. (If the fetch above failed we never get here, and
// the player stays on the difficulty screen with the clip still playing.)
if (this.bgVideo?.scene) { this.bgVideo.destroy(); this.bgVideo = null; }
this.showImageBackground();
this.buildBoard(); this.buildBoard();
} }