// 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-.mp4) // 2. a high-resolution still (assets/images/vega/char-.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/.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;