diff --git a/assets/images/vega/menu-title.png b/assets/images/vega/menu-title.png index ce229ca..259ab1f 100644 Binary files a/assets/images/vega/menu-title.png and b/assets/images/vega/menu-title.png differ diff --git a/assets/music/vega/intro-theme.mp3 b/assets/music/vega/intro-theme.mp3 new file mode 100644 index 0000000..5cbed69 Binary files /dev/null and b/assets/music/vega/intro-theme.mp3 differ diff --git a/assets/videos/vega/mov-intro.mp4 b/assets/videos/vega/mov-intro.mp4 new file mode 100644 index 0000000..5caa378 Binary files /dev/null and b/assets/videos/vega/mov-intro.mp4 differ diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index d798f82..aecc7db 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -37,6 +37,7 @@ import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js'; import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; import { openAudienceScreen } from './VegaAudience.js'; +import { playIntroVideo } from './VegaIntroVideo.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; const SAVE_KEY = 'mastervega-save'; @@ -91,13 +92,25 @@ export default class MasterOfVegaGame extends Phaser.Scene { try { this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); - this.music.setCategory('menu'); } catch (err) { /* music is optional */ } this.events.once('shutdown', () => this.teardown()); - if (this.pendingSavedState) this.beginGame(null, this.pendingSavedState); - else this.showLanding(); + // A Load-slot resume drops straight into a running game and never sees a + // landing screen at all, so it skips the intro too — only a fresh entry + // plays it, right before showLanding(). The menu track waits until + // whichever of those actually runs: the intro clip carries its own + // soundtrack, and starting the menu theme underneath it would just be two + // tracks fighting for the same speakers. + if (this.pendingSavedState) { + this.music?.setCategory('menu'); + this.beginGame(null, this.pendingSavedState); + } else { + playIntroVideo(this, () => { + this.music?.setCategory('menu'); + this.showLanding(); + }); + } } // Wrap a menu handler so it clicks. Every button and card on the front-end diff --git a/src/games/mastervega/VegaIntroVideo.js b/src/games/mastervega/VegaIntroVideo.js new file mode 100644 index 0000000..0240057 --- /dev/null +++ b/src/games/mastervega/VegaIntroVideo.js @@ -0,0 +1,136 @@ +// Master of Vega — the full-screen studio intro that plays once, ahead of the +// landing screen, on every fresh entry into the game (never on a Load-slot +// resume, which drops straight into a running game and has no landing screen +// to precede). +// +// Unlike every other video in this game (colonyVideos, audienceVideos, the +// commander portrait loops — see VegaColonyIntro.js and VegaShipMedia.js), +// this one is loaded with `noAudio: false`: the clip carries its own +// soundtrack and there is no separate track to layer under it. It is safe to +// autoplay with sound here specifically because reaching this scene already +// required a user gesture (clicking the game's icon in the arcade), which is +// what the browser's autoplay gate actually checks for. + +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { FONT } from './VegaScreens.js'; + +const INTRO_KEY = 'vega-mov-intro'; +const INTRO_PATH = 'assets/videos/vega/mov-intro.mp4'; +// Source resolution, used until the real texture decodes — see VegaArt.js's +// sourceWidth() for why a Video's own width/height lie (a square placeholder) +// before its first frame. +const INTRO_SRC_W = 854; +const INTRO_SRC_H = 480; + +// How long "Press any key to skip" stays up after the first press before it +// quietly stands down (rather than skipping on its own) and waits for the +// next one. +const SKIP_HOLD_MS = 5000; + +/** + * Play the intro clip full-screen, then call `onDone`. The first key press or + * click surfaces a skip prompt; a second one inside SKIP_HOLD_MS jumps + * straight to `onDone`. A load failure or playback error also falls straight + * through to `onDone` — a missing intro is never allowed to strand the player + * in front of a black screen. + */ +export function playIntroVideo(scene, onDone) { + let done = false; + let video = null; + let skipArmed = false; + let skipTimer = null; + let skipText = null; + + const root = scene.add.container(0, 0).setDepth(1000); + root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 1).setOrigin(0, 0)); + + const clearPrompt = () => { + skipTimer?.remove(false); + skipTimer = null; + skipText?.destroy(); + skipText = null; + skipArmed = false; + }; + + const cleanup = () => { + scene.input.keyboard?.off('keydown', onInput); + scene.input.off('pointerdown', onInput); + clearPrompt(); + video?.destroy(); + root.destroy(); + }; + + const finish = () => { + if (done) return; + done = true; + cleanup(); + onDone(); + }; + + // Belt-and-braces: if the scene tears down mid-clip (e.g. a hot reload), + // drop our own listeners and the video element without firing onDone a + // second time behind whatever else shutdown is already doing. + scene.events.once('shutdown', () => { if (!done) { done = true; cleanup(); } }); + + const armSkipPrompt = () => { + skipArmed = true; + skipText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 96, 'Press any key to skip', { + fontFamily: FONT, fontSize: '28px', color: '#e8f4ff', + }).setOrigin(0.5).setAlpha(0); + root.add(skipText); + scene.tweens.add({ targets: skipText, alpha: 1, duration: 200 }); + skipTimer = scene.time.delayedCall(SKIP_HOLD_MS, clearPrompt); + }; + + function onInput() { + if (done) return; + if (skipArmed) { finish(); return; } + armSkipPrompt(); + } + + scene.input.keyboard?.on('keydown', onInput); + scene.input.on('pointerdown', onInput); + + const startVideo = () => { + video = scene.add.video(GAME_WIDTH / 2, GAME_HEIGHT / 2, INTRO_KEY); + video.setLoop(false); + video.setMute(false); + video.setVolume(1); + + // Cover, not contain: this is meant to fill the screen edge-to-edge, and + // the clip's own aspect (854x480) is within a hair of the game's + // (1920x1080) anyway, so the crop is imperceptible. + const fit = () => { + const real = !!video.videoTexture; + const w = real ? video.width : INTRO_SRC_W; + const h = real ? video.height : INTRO_SRC_H; + video.setScale(Math.max(GAME_WIDTH / w, GAME_HEIGHT / h)); + }; + fit(); + video.on('created', fit); + video.on('playing', fit); + + video.once('complete', finish); + video.once('error', finish); + // Same as every other audio-bearing clip in this game (see + // VegaColonyIntro.js's startVideo): a browser that refuses to autoplay + // sound fires 'locked' and keeps retrying muted from preUpdate. + video.once('locked', () => { if (video?.scene) video.setMute(true); }); + + video.play(false); + root.add(video); + }; + + if (scene.cache.video?.exists(INTRO_KEY)) { + startVideo(); + return; + } + + scene.load.video(INTRO_KEY, INTRO_PATH, false); + scene.load.once(`filecomplete-video-${INTRO_KEY}`, () => { if (!done) startVideo(); }); + scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => { + if (file?.key === INTRO_KEY) finish(); + }); + if (!scene.load.isLoading()) scene.load.start(); +}