diff --git a/assets/videos/vega/audience-kestrelli-angry.mp4 b/assets/videos/vega/audience-kestrelli-angry.mp4 new file mode 100644 index 0000000..15eac15 Binary files /dev/null and b/assets/videos/vega/audience-kestrelli-angry.mp4 differ diff --git a/assets/videos/vega/audience-kestrelli-happy.mp4 b/assets/videos/vega/audience-kestrelli-happy.mp4 new file mode 100644 index 0000000..ecc2c10 Binary files /dev/null and b/assets/videos/vega/audience-kestrelli-happy.mp4 differ diff --git a/assets/videos/vega/audience-kestrelli-neutral.mp4 b/assets/videos/vega/audience-kestrelli-neutral.mp4 new file mode 100644 index 0000000..fefaadd Binary files /dev/null and b/assets/videos/vega/audience-kestrelli-neutral.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 082a73d..f3936d2 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -136,9 +136,9 @@ "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } }, "kestrelli": { - "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, - "neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" }, - "happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" } + "angry": { "key": "vega-audience-kestrelli-angry", "path": "assets/videos/vega/audience-kestrelli-angry.mp4" }, + "neutral": { "key": "vega-audience-kestrelli-neutral", "path": "assets/videos/vega/audience-kestrelli-neutral.mp4" }, + "happy": { "key": "vega-audience-kestrelli-happy", "path": "assets/videos/vega/audience-kestrelli-happy.mp4" } }, "ursaal": { "angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" }, diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index 0f35f94..b75c5f3 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -22,12 +22,16 @@ // VegaArt.sourceWidth's "trap 23" (docs/mastervega-build-plan.md), a fresh // Phaser Video reports a 256px placeholder width — never 0 — until it decodes, // so scale is always derived from sourceWidth(), re-applied on 'created' and -// 'playing'. All three mood clips for a species are JIT-loaded (like -// colonyVideos, not the eager shipVideos/portraitVideos), warmed the instant -// contact is claimed and again here on open. A species with no audience clips -// yet falls back to one shared, un-swapping portrait (video/still), letterboxed -// into the 3:2 frame with that species' own colour as the bar — so an art-less -// species is still fully playable. +// 'playing'. Each mood clip is JIT-loaded (like colonyVideos, not the eager +// shipVideos/portraitVideos) one at a time, only the moment it is actually +// about to be shown — never all three moods at once, since a species' full +// set runs several megabytes and most conversations never even change mood. +// Whatever is already on screen keeps playing while a newly-needed mood loads +// behind it; the very first clip a species ever shows gets a letterboxed +// placeholder in its place until that first fetch lands. A species with no +// audience clips at all falls back to that same placeholder permanently, +// letterboxed into the 3:2 frame with that species' own colour as the bar — +// so an art-less species is still fully playable. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; @@ -118,18 +122,6 @@ function ensureOneAudienceVideo(scene, speciesId, mood, onDone) { if (!scene.load.isLoading()) scene.load.start(); } -/** - * Warm all three mood clips for a species. Mood can shift mid-conversation, so - * all three are worth having ready rather than only the one currently shown. - * Called speculatively the instant a fresh contact is claimed, and again here - * on open (covers a manual Seek Audience click long after original contact). - */ -export function ensureAudienceVideos(scene, speciesId, onReady = null) { - let remaining = MOODS.length; - const settle = () => { remaining -= 1; if (remaining <= 0) onReady?.(); }; - for (const mood of MOODS) ensureOneAudienceVideo(scene, speciesId, mood, settle); -} - // -------------------------------------------------------------------------- // The mood visual: three pre-built Video objects (built lazily on first use, // per mood), hide+pause on swap, never destroy+recreate — safe and cheap per @@ -161,7 +153,12 @@ function buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h) { // swapping back to an already-finished mood re-arms the badge immediately), // on the video's own 'complete' event, and on an explicit replay() call. function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEndedChanged) { - const anyClip = MOODS.some((m) => hasAudienceVideo(scene, speciesId, m)); + // Whether this species HAS clips at all is a property of the artwork + // manifest, known synchronously — it must not be read off `hasAudienceVideo` + // (whether bytes happen to be loaded already), or the very first audience + // this session would always read as clip-less and get stuck on the + // placeholder forever, having never asked the loader for anything. + const anyClip = MOODS.some((m) => !!audienceVideoPath(scene, speciesId, m)); if (!anyClip) { // Nothing to visually swap, but `current` still has to track the real // mood — otherwise afterAction()'s "did the tier change" check compares @@ -173,11 +170,14 @@ function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEn parent.add(shared); let current = null; return { - show(mood) { current = mood; }, get current() { return current; }, replay() {}, + show(mood) { current = mood; }, get current() { return current; }, replay() {}, destroy() {}, }; } const built = {}; // mood -> { obj, ended } - let current = null; + let current = null; // mood actually on screen right now + let wanted = null; // mood show() last asked for — may still be loading + let placeholder = null; // letterboxed stand-in, only ever needed before the FIRST clip lands + let destroyed = false; function build(mood) { const obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood)); @@ -202,21 +202,46 @@ function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEn return entry; } + /** Swap the visible clip to `mood`, which must already be in the video cache. */ + function showLoaded(mood) { + placeholder?.setVisible(false); + if (built[current]) { built[current].obj.setVisible(false); built[current].obj.pause(); } + let entry = built[mood]; + if (!entry) entry = build(mood); + else { + entry.obj.setVisible(true); + // Only resume playback if it never finished — an ended video's + // `.play()`/`.resume()` seeks back to 0 on some browsers, which + // would silently replay it just for being swapped back to. + if (!entry.ended) entry.obj.resume(); + } + current = mood; + onEndedChanged?.(entry.ended); + } + return { show(mood) { - if (current === mood) return; - if (built[current]) { built[current].obj.setVisible(false); built[current].obj.pause(); } - let entry = built[mood]; - if (!entry) entry = build(mood); - else { - entry.obj.setVisible(true); - // Only resume playback if it never finished — an ended video's - // `.play()`/`.resume()` seeks back to 0 on some browsers, which - // would silently replay it just for being swapped back to. - if (!entry.ended) entry.obj.resume(); + if (wanted === mood) return; + wanted = mood; + if (hasAudienceVideo(scene, speciesId, mood)) { showLoaded(mood); return; } + // Not fetched yet. Whatever is already on screen keeps playing (that's + // the whole point of loading one mood at a time instead of all three up + // front) — except the very first clip this species ever shows, which + // has nothing to keep on screen, so it gets a placeholder instead of a + // blank frame. + if (!current) { + if (!placeholder) { + placeholder = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h); + parent.add(placeholder); + } + placeholder.setVisible(true); } - current = mood; - onEndedChanged?.(entry.ended); + ensureOneAudienceVideo(scene, speciesId, mood, () => { + // Superseded by a later show() call, or the screen is gone — either + // way this fetch's result is no longer wanted. + if (destroyed || wanted !== mood) return; + if (hasAudienceVideo(scene, speciesId, mood)) showLoaded(mood); + }); }, get current() { return current; }, replay() { @@ -230,6 +255,10 @@ function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEn // identical replay mechanism). entry.obj.play(false); }, + // Called from openAudienceScreen's close(), before root.destroy() tears + // down whatever WAS built — stops a fetch that lands after the screen is + // gone from reaching into a destroyed container. + destroy() { destroyed = true; }, }; } @@ -279,8 +308,6 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo const vars = { you: state.empires[me].name, me: other.name }; const speciesColor = Phaser.Display.Color.HexStringToColor(spec.color).color; - ensureAudienceVideos(scene, speciesId); - // Declared up front — pushChat()/say() both guard on it, and pushChat() is // called synchronously below (the opening dialogue) well before close()'s // own declaration used to sit. @@ -622,6 +649,9 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo stopTyping(); indicator?.destroy(); chatMaskG.destroy(); + // Stops a still-in-flight fetch from swapping a clip into a container + // that's about to be destroyed out from under it. + moodVisual.destroy(); // Destroys the mood video(s) with it, which is what stops their // decoders — same contract as VegaColonyIntro's root.destroy(). root.destroy();