728 lines
28 KiB
JavaScript
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;
|