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

728 lines
28 KiB
JavaScript

// Master of Vega — procedural art fallback.
//
// Every spritesheet in data/mastervega-artwork.json is optional. If its `path`
// is null (or the PNG 404s) we paint a canvas stand-in with the identical frame
// layout, so the renderers never branch on whether art exists and the game is
// fully playable with zero art files present.
//
// Modelled on src/games/totalannihilation/TAArt.js, including the structural
// point: painters are keyed by the sheet's `kind`, NOT its name. That is what
// makes adding an eleventh species or a second leader sheet a pure JSON edit.
const OUTLINE = '#12151d';
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
export function frameRng(seed) {
let a = (seed * 2654435761) >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Create a canvas texture laid out as a spritesheet and register its frames.
* `at(frame, draw)` translates to that frame's origin; `finish()` uploads.
*/
export function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
// Guard every dimension. A NaN here reaches createCanvas as a zero-height
// texture and surfaces as an opaque WebGL error far from the real mistake.
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
const frameW = size(wantW, 64);
const frameH = size(wantH, 64);
const cols = size(wantCols, 8);
const count = size(wantCount, 1);
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
console.warn(`[VegaArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
}
const rows = Math.max(1, Math.ceil(count / cols));
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
const ctx = tex.getContext();
return {
ctx,
at(frame, draw) {
const fx = (frame % cols) * frameW;
const fy = ((frame / cols) | 0) * frameH;
ctx.save();
ctx.translate(fx, fy);
draw(ctx, frameW, frameH);
ctx.restore();
},
finish() {
tex.refresh();
for (let f = 0; f < count; f += 1) {
const fx = (f % cols) * frameW;
const fy = ((f / cols) | 0) * frameH;
tex.add(f, 0, fx, fy, frameW, frameH);
}
return tex;
},
};
}
export function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function poly(ctx, pts) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
}
function shade(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const cl = (v) => Math.max(0, Math.min(255, Math.round(v)));
const r = cl(((n >> 16) & 255) * amount);
const g = cl(((n >> 8) & 255) * amount);
const b = cl((n & 255) * amount);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
// --------------------------------------------------------------------------
// Ships — one row per species, one column per hull.
// Silhouettes in unit space (0..1 on both axes), nose UP. Each hull reads at a
// glance from its outline alone, which matters because the star map draws these
// at 24px.
const HULL_SHAPES = {
0: [[0.5, 0.06], [0.62, 0.42], [0.58, 0.88], [0.42, 0.88], [0.38, 0.42]], // scout — a dart
1: [[0.5, 0.10], [0.74, 0.34], [0.74, 0.76], [0.5, 0.92], [0.26, 0.76], [0.26, 0.34]], // colony ship — a fat pod
2: [[0.34, 0.14], [0.66, 0.14], [0.78, 0.5], [0.66, 0.88], [0.34, 0.88], [0.22, 0.5]], // transport — a barge
3: [[0.5, 0.05], [0.66, 0.40], [0.60, 0.90], [0.40, 0.90], [0.34, 0.40]], // frigate
4: [[0.5, 0.04], [0.60, 0.30], [0.80, 0.56], [0.62, 0.92], [0.38, 0.92], [0.20, 0.56], [0.40, 0.30]], // destroyer
5: [[0.5, 0.03], [0.64, 0.26], [0.86, 0.50], [0.72, 0.70], [0.66, 0.94], [0.34, 0.94], [0.28, 0.70], [0.14, 0.50], [0.36, 0.26]], // cruiser
6: [[0.5, 0.02], [0.62, 0.20], [0.78, 0.34], [0.92, 0.62], [0.74, 0.72], [0.70, 0.96], [0.30, 0.96], [0.26, 0.72], [0.08, 0.62], [0.22, 0.34], [0.38, 0.20]], // battleship
7: [[0.5, 0.08], [0.78, 0.28], [0.90, 0.62], [0.66, 0.90], [0.34, 0.90], [0.10, 0.62], [0.22, 0.28]], // star base — a ring fort
};
function paintShips(scene, key, spec, rules) {
const cols = spec.cols ?? 8;
const rows = spec.rows ?? 10;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, cols * rows);
const speciesByFrame = new Map(rules.speciesList.map((s) => [s.shipFrame, s]));
for (let f = 0; f < cols * rows; f += 1) {
const hullFrame = f % cols;
const speciesFrame = Math.floor(f / cols);
const species = speciesByFrame.get(speciesFrame) ?? rules.speciesList[0];
const shape = HULL_SHAPES[hullFrame] ?? HULL_SHAPES[3];
const rnd = frameRng(f + 1);
sheet.at(f, (ctx, w, h) => {
const body = species.color;
ctx.lineJoin = 'round';
// Hull
poly(ctx, shape.map(([x, y]) => [x * w, y * h]));
const grad = ctx.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, shade(body, 1.25));
grad.addColorStop(0.55, body);
grad.addColorStop(1, shade(body, 0.5));
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.lineWidth = Math.max(1.5, w * 0.022);
ctx.stroke();
// Cockpit / core glow
ctx.beginPath();
ctx.ellipse(w * 0.5, h * 0.34, w * 0.09, h * 0.13, 0, 0, Math.PI * 2);
ctx.fillStyle = '#cfe8ff';
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
// Engine flare at the stern
const flare = ctx.createLinearGradient(0, h * 0.86, 0, h);
flare.addColorStop(0, 'rgba(255,214,140,0.9)');
flare.addColorStop(1, 'rgba(255,120,60,0)');
ctx.fillStyle = flare;
ctx.fillRect(w * 0.38, h * 0.86, w * 0.24, h * 0.14);
// Hull plating speckle so big hulls do not read as flat blocks
ctx.fillStyle = shade(body, 0.72);
const plates = 3 + hullFrame;
for (let i = 0; i < plates; i += 1) {
const px = w * (0.3 + rnd() * 0.4);
const py = h * (0.35 + rnd() * 0.45);
ctx.fillRect(px, py, w * 0.06, h * 0.03);
}
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Planets
function paintPlanets(scene, key, spec, rules) {
const cols = spec.cols ?? 5;
const count = rules.planetTypeList.length;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, Math.max(count, cols));
const byFrame = new Map(rules.planetTypeList.map((p) => [p.frame, p]));
for (let f = 0; f < Math.max(count, cols); f += 1) {
const type = byFrame.get(f);
const rnd = frameRng(f + 101);
sheet.at(f, (ctx, w, h) => {
if (!type) return;
const cx = w / 2;
const cy = h / 2;
const r = w * 0.42;
const base = type.color;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = base;
ctx.fillRect(0, 0, w, h);
if (type.id === 'gasgiant') {
// Latitude banding.
for (let i = 0; i < 9; i += 1) {
const y = (i / 9) * h;
ctx.fillStyle = shade(base, 0.72 + (i % 2) * 0.4 + rnd() * 0.12);
ctx.fillRect(0, y, w, h / 9);
}
ctx.beginPath();
ctx.ellipse(cx * 1.25, cy * 1.15, w * 0.13, h * 0.06, 0, 0, Math.PI * 2);
ctx.fillStyle = shade('#c05a3a', 1);
ctx.fill();
} else if (type.id === 'asteroids') {
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 34; i += 1) {
const a = rnd() * Math.PI * 2;
const d = r * (0.25 + rnd() * 0.75);
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * d, cy + Math.sin(a) * d * 0.5, w * (0.012 + rnd() * 0.03), 0, Math.PI * 2);
ctx.fillStyle = shade(base, 0.6 + rnd() * 0.8);
ctx.fill();
}
} else {
// Continents / surface mottling.
const blobs = type.habitability > 0.5 ? 7 : 11;
for (let i = 0; i < blobs; i += 1) {
const a = rnd() * Math.PI * 2;
const d = r * rnd() * 0.85;
const rr = w * (0.06 + rnd() * 0.16);
ctx.beginPath();
ctx.ellipse(cx + Math.cos(a) * d, cy + Math.sin(a) * d, rr, rr * (0.6 + rnd() * 0.6), rnd() * 3, 0, Math.PI * 2);
ctx.fillStyle = shade(base, type.hostility > 2 ? 0.6 + rnd() * 0.5 : 0.72 + rnd() * 0.55);
ctx.globalAlpha = 0.85;
ctx.fill();
}
ctx.globalAlpha = 1;
// Ice caps on anything cold enough to have them.
if (['tundra', 'terran', 'ocean', 'steppe', 'minimal'].includes(type.id)) {
ctx.fillStyle = 'rgba(236,246,255,0.85)';
ctx.beginPath();
ctx.ellipse(cx, cy - r * 0.92, r * 0.55, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.92, r * 0.5, r * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
}
}
// Terminator shading — the single cheapest thing that makes a flat disc
// read as a sphere.
const lit = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.1, cx, cy, r * 1.05);
lit.addColorStop(0, 'rgba(255,255,255,0.30)');
lit.addColorStop(0.5, 'rgba(0,0,0,0)');
lit.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = lit;
ctx.fillRect(0, 0, w, h);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(180,210,255,0.28)';
ctx.lineWidth = Math.max(1, w * 0.01);
ctx.stroke();
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Stars
function paintStars(scene, key, spec, rules) {
const cols = spec.cols ?? 3;
const count = rules.starClassList.length;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, Math.max(count, cols));
rules.starClassList.forEach((cls, f) => {
sheet.at(f, (ctx, w, h) => {
const cx = w / 2;
const cy = h / 2;
const r = w * 0.20;
if (cls.special === 'blackhole') {
// Accretion ring, then a hole punched out of the middle.
const ring = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.6);
ring.addColorStop(0, 'rgba(255,190,120,0)');
ring.addColorStop(0.35, 'rgba(255,170,90,0.85)');
ring.addColorStop(0.7, 'rgba(150,90,220,0.45)');
ring.addColorStop(1, 'rgba(40,20,70,0)');
ctx.fillStyle = ring;
ctx.fillRect(0, 0, w, h);
ctx.beginPath();
ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
ctx.fillStyle = '#04030a';
ctx.fill();
return;
}
// Outer corona
const glow = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, w * 0.48);
glow.addColorStop(0, cls.coreColor);
glow.addColorStop(0.18, cls.color);
glow.addColorStop(0.5, `${cls.color}55`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
// Core
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = cls.coreColor;
ctx.fill();
// Lens-flare spikes — four long, four short.
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.55;
for (let i = 0; i < 8; i += 1) {
const len = w * (i % 2 === 0 ? 0.46 : 0.26);
ctx.rotate(Math.PI / 4);
const g = ctx.createLinearGradient(0, 0, 0, -len);
g.addColorStop(0, cls.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-w * 0.012, 0);
ctx.lineTo(w * 0.012, 0);
ctx.lineTo(0, -len);
ctx.closePath();
ctx.fill();
}
ctx.restore();
if (cls.special === 'binary') {
ctx.beginPath();
ctx.arc(cx + w * 0.20, cy - h * 0.13, r * 0.62, 0, Math.PI * 2);
ctx.fillStyle = cls.coreColor;
ctx.fill();
}
if (cls.special === 'pulsar') {
// The sweeping beam pair.
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.5);
const beam = ctx.createLinearGradient(0, 0, 0, -w * 0.5);
beam.addColorStop(0, 'rgba(210,240,255,0.9)');
beam.addColorStop(1, 'rgba(120,190,255,0)');
ctx.fillStyle = beam;
for (const dir of [1, -1]) {
ctx.save();
ctx.scale(1, dir);
ctx.beginPath();
ctx.moveTo(-w * 0.04, 0);
ctx.lineTo(w * 0.04, 0);
ctx.lineTo(w * 0.10, -w * 0.5);
ctx.lineTo(-w * 0.10, -w * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.restore();
}
});
});
return sheet.finish();
}
// --------------------------------------------------------------------------
// Portraits — species and leaders share one painter.
function paintPortraits(scene, key, spec, rules) {
const cols = spec.cols ?? 5;
const rows = spec.rows ?? 2;
const count = cols * rows;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, count);
// Species sheets get species colours; the leader sheet has no species, so it
// falls back to a neutral ramp.
const isSpecies = count <= rules.speciesList.length + cols;
const byFrame = new Map(rules.speciesList.map((s) => [s.portraitFrame, s]));
for (let f = 0; f < count; f += 1) {
const species = isSpecies ? byFrame.get(f) : null;
const rnd = frameRng(f + 501);
const tint = species ? species.color : `hsl(${(f * 47) % 360}, 32%, 55%)`;
sheet.at(f, (ctx, w, h) => {
// Backdrop
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, shade(tint, 0.34));
bg.addColorStop(1, '#0a0c12');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
// Head silhouette. Its proportions vary per frame so the ten species do
// not all read as the same creature in different colours.
const headW = w * (0.30 + rnd() * 0.14);
const headH = h * (0.30 + rnd() * 0.14);
const cx = w / 2;
const cy = h * 0.44;
ctx.beginPath();
ctx.ellipse(cx, cy, headW, headH, 0, 0, Math.PI * 2);
const face = ctx.createRadialGradient(cx - headW * 0.3, cy - headH * 0.35, headW * 0.15, cx, cy, headW * 1.2);
face.addColorStop(0, shade(tint, 1.35));
face.addColorStop(1, shade(tint, 0.55));
ctx.fillStyle = face;
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.lineWidth = Math.max(2, w * 0.012);
ctx.stroke();
// Shoulders
ctx.beginPath();
ctx.moveTo(cx - w * 0.34, h);
ctx.quadraticCurveTo(cx, h * 0.66, cx + w * 0.34, h);
ctx.closePath();
ctx.fillStyle = shade(tint, 0.42);
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.stroke();
// Eyes — count and placement carry most of the "alien" read.
const eyes = 1 + Math.floor(rnd() * 3);
ctx.fillStyle = '#e8f6ff';
for (let i = 0; i < eyes; i += 1) {
const ex = cx + (i - (eyes - 1) / 2) * headW * 0.62;
ctx.beginPath();
ctx.ellipse(ex, cy - headH * 0.12, headW * 0.16, headH * 0.11, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#101820';
for (let i = 0; i < eyes; i += 1) {
const ex = cx + (i - (eyes - 1) / 2) * headW * 0.62;
ctx.beginPath();
ctx.ellipse(ex, cy - headH * 0.12, headW * 0.06, headH * 0.07, 0, 0, Math.PI * 2);
ctx.fill();
}
// A crest, horns or antennae, chosen per frame.
ctx.strokeStyle = shade(tint, 0.8);
ctx.lineWidth = Math.max(2, w * 0.016);
const crest = Math.floor(rnd() * 3);
if (crest === 0) {
for (const s of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(cx + s * headW * 0.6, cy - headH * 0.6);
ctx.quadraticCurveTo(cx + s * headW * 1.2, cy - headH * 1.3, cx + s * headW * 0.75, cy - headH * 1.5);
ctx.stroke();
}
} else if (crest === 1) {
ctx.beginPath();
ctx.moveTo(cx - headW * 0.5, cy - headH * 0.85);
ctx.lineTo(cx, cy - headH * 1.45);
ctx.lineTo(cx + headW * 0.5, cy - headH * 0.85);
ctx.stroke();
}
// Vignette
const vig = ctx.createRadialGradient(cx, h * 0.45, w * 0.2, cx, h * 0.5, w * 0.72);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, w, h);
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Icons — buildings and tech glyphs.
function paintIcons(scene, key, spec) {
const cols = spec.cols ?? 8;
const rows = spec.rows ?? 2;
const count = cols * rows;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, count);
for (let f = 0; f < count; f += 1) {
const rnd = frameRng(f + 907);
const hue = (f * 37) % 360;
sheet.at(f, (ctx, w, h) => {
const pad = w * 0.14;
roundRect(ctx, pad, pad, w - pad * 2, h - pad * 2, w * 0.16);
const g = ctx.createLinearGradient(0, pad, 0, h - pad);
g.addColorStop(0, `hsl(${hue}, 42%, 46%)`);
g.addColorStop(1, `hsl(${hue}, 46%, 24%)`);
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = 'rgba(220,236,255,0.5)';
ctx.lineWidth = Math.max(1, w * 0.03);
ctx.stroke();
// A distinct glyph per frame so a grid of icons is scannable even before
// real art lands.
ctx.strokeStyle = '#eaf4ff';
ctx.lineWidth = Math.max(1.5, w * 0.055);
ctx.lineCap = 'round';
const cx = w / 2;
const cy = h / 2;
const r = w * 0.20;
const glyph = f % 6;
ctx.beginPath();
if (glyph === 0) { ctx.arc(cx, cy, r, 0, Math.PI * 2); }
else if (glyph === 1) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
else if (glyph === 2) { ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy + r); ctx.lineTo(cx - r, cy + r); ctx.closePath(); }
else if (glyph === 3) { ctx.rect(cx - r, cy - r, r * 2, r * 2); }
else if (glyph === 4) { ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy); ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r); }
else { ctx.moveTo(cx - r, cy + r * 0.6); ctx.lineTo(cx - r * 0.2, cy - r * 0.6); ctx.lineTo(cx + r * 0.3, cy + r * 0.2); ctx.lineTo(cx + r, cy - r * 0.7); }
ctx.stroke();
if (rnd() < 0.4) {
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.fillRect(pad, pad, w - pad * 2, (h - pad * 2) * 0.28);
}
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
export const PROC_PAINTERS = {
ship: paintShips,
planet: paintPlanets,
star: paintStars,
portrait: paintPortraits,
icon: (scene, key, spec) => paintIcons(scene, key, spec),
};
/**
* Resolve every sheet to a texture key: the drop-in art if it loaded, otherwise
* a procedurally painted stand-in with the same frame layout. Callers use
* `keys[name]` and never have to know which they got.
*/
export function ensureSheets(scene, rules, art) {
const keys = Object.create(null);
const procedural = [];
for (const [name, spec] of Object.entries(art?.sheets ?? {})) {
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
const procKey = `${spec.key}-proc`;
if (!scene.textures.exists(procKey)) {
const painter = PROC_PAINTERS[spec.kind];
if (!painter) {
console.warn(`[VegaArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
continue;
}
painter(scene, procKey, spec, rules);
}
keys[name] = procKey;
procedural.push(name);
}
return { keys, procedural };
}
// --------------------------------------------------------------------------
// Species portraits
//
// Three tiers, best first:
// 1. a looping, muted video (assets/videos/vega/char-<id>.mp4)
// 2. a high-resolution still (assets/images/vega/char-<id>.png)
// 3. a frame on the procedural sheet (always present)
//
// All three are square and return a plain display object, so no caller has to
// know which it got. That is what lets the roster be finished a species at a
// time without any code changing.
/** Source resolution of the portrait videos (see sprites.md). */
const PORTRAIT_VIDEO_PX = 256;
export const speciesVideoKey = (speciesId) => `vega-char-${speciesId}`;
export const speciesStillKey = (speciesId) => `vega-still-${speciesId}`;
// Speech is streamed by ui/SpeechQueue.js straight from assets/speech/<clip>.mp3,
// never through Phaser's loader, so unlike the portraits it is addressed purely
// by convention rather than declared in the artwork manifest. The verifier
// enforces the convention so a missing or misspelled file is caught here rather
// than as a silent 404.
export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`;
/** Non-species speech clips, addressed the same way. */
export const UI_SPEECH = {
chooseSpecies: 'vega/ui-choose-start',
};
export function hasSpeciesVideo(scene, speciesId) {
return !!scene.cache.video?.exists(speciesVideoKey(speciesId));
}
export function hasSpeciesStill(scene, speciesId) {
return scene.textures.exists(speciesStillKey(speciesId));
}
/**
* Create a square species portrait `size` px across, centred on (x, y).
* Returns a Phaser Video or Image — caller adds it to its own container.
*/
export function makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size) {
if (hasSpeciesVideo(scene, speciesId)) {
const v = scene.add.video(x, y, speciesVideoKey(speciesId));
v.setMute(true);
v.setLoop(true);
// Scale from the known source size rather than setDisplaySize — see
// sourceWidth() for why a Video's own width cannot be trusted this early.
v.setScale(size / sourceWidth(v, PORTRAIT_VIDEO_PX));
v.play(true);
// If the file is present but the browser refuses to decode it, drop to the
// still rather than leaving a hole where the portrait should be.
v.once('error', () => {
if (!v.scene) return;
const fallback = makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size);
v.parentContainer?.add(fallback);
v.destroy();
});
return v;
}
return makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size);
}
function makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size) {
const img = hasSpeciesStill(scene, speciesId)
? scene.add.image(x, y, speciesStillKey(speciesId))
: scene.add.image(x, y, art.portraits, speciesPortraitFrame(rules, speciesId));
sizeSpeciesPortrait(img, size);
return img;
}
/**
* The width a game object should be scaled FROM.
*
* THE TRAP THIS EXISTS FOR: a freshly created Phaser `Video` does **not**
* report a width of zero. It carries a placeholder size until its first frame
* decodes, at which point `updateTexture()` builds the real texture and
* re-sizes the object to it. So the obvious `obj.width || SRC` guard never
* fires — it divides by the placeholder — and the scale is wrong by
* `placeholder / realWidth` from then on. `videoTexture` is null until that
* moment and is the only reliable way to tell the two states apart, which is
* what this gates on: the exact placeholder value does not matter, and phaser
* is not vendored here to read it off (the arithmetic below says 256).
*
* This went unnoticed for as long as it did because every video in this game
* WAS 256 px square: the placeholder and the fallback were the same number, so
* the bad branch and the good branch agreed. The 960x544 colony clips are what
* finally showed it — they opened at 2.5x and only snapped to the right size
* on the second time round the loop, when the re-fit ran against a texture
* that by then was real.
*/
export function sourceWidth(obj, fallback) {
if (obj.type === 'Video' && !obj.videoTexture) return fallback;
return obj.width || fallback;
}
/**
* Resize a portrait made by makeSpeciesPortrait, whichever tier it came from.
*
* Scales from the object's TEXTURE width rather than calling setDisplaySize,
* so it stays correct after repeated resizes (displayWidth changes, width does
* not) and survives a Video whose first frame has not decoded yet.
*/
export function sizeSpeciesPortrait(obj, size) {
obj.setScale(size / sourceWidth(obj, PORTRAIT_VIDEO_PX));
return obj;
}
// --------------------------------------------------------------------------
// World backdrops
//
// Full-screen 1920x1080 opaque art for the colony screen, one per planet type,
// declared in the artwork manifest's `worldBackgrounds` block. Deliberately NOT
// part of the `planets` sheet: that one holds transparent 192px discs for the
// orrery, which is a different picture of the same world.
//
// Same drop-in contract as the species portraits — a type with no art returns
// null and the caller paints a gradient from the type's own colour, so the set
// can be finished one world at a time with no code change.
export const worldBgKey = (typeId) => `vega-world-${typeId}`;
/** Loaded backdrop texture key for a planet type, or null if there is no art. */
export function worldBackground(scene, typeId) {
const key = worldBgKey(typeId);
return scene.textures.exists(key) ? key : null;
}
// Colony-founding vignettes
//
// A 960x544 clip per COLONISABLE planet type (`colonyVideos` in the manifest),
// played full-screen by VegaColonyIntro.js. Third picture of the same world
// after the orrery disc and the backdrop still, and it falls back to that still
// — and then to the gradient — when a type has no clip.
export const colonyVideoKey = (typeId) => `vega-colony-${typeId}`;
export function hasColonyVideo(scene, typeId) {
return !!scene.cache.video?.exists(colonyVideoKey(typeId));
}
// Audience-screen mood clips (VegaAudience.js) — one per diplomacy-capable
// species x {angry, neutral, happy}. Kept here rather than in VegaAudience.js
// itself so the naming convention stays importable from a Phaser-free module
// (VegaAudience.js pulls in `phaser` for Color/Loader-event helpers, which
// tools/verifyMasterOfVega.js — a plain Node script — cannot resolve).
export const audienceVideoKey = (speciesId, mood) => `vega-audience-${speciesId}-${mood}`;
export function hasAudienceVideo(scene, speciesId, mood) {
return !!scene.cache.video?.exists(audienceVideoKey(speciesId, mood));
}
// Research-screen ambient loops (VegaResearchScreen.js) — one per species,
// showing that species' scientists at work, JIT-loaded like the audience
// clips above but with no mood dimension and no play-once contract (these
// loop continuously for as long as the screen is open).
export const speciesResearchVideoKey = (speciesId) => `vega-research-${speciesId}`;
export function hasSpeciesResearchVideo(scene, speciesId) {
return !!scene.cache.video?.exists(speciesResearchVideoKey(speciesId));
}
// Colonies-screen advisor loops (VegaColoniesScreen.js) — one per species,
// a distinct Colonial Advisor persona (not the leader portrait, not the
// research scientist). JIT-loaded the same way, no mood dimension, loops
// continuously for as long as the screen is open.
export const advisorVideoKey = (speciesId) => `vega-advisor-${speciesId}`;
export function hasAdvisorVideo(scene, speciesId) {
return !!scene.cache.video?.exists(advisorVideoKey(speciesId));
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];
const h = rules.hulls[hullId];
return (s?.shipFrame ?? 0) * 8 + (h?.frame ?? 0);
};
export const planetFrame = (rules, typeId) => rules.planetTypes[typeId]?.frame ?? 0;
export const starFrame = (rules, classId) => rules.starClassList.findIndex((c) => c.id === classId);
export const speciesPortraitFrame = (rules, speciesId) => rules.species[speciesId]?.portraitFrame ?? 0;
export const techFrame = (rules, techId) => rules.techs[techId]?.iconFrame ?? 0;
export const buildingFrame = (rules, buildingId) => rules.buildings[buildingId]?.frame ?? 0;