fertig-classic-games/src/games/mastervega/VegaShipMedia.js

176 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Master of Vega — ship pictures and ship-commander videos.
//
// Two pieces of art travel together everywhere a ship is listed: the hull
// itself, cut from the `ships` sheet, and a looping video of the officer who
// flies it. This module is the one place that knows how either is addressed.
//
// The commander clips follow the same drop-in contract as the species
// portraits, one tier deeper:
//
// 1. this species' clip for this hull (shipVideos[species][hull])
// 2. the species' own portrait video (portraitVideos[species])
// 3. its high-resolution still (portraitStills[species])
// 4. a frame on the procedural sheet (always present)
//
// Tiers 2-4 are exactly makeSpeciesPortrait's ladder, so a species with no ship
// clips recorded still shows a moving face on every row — which is why only
// `human` is filled in today and the other nine are a single `null` each in the
// artwork manifest.
//
// Ships are drawn facing UP on the sheet. Thumbnails and the detail window turn
// them a full 180°, which is a ROTATION and not a mirror: setFlipY would flip
// the hull's asymmetries with it.
import { makeSpeciesPortrait, sizeSpeciesPortrait, shipFrame } from './VegaArt.js';
/** Source resolution of the commander videos, and of a `ships` sheet frame. */
const COMMANDER_VIDEO_PX = 256;
const SHIP_FRAME_PX = 192;
export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`;
export function hasShipVideo(scene, speciesId, hullId) {
return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId));
}
/**
* The hull itself, `size` px square and turned upside down, centred on (x, y).
*
* Scales from the texture width rather than calling setDisplaySize so repeated
* resizes stay correct — displayWidth changes as it is scaled, width does not.
*/
export function makeShipIcon(scene, rules, art, speciesId, hullId, x, y, size) {
const img = scene.add.image(x, y, art.ships, shipFrame(rules, speciesId, hullId));
img.setScale(size / (img.width || SHIP_FRAME_PX));
img.setAngle(180);
return img;
}
/**
* The commanding officer, `size` px square and centred on (x, y). Returns a
* Phaser Video or Image — the caller adds it to its own container and never has
* to know which tier it got.
*
* `onReplaced` fires if the clip is present but the browser refuses to decode
* it and the portrait is swapped underneath; a pool holding a reference needs
* to hear about that, or it keeps handing out a destroyed object.
*/
export function makeCommanderPortrait(
scene, rules, art, speciesId, hullId, x, y, size, onReplaced,
) {
if (!hasShipVideo(scene, speciesId, hullId)) {
return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
}
const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId));
v.setMute(true);
v.setLoop(true);
// Scale from the known source size rather than setDisplaySize: a Video's
// texture can still report zero width before its first frame is decoded, and
// setDisplaySize would then divide by it and blank the portrait.
v.setScale(size / (v.width || COMMANDER_VIDEO_PX));
v.play(true);
v.once('error', () => {
if (!v.scene) return;
const fallback = makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
v.parentContainer?.add(fallback);
v.destroy();
onReplaced?.(fallback);
});
return v;
}
/**
* A scene-lifetime cache of commander portraits, keyed by species and hull.
*
* Every view that lists ships rebuilds its whole body on each click — the side
* panel on every / +, the colony catalogue on every enqueue. Creating the
* portraits inline would tear down and re-create a decoder per row per click,
* restarting every loop and burning CPU for no visible gain. So the portraits
* live in `layer`, which the caller parents wherever it likes and deliberately
* does NOT clear during a rebuild:
*
* pool.beginFrame();
* ... rows call pool.get(...) ...
* pool.endFrame();
*
* Anything not claimed this frame is hidden and paused rather than destroyed,
* so dialling a stack to zero and back does not restart its loop.
*
* One pool per view. Two pools may each hold a portrait on the same cached
* video: verified against the Phaser 3.90 source, every Video game object
* creates its own HTMLVideoElement and destroys only that one, so this costs
* decode time and nothing else.
*/
export function createShipMediaPool(scene, rules, art) {
const entries = new Map();
const claimed = new Set();
const layer = scene.add.container(0, 0);
let paused = false;
// A view can be torn down while one of its own close tweens is still
// running, so every entry point has to survive being called after destroy().
let dead = false;
const play = (e) => { if (e.isVideo && e.obj.scene) e.obj.resume?.(); };
const halt = (e) => { if (e.isVideo && e.obj.scene) e.obj.pause?.(); };
return {
/** Parent this where the portraits should draw; never removeAll(true) it. */
layer,
beginFrame() { claimed.clear(); },
get(speciesId, hullId, x, y, size) {
if (dead) return null;
const key = `${speciesId}|${hullId}`;
let e = entries.get(key);
// A destroyed object still answers to its variable. Phaser nulls `scene`
// on destroy, which is the only reliable liveness check we have.
if (e && !e.obj.scene) { entries.delete(key); e = null; }
if (!e) {
const obj = makeCommanderPortrait(
scene, rules, art, speciesId, hullId, x, y, size,
(replacement) => {
const cur = entries.get(key);
if (cur) entries.set(key, { obj: replacement, isVideo: false });
},
);
e = { obj, isVideo: obj.type === 'Video' };
entries.set(key, e);
layer.add(obj);
}
claimed.add(key);
e.obj.setPosition(x, y);
sizeSpeciesPortrait(e.obj, size);
e.obj.setVisible(true);
if (!paused) play(e);
return e.obj;
},
endFrame() {
for (const [key, e] of entries) {
if (claimed.has(key)) continue;
e.obj.setVisible(false);
halt(e);
}
},
/** Stop decoding entirely — the view is hidden, or something is over it. */
setPaused(value) {
if (dead) return;
paused = !!value;
for (const [key, e] of entries) {
if (paused || !claimed.has(key)) halt(e);
else play(e);
}
},
destroy() {
if (dead) return;
dead = true;
layer.destroy();
entries.clear();
claimed.clear();
},
};
}