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