186 lines
7.2 KiB
JavaScript
186 lines
7.2 KiB
JavaScript
// 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, sourceWidth } 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}`;
|
||
|
||
// The Colony Transport ('poptransport') shares the Troop Transport's static
|
||
// picture (same sheet frame — see data/mastervega-rules.json, the ships sheet
|
||
// is full at 8 columns) but is deliberately given the COLONY SHIP's commander
|
||
// clip instead: thematically the same kind of settlement run, and it means
|
||
// every species with a colony-ship video already has one for this hull too,
|
||
// with zero new video files. `shipVideoKey` itself stays a pure naming
|
||
// function — the alias is applied only where a video is actually looked up.
|
||
const VIDEO_HULL_ALIAS = { poptransport: 'colonyship' };
|
||
|
||
export function hasShipVideo(scene, speciesId, hullId) {
|
||
return !!scene.cache.video?.exists(shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? 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, VIDEO_HULL_ALIAS[hullId] ?? hullId));
|
||
v.setMute(true);
|
||
v.setLoop(true);
|
||
// Scale from the known source size rather than setDisplaySize — see
|
||
// VegaArt.sourceWidth() for why a Video's own width lies before its first
|
||
// frame decodes. A no-op here today (these clips ARE 256 px, the same as the
|
||
// placeholder it would otherwise divide by) and correct if one ever is not.
|
||
v.setScale(size / sourceWidth(v, 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();
|
||
},
|
||
};
|
||
}
|