diff --git a/assets/images/background-spellingbee.png b/assets/images/background-spellingbee.png new file mode 100644 index 0000000..6727349 Binary files /dev/null and b/assets/images/background-spellingbee.png differ diff --git a/assets/videos/games/spellingBee.mp4 b/assets/videos/games/spellingBee.mp4 new file mode 100644 index 0000000..86058b3 Binary files /dev/null and b/assets/videos/games/spellingBee.mp4 differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 6eb738b..f9f9cdb 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -256,6 +256,9 @@ export const MANIFEST = { image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.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: [ // Carries its own title, so the level-select screen draws no heading. // In-play art is procedural (see games/rushhour/RushHourArt.js). diff --git a/src/games/spellingbee/SpellingBeeGame.js b/src/games/spellingbee/SpellingBeeGame.js index 142fda5..6bfcce1 100644 --- a/src/games/spellingbee/SpellingBeeGame.js +++ b/src/games/spellingbee/SpellingBeeGame.js @@ -17,6 +17,28 @@ const INK_DARK = '#1a1208'; // dark letter on the honey center hex const TITLE_GOLD = '#f2c14e'; 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 }; // ── Honeycomb geometry ───────────────────────────────────────────────────────── @@ -48,6 +70,9 @@ export default class SpellingBeeGame extends Phaser.Scene { this.curLetters = []; // per-char text objects for the current word this.foundTexts = []; 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() { @@ -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) .setDepth(DEPTH.bg); + this.buildVideoBackground(); + this.add.text(40, 36, 'SPELLING BEE', { fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD, }).setDepth(DEPTH.ui); @@ -70,6 +97,89 @@ export default class SpellingBeeGame extends Phaser.Scene { this.input.keyboard.off('keydown', this.keyHandler); 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) ───────────── @@ -107,6 +217,31 @@ export default class SpellingBeeGame extends Phaser.Scene { b.setDepth(DEPTH.ui); 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) { @@ -130,6 +265,12 @@ export default class SpellingBeeGame extends Phaser.Scene { this.maxScore = data.maxScore ?? 0; 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(); }