feat(masterofvega): add studio intro video and colony focus misalignment indicators

- Add full-screen intro video (mov-intro.mp4) that plays once on fresh game
  entry, with skip prompt on first keypress and immediate skip on second
- Include intro soundtrack (intro-theme.mp3) and updated menu title image
- Skip video on Load-slot resume to avoid interrupting returning players
- Add orange highlight to Colony/Allocation Focus pills when their setting
  disagrees with the advisor's recommendation (recommendColonyFocus/
  recommendAllocationFocus)
This commit is contained in:
Brian Fertig 2026-08-12 23:00:18 -06:00
parent 72f44178b0
commit e78f626406
5 changed files with 152 additions and 3 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Binary file not shown.

View File

@ -37,6 +37,7 @@ import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import { openAudienceScreen } from './VegaAudience.js'; import { openAudienceScreen } from './VegaAudience.js';
import { playIntroVideo } from './VegaIntroVideo.js';
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
const SAVE_KEY = 'mastervega-save'; const SAVE_KEY = 'mastervega-save';
@ -91,13 +92,25 @@ export default class MasterOfVegaGame extends Phaser.Scene {
try { try {
this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music'));
this.music.setCategory('menu');
} catch (err) { /* music is optional */ } } catch (err) { /* music is optional */ }
this.events.once('shutdown', () => this.teardown()); this.events.once('shutdown', () => this.teardown());
if (this.pendingSavedState) this.beginGame(null, this.pendingSavedState); // A Load-slot resume drops straight into a running game and never sees a
else this.showLanding(); // 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 // Wrap a menu handler so it clicks. Every button and card on the front-end

View File

@ -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();
}