660 lines
27 KiB
JavaScript
660 lines
27 KiB
JavaScript
/**
|
||
* GalaxyChart — the pure model + geometry behind the MAP console's
|
||
* GALAXY tab (js/ui/GalaxyView.js renders it; GameScene feeds it).
|
||
*
|
||
* edgeKey(a, b) the undirected jump-lane key — min/max pair
|
||
* joined with '<' (save-stable: the same lane
|
||
* from either end)
|
||
* buildGalaxySnapshot(o) the live GALAXY plate data — the systems
|
||
* (position, type, visited), the dedup'd jump
|
||
* lanes (used / frontier / live), and the
|
||
* readout stats. Per-system `faction: null`
|
||
* is the FACTIONS seam (planned — see
|
||
* docs/PROJECT_NOTES.md "Factions"): the
|
||
* plate already reserves a faction color
|
||
* layer + a territory-region pass for it.
|
||
* convexHull(points) monotone-chain hull (interior + collinear
|
||
* points dropped)
|
||
* paddedHullPolygon(p, pad) the hull inflated by `pad` world-px (the
|
||
* CHARTED REGION outline; 1 pt → ring,
|
||
* 2 → capsule)
|
||
* starPulse(type, timeMs) the per-star-type pulse phase 0..1
|
||
* (data/map.json → galaxy.pulse)
|
||
* starTypeColor(type) the archetype's chart color
|
||
* (data/systems.json → types)
|
||
*
|
||
* Pure (no Phaser) — Node-testable (dev/galaxy-map.test.mjs).
|
||
*/
|
||
import { config } from '../config/Config.js';
|
||
|
||
/**
|
||
* The undirected key of a jump lane between systems a and b.
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {string} `min<max` — identical from either end
|
||
*/
|
||
export function edgeKey(a, b) {
|
||
return a <= b ? `${a}<${b}` : `${b}<${a}`;
|
||
}
|
||
|
||
/**
|
||
* The live galaxy-plate snapshot (see the file header).
|
||
*
|
||
* @param {object} o
|
||
* @param {import('./Galaxy.js').Galaxy} o.galaxy
|
||
* @param {Iterable<string>} [o.visited] system ids the run has ENTERED
|
||
* @param {Iterable<string>} [o.used] lane keys (edgeKey) the run has
|
||
* TRAVELED
|
||
* @param {Iterable<string>} [o.live] activated gate keys
|
||
* (`"${from}>${to}"`, GameScene.activatedGates) — the lanes a JUMP
|
||
* can currently be confirmed from the current system
|
||
* @param {string|null} [o.currentSystemId]
|
||
* @returns {{name:string, seed:string, homeSystemId:string|null,
|
||
* currentSystemId:string|null, systems:Array<object>, edges:Array<object>,
|
||
* stats:{systems:number, visited:number, lanes:number, lanesUsed:number}}}
|
||
*/
|
||
export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null } = {}) {
|
||
const visitedSet = new Set(visited ?? []);
|
||
const usedSet = new Set(used ?? []);
|
||
const liveSet = new Set(live ?? []);
|
||
const network = galaxy?.jumpNetwork ?? null;
|
||
const homeId = galaxy?.homeSystemId ?? null;
|
||
|
||
const systems = [];
|
||
for (const s of galaxy?.records ?? []) {
|
||
if (!s || typeof s.id !== 'string') continue;
|
||
systems.push({
|
||
id: s.id,
|
||
name: s.name,
|
||
type: s.type,
|
||
x: s.x,
|
||
y: s.y,
|
||
visited: visitedSet.has(s.id),
|
||
isHome: s.id === homeId,
|
||
isCurrent: s.id === currentSystemId,
|
||
gates: network?.gates?.get?.(s.id)?.length ?? 0,
|
||
// FACTIONS (planned — not yet implemented): the system's faction
|
||
// id + the player's relation tier (Neutral / Friendly / Aligned /
|
||
// Hostile) will land here. The galaxy plate reserves a faction
|
||
// color layer on the stars + a territory-region fill (the same
|
||
// hull pass as the charted region) for when it ships.
|
||
faction: null,
|
||
});
|
||
}
|
||
|
||
const ids = new Set(systems.map((s) => s.id));
|
||
const seen = new Set();
|
||
const edges = [];
|
||
for (const [a, dests] of network?.gates ?? []) {
|
||
if (!ids.has(a) || !Array.isArray(dests)) continue;
|
||
for (const b of dests) {
|
||
if (!ids.has(b) || a === b) continue;
|
||
const key = edgeKey(a, b);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const used = usedSet.has(key);
|
||
const lo = a < b ? a : b;
|
||
const hi = a < b ? b : a;
|
||
edges.push({
|
||
a: lo,
|
||
b: hi,
|
||
key,
|
||
used,
|
||
// FRONTIER — a lane leaving the charted region (exactly one end
|
||
// visited): the "next step" of the maze, drawn brighter than the
|
||
// unexplored web.
|
||
frontier: !used && visitedSet.has(lo) !== visitedSet.has(hi),
|
||
// LIVE — an activated gate on this lane out of the CURRENT
|
||
// system: the plate's CONFIRM JUMP applies to these.
|
||
live:
|
||
currentSystemId != null &&
|
||
(liveSet.has(`${currentSystemId}>${lo}`) || liveSet.has(`${currentSystemId}>${hi}`)),
|
||
});
|
||
}
|
||
}
|
||
|
||
const stats = {
|
||
systems: systems.length,
|
||
visited: systems.filter((s) => s.visited).length,
|
||
lanes: edges.length,
|
||
lanesUsed: edges.filter((e) => e.used).length,
|
||
};
|
||
return {
|
||
name: galaxy?.name ?? 'UNKNOWN GALAXY',
|
||
seed: galaxy?.seed ?? '',
|
||
homeSystemId: homeId,
|
||
currentSystemId,
|
||
systems,
|
||
edges,
|
||
stats,
|
||
};
|
||
}
|
||
|
||
// ── charted-region geometry ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Monotone-chain convex hull.
|
||
* @param {Array<{x:number, y:number}>} points
|
||
* @returns {Array<{x:number, y:number}>} the hull vertices in order
|
||
* (fewer than 3 input points → the input itself; all-collinear input
|
||
* → the two extreme points)
|
||
*/
|
||
export function convexHull(points) {
|
||
const pts = (points ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
|
||
if (pts.length < 3) return pts.map((p) => ({ x: p.x, y: p.y }));
|
||
const sorted = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
|
||
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||
const lower = [];
|
||
for (const p of sorted) {
|
||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||
lower.push(p);
|
||
}
|
||
const upper = [];
|
||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||
const p = sorted[i];
|
||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
||
upper.push(p);
|
||
}
|
||
return lower.slice(0, -1).concat(upper.slice(0, -1));
|
||
}
|
||
|
||
/** Shoelace signed area (positive for the hull orientation convexHull
|
||
* yields — the paddedHullPolygon outward-normal formula assumes it). */
|
||
function signedArea(pts) {
|
||
let s = 0;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[i];
|
||
const b = pts[(i + 1) % pts.length];
|
||
s += a.x * b.y - b.x * a.y;
|
||
}
|
||
return s / 2;
|
||
}
|
||
|
||
/** An n-gon ring of radius r about (cx, cy). */
|
||
function ring(cx, cy, r, n = 40) {
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const a = (i / n) * Math.PI * 2;
|
||
out.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A capsule: the segment a→b thickened by r (both end-discs). */
|
||
function capsule(a, b, r) {
|
||
if (r <= 0) return [a, b].map((p) => ({ x: p.x, y: p.y }));
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
const nx = dy / len;
|
||
const ny = -dx / len; // either normal — the shape is symmetric
|
||
const n = 14;
|
||
const out = [];
|
||
for (let i = 0; i <= n; i++) {
|
||
const t = i / n;
|
||
out.push({ x: a.x + dx * t + nx * r, y: a.y + dy * t + ny * r });
|
||
}
|
||
for (let i = n; i >= 0; i--) {
|
||
const t = i / n;
|
||
out.push({ x: a.x + dx * t - nx * r, y: a.y + dy * t - ny * r });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The charted-region polygon: the convex hull of the given points
|
||
* inflated by `pad` (world px) — each edge translated outward along its
|
||
* normal, corners bridged (the Minkowski-sum-with-a-disc shape, sampled).
|
||
*
|
||
* @param {Array<{x:number, y:number}>} points — the region's points
|
||
* @param {number} [pad=0] the inflation, in the same units as the points
|
||
* @returns {Array<{x:number, y:number}>} a closed polygon (length ≥ 2)
|
||
*/
|
||
export function paddedHullPolygon(points, pad = 0) {
|
||
const hull = convexHull(points);
|
||
const p = Math.max(0, Number(pad) || 0);
|
||
if (hull.length === 0) return [];
|
||
if (hull.length === 1) return ring(hull[0].x, hull[0].y, p);
|
||
if (hull.length === 2) return capsule(hull[0], hull[1], p);
|
||
if (p <= 0) return hull;
|
||
// Outward normals assume the convexHull orientation — enforce it.
|
||
const poly = signedArea(hull) < 0 ? [...hull].reverse() : hull;
|
||
const n = poly.length;
|
||
// Each edge translated outward by p along its normal…
|
||
const offs = poly.map((a, i) => {
|
||
const b = poly[(i + 1) % n];
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
const nx = dy / len;
|
||
const ny = -dx / len; // outward (see signedArea's orientation note)
|
||
return { p: { x: a.x + nx * p, y: a.y + ny * p }, d: { x: dx, y: dy } };
|
||
});
|
||
// …and corner i = where edge (i−1)'s offset line meets edge i's —
|
||
// the true parallel polygon (the Minkowski sum's sharp corners).
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const L1 = offs[(i - 1 + n) % n];
|
||
const L2 = offs[i];
|
||
const det = L1.d.x * L2.d.y - L1.d.y * L2.d.x;
|
||
if (Math.abs(det) < 1e-12) {
|
||
out.push({ x: L2.p.x, y: L2.p.y });
|
||
continue;
|
||
}
|
||
const t = ((L2.p.x - L1.p.x) * L2.d.y - (L2.p.y - L1.p.y) * L2.d.x) / det;
|
||
out.push({ x: L1.p.x + t * L1.d.x, y: L1.p.y + t * L1.d.y });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── star animation ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A star-type's pulse phase, 0..1 — the per-archetype heartbeat of the
|
||
* galaxy plate (data/map.json → galaxy.pulse.<type>: `speed` in Hz,
|
||
* `amp` = the swing depth 0..1 (1 = full 0..1 swing, 0 = steady 0.5)).
|
||
* Deterministic for (type, timeMs, phase) — the per-star `phase` is a
|
||
* seeded offset (GalaxyView) so stars don't beat in unison.
|
||
*
|
||
* @param {string} type the system archetype (data/systems.json keys)
|
||
* @param {number} timeMs scene time (ms)
|
||
* @param {number} [phase=0] per-star phase offset (radians)
|
||
* @returns {number} 0.5 - 0.5·amp … 0.5 + 0.5·amp
|
||
*/
|
||
export function starPulse(type, timeMs = 0, phase = 0) {
|
||
const pc = config.get(`map.galaxy.pulse.${type}`, {});
|
||
const speed = Math.max(0.02, Number(pc?.speed ?? 1) || 1);
|
||
const amp = Math.min(1, Math.max(0, Number(pc?.amp ?? 0.6) || 0));
|
||
return 0.5 + 0.5 * amp * Math.sin((timeMs / 1000) * speed * Math.PI * 2 + phase);
|
||
}
|
||
|
||
/**
|
||
* The archetype's chart color (data/systems.json → types.<t>.theme.color)
|
||
* — a CSS hex string, or `fallback` for an unknown/misconfigured type.
|
||
* @param {string} type
|
||
* @param {string} [fallback]
|
||
* @returns {string} '#rrggbb'
|
||
*/
|
||
export function starTypeColor(type, fallback = '#9fb6d8') {
|
||
const hex = config.get(`systems.types.${type}.theme.color`, null);
|
||
return typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : fallback;
|
||
}
|
||
|
||
// ── plate clipping (the chart stays INSIDE its window) ────────────────────────────
|
||
//
|
||
// The galaxy is drawn in world space and magnified by the view (zoom/pan), so
|
||
// at high zoom the lanes/hull run past the plate's padded frame. The plate is
|
||
// the window onto the galaxy — its content is clipped to the plate rect.
|
||
// (The vendored v4 build has no mask API, so the drawing passes clip their
|
||
// own geometry with these two pure functions.)
|
||
|
||
/**
|
||
* Liang-Barsky: clip a line segment to an axis-aligned rect.
|
||
* @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
|
||
* @param {{x:number, y:number, w:number, h:number}} r the plate rect
|
||
* @returns {number[]|null} [x1, y1, x2, y2] — or null when fully outside
|
||
*/
|
||
export function clipLineToRect(x1, y1, x2, y2, r) {
|
||
let t0 = 0;
|
||
let t1 = 1;
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
const clips = [
|
||
[-dx, x1 - r.x],
|
||
[dx, r.x + r.w - x1],
|
||
[-dy, y1 - r.y],
|
||
[dy, r.y + r.h - y1],
|
||
];
|
||
for (const [p, q] of clips) {
|
||
if (p === 0) {
|
||
if (q < 0) return null; // parallel and outside
|
||
continue;
|
||
}
|
||
const t = q / p;
|
||
if (p < 0) {
|
||
if (t > t1) return null;
|
||
if (t > t0) t0 = t;
|
||
} else {
|
||
if (t < t0) return null;
|
||
if (t < t1) t1 = t;
|
||
}
|
||
}
|
||
return [x1 + t0 * dx, y1 + t0 * dy, x1 + t1 * dx, y1 + t1 * dy];
|
||
}
|
||
|
||
/**
|
||
* Sutherland-Hodgman: clip a polygon to an axis-aligned rect (four
|
||
* half-plane passes). The result keeps winding; it is empty when the
|
||
* polygon is fully outside.
|
||
* @param {Array<{x:number, y:number}>} pts
|
||
* @param {{x:number, y:number, w:number, h:number}} r the plate rect
|
||
* @returns {Array<{x:number, y:number}>} the clipped polygon (0..n pts)
|
||
*/
|
||
export function clipPolygonToRect(pts, r) {
|
||
const x1 = r.x;
|
||
const y1 = r.y;
|
||
const x2 = r.x + r.w;
|
||
const y2 = r.y + r.h;
|
||
const clipEdge = (list, inside, cross) => {
|
||
const out = [];
|
||
for (let i = 0; i < list.length; i++) {
|
||
const a = list[i];
|
||
const b = list[(i + 1) % list.length];
|
||
const ain = inside(a);
|
||
const bin = inside(b);
|
||
if (ain && bin) out.push(b);
|
||
else if (ain) out.push(cross(a, b));
|
||
else if (bin) {
|
||
out.push(cross(a, b));
|
||
out.push(b);
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
const xInt = (bnd) => (a, b) => {
|
||
const t = (bnd - a.x) / (b.x - a.x);
|
||
return { x: bnd, y: a.y + t * (b.y - a.y) };
|
||
};
|
||
const yInt = (bnd) => (a, b) => {
|
||
const t = (bnd - a.y) / (b.y - a.y);
|
||
return { x: a.x + t * (b.x - a.x), y: bnd };
|
||
};
|
||
let list = pts;
|
||
list = clipEdge(list, (p) => p.x >= x1, xInt(x1));
|
||
list = clipEdge(list, (p) => p.x <= x2, xInt(x2));
|
||
list = clipEdge(list, (p) => p.y >= y1, yInt(y1));
|
||
list = clipEdge(list, (p) => p.y <= y2, yInt(y2));
|
||
return list;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* STAR ART — the zoom-bloom design for each star on the GALAXY plate.
|
||
*
|
||
* A star is a ~3px dot when the plate is fully zoomed out, and it
|
||
* BLOOMS as you zoom in (data/map.json → galaxy.stars: minPx/zoomGrow
|
||
* set the dot, art.flareAt/crownAt/surfaceAt set when each layer
|
||
* fades in):
|
||
*
|
||
* flare — diffraction spikes (the "star sparkle"), per-type count
|
||
* crown — the type's signature, all seeded per system:
|
||
* main granulation rim + corona ticks
|
||
* redDwarf breathing corona + prominence arcs
|
||
* binary an orbiting companion on a faint ellipse
|
||
* habitable the life-zone rings + orbiting planet(s)
|
||
* nebula a tilted accretion disc + speckles
|
||
* void photon ring + dark horizon + lensing ticks
|
||
* surface — a slow surface wobble + a glint (the core gains texture)
|
||
*
|
||
* Pure (no Phaser) — this module returns a list of typed primitives
|
||
* relative to the star's center; GalaxyView blits them. Node-testable
|
||
* (dev/star-art.test.mjs).
|
||
* ------------------------------------------------------------------ */
|
||
const TAU_ = Math.PI * 2;
|
||
|
||
const num = (v, d = 0) => (Number.isFinite(+v) ? +v : d);
|
||
|
||
/** Mix two #rrggbb colors (t = 0..1, toward b). Returns #rrggbb. */
|
||
export function mixHex(a, b, t) {
|
||
const p = (h) => {
|
||
const n = parseInt(String(h).replace('#', ''), 16);
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
};
|
||
const [r1, g1, b1] = p(a);
|
||
const [r2, g2, b2] = p(b);
|
||
const c = (x, y) => Math.round(x + (y - x) * Math.min(1, Math.max(0, t)));
|
||
return (
|
||
'#' +
|
||
[c(r1, r2), c(g1, g2), c(b1, b2)]
|
||
.map((v) => v.toString(16).padStart(2, '0'))
|
||
.join('')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The star's core-dot size in plate-px at zoom z.
|
||
* minPx = the fully-zoomed-out floor (a tad bigger than a pixel);
|
||
* zoomGrow = how many px it gains per zoom step.
|
||
* @returns {number}
|
||
*/
|
||
export function starDotPx(z, cfg = {}) {
|
||
const minPx = Math.max(1, num(cfg.minPx, 3));
|
||
const grow = Math.max(0, num(cfg.zoomGrow, 1.2));
|
||
return minPx + Math.max(0, num(z, 1) - 1) * grow;
|
||
}
|
||
|
||
/** 0 below `at`, 1 at/after `at + fade`, linear between (layer fade-in). */
|
||
export function layerFade(z, at, fade) {
|
||
const f = Math.max(0.01, num(fade, 0.45));
|
||
return Math.min(1, Math.max(0, (num(z, 1) - at) / f));
|
||
}
|
||
|
||
/**
|
||
* The star art spec — the primitives to draw for one star.
|
||
* @param {string} type the archetype (main | redDwarf | binary | habitable | nebula | void)
|
||
* @param {object} o
|
||
* o.z zoom (map.galaxy.zoom.zMin..zMax)
|
||
* o.time ms (scene time — drives the slow animations)
|
||
* o.dot the core-dot px at this zoom (starDotPx × type sizeMul)
|
||
* o.pulse the type's pulse phase 0..1 (starPulse)
|
||
* o.seed pre-rolled per-system values (see below; any value may
|
||
* be missing — a fresh seed is derived deterministically)
|
||
* o.cfg the `galaxy.stars.art` config block
|
||
* @returns {{prims: object[]}} primitives in paint order (earliest first)
|
||
* each prim: { k, … } — one of:
|
||
* ellipse { rx, ry, rot, w, a, c } a tilted ring (disc layers)
|
||
* ring { r, w, a, c, dash?, dashA? } a circle (dash = n segments)
|
||
* arc { r, a0, a1, w, a, c } a partial circle (radians)
|
||
* tri { len, w, ang, a, c } a diffraction spike (triangle)
|
||
* dot { x, y, r, a, c } a filled circle (companion, planet)
|
||
* tick { r0, r1, ang, w, a, c } a radial line
|
||
* wobble { r, amp, n, rot, w, a, c } a surface-noise circle (n lobes)
|
||
* darkdot { r, a, c } a flat dark disc (the void horizon)
|
||
*/
|
||
export function starArtSpec(type, o = {}) {
|
||
const z = num(o.z, 1);
|
||
const time = num(o.time, 0);
|
||
const dot = Math.max(1, num(o.dot, 3));
|
||
const p = num(o.pulse, 0.5);
|
||
const cfg = o.cfg ?? {};
|
||
const fadeSpan = num(cfg.fade, 0.45);
|
||
const tc = typeCfg(cfg, type);
|
||
|
||
const aFlare = layerFade(z, num(tc.flareAt ?? cfg.flareAt, 1.6), fadeSpan);
|
||
const aCrown = layerFade(z, num(tc.crownAt ?? cfg.crownAt, 2.8), fadeSpan);
|
||
const aSurf = layerFade(z, num(tc.surfaceAt ?? cfg.surfaceAt, 4.5), fadeSpan);
|
||
if (aFlare + aCrown + aSurf <= 0.001) return { prims: [] };
|
||
|
||
const base = starTypeColor(type);
|
||
const seed = o.seed ?? {};
|
||
// deterministic per-system fallbacks (the painter pre-rolls these in
|
||
// _buildStars; the fallbacks keep this pure function self-sufficient)
|
||
const S = {
|
||
spikeAngle: num(seed.spikeAngle, 0),
|
||
ringRot: num(seed.ringRot, 0),
|
||
tickAngles: seed.tickAngles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => (i / 12) * TAU_ + 0.07),
|
||
tickLens: seed.tickLens ?? [0.3, 0.4, 0.35, 0.45, 0.3, 0.4, 0.38, 0.32, 0.42, 0.36, 0.3, 0.44],
|
||
proms: seed.proms ?? [
|
||
{ a: 0.6, span: 0.9, r: 1.35 },
|
||
{ a: 2.7, span: 0.7, r: 1.5 },
|
||
{ a: 4.4, span: 1.0, r: 1.3 },
|
||
],
|
||
orbitRot: num(seed.orbitRot, 0.5),
|
||
compPhase: num(seed.compPhase, 0),
|
||
planetPhases: seed.planetPhases ?? [0.8, 3.9],
|
||
speckles: seed.speckles ?? [0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => ({ a: i * 0.7 + 0.3, s: 0.7 + ((i * 37) % 10) / 22 })),
|
||
glintAngle: num(seed.glintAngle, 0.9),
|
||
wobblePhase: num(seed.wobblePhase, 0),
|
||
dashOffsets: seed.dashOffsets ?? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => i / 10),
|
||
lensAngles: seed.lensAngles ?? [0.4, 1.9, 3.6, 5.2],
|
||
};
|
||
|
||
const prims = [];
|
||
const slow = time * 0.00005; // a slow, calm rotation for all spinners
|
||
|
||
/* — NEBULA: the tilted disc goes FIRST (behind the star body) — */
|
||
if (tc.crown === 'disk' && aCrown > 0) {
|
||
const tilt = num(tc.tilt, 0.4);
|
||
const rot = S.orbitRot + slow * 0.4;
|
||
const rings = tc.rings ?? [2.1, 2.9, 3.7];
|
||
const alphas = [0.16, 0.11, 0.08];
|
||
rings.forEach((rr, i) => {
|
||
const rx = num(rr, 2 + i) * dot;
|
||
prims.push({ k: 'ellipse', rx, ry: rx * tilt, rot, w: 2.6 - i * 0.5, a: alphas[i] * aCrown, c: base });
|
||
});
|
||
// drifting speckles on the middle ring (gas clumps)
|
||
const mr = num(rings[1] ?? 2.9, 2.9) * dot;
|
||
const mry = mr * tilt;
|
||
S.speckles.forEach((sp, i) => {
|
||
const ang = sp.a + slow * 1.2;
|
||
const x = Math.cos(ang) * mr;
|
||
const y = Math.sin(ang) * mry;
|
||
// rotate into the disc plane
|
||
const cs = Math.cos(rot);
|
||
const sn = Math.sin(rot);
|
||
prims.push({
|
||
k: 'dot',
|
||
x: x * cs - y * sn,
|
||
y: x * sn + y * cs,
|
||
r: 0.7 + sp.s * 0.7,
|
||
a: (0.22 + 0.1 * Math.sin(time * 0.001 + i)) * aCrown,
|
||
c: mixHex(base, '#ffffff', 0.3),
|
||
});
|
||
});
|
||
}
|
||
|
||
/* — BINARY: the faint orbit ellipse (behind both stars) — */
|
||
if (tc.crown === 'companion' && aCrown > 0) {
|
||
const dist = num(tc.companion?.dist, 2.7) * dot;
|
||
const tilt = num(tc.companion?.tilt, 0.55);
|
||
prims.push({ k: 'ellipse', rx: dist, ry: dist * tilt, rot: S.orbitRot, w: 1, a: 0.3 * aCrown, c: base });
|
||
}
|
||
|
||
/* — SPIKES: the diffraction cross (all types but the void) — */
|
||
const spikes = Math.max(0, num(tc.spikes, 4));
|
||
if (spikes > 0 && aFlare > 0) {
|
||
const n = spikes;
|
||
const len = dot * (3.0 + 0.5 * p) * (tc.spikeLenMul ?? 1);
|
||
for (let i = 0; i < n; i++) {
|
||
const ang = S.spikeAngle + (i / n) * TAU_ + (tc.spikeRot ?? 0);
|
||
prims.push({ k: 'tri', len, w: Math.max(0.8, dot * 0.22), ang, a: 0.42 * aFlare * (tc.spikeAlpha ?? 1), c: base });
|
||
}
|
||
// a hot inner cross, rotated half a step (the sparkle's second layer)
|
||
for (let i = 0; i < n; i++) {
|
||
const ang = S.spikeAngle + (i / n) * TAU_ + Math.PI / n + (tc.spikeRot ?? 0);
|
||
prims.push({ k: 'tri', len: len * 0.45, w: Math.max(0.6, dot * 0.14), ang, a: 0.5 * aFlare, c: mixHex(base, '#ffffff', 0.55) });
|
||
}
|
||
}
|
||
|
||
/* — TYPE CROWNS — */
|
||
if (aCrown > 0) {
|
||
if (tc.crown === 'granulation') {
|
||
prims.push({ k: 'ring', r: dot * 1.42, w: 1.2, a: 0.42 * aCrown, c: base });
|
||
prims.push({ k: 'ring', r: dot * 1.1, w: 1, a: 0.28 * aCrown, c: base });
|
||
// corona ticks — seeded lengths, a slow drift
|
||
S.tickAngles.forEach((ang, i) => {
|
||
const a = ang + slow;
|
||
const r0 = dot * 1.12;
|
||
const r1 = r0 + dot * (S.tickLens[i % S.tickLens.length] ?? 0.35);
|
||
prims.push({ k: 'tick', r0, r1, ang: a, w: 1, a: 0.34 * aCrown, c: base });
|
||
});
|
||
} else if (tc.crown === 'prominences') {
|
||
// a breathing double corona
|
||
const breathe = 0.75 + 0.25 * p;
|
||
prims.push({ k: 'ring', r: dot * 1.38 * breathe, w: 1.6, a: 0.34 * aCrown, c: base });
|
||
prims.push({ k: 'ring', r: dot * 1.85 * breathe, w: 1.2, a: 0.2 * aCrown, c: base });
|
||
// prominence arcs — hot filaments arcing off the limb
|
||
S.proms.forEach((pr, i) => {
|
||
const span = num(pr.span, 0.8);
|
||
const r = num(pr.r, 1.4) * dot * (1 + 0.08 * Math.sin(time * 0.0009 + i * 2));
|
||
const a0 = num(pr.a, i) + slow * 0.7;
|
||
prims.push({ k: 'arc', r, a0, a1: a0 + span, w: 1.8, a: 0.4 * aCrown, c: base });
|
||
prims.push({ k: 'arc', r, a0: a0 + span * 0.25, a1: a0 + span * 0.75, w: 1, a: 0.5 * aCrown, c: mixHex(base, '#ffd9a0', 0.6) });
|
||
});
|
||
} else if (tc.crown === 'companion') {
|
||
// the second star, riding its seeded orbit
|
||
const dist = num(tc.companion?.dist, 2.7) * dot;
|
||
const tilt = num(tc.companion?.tilt, 0.55);
|
||
const period = Math.max(1000, num(tc.companion?.periodMs, 14000));
|
||
const ang = S.compPhase + (TAU_ * time) / period;
|
||
const lx = Math.cos(ang) * dist;
|
||
const ly = Math.sin(ang) * dist * tilt;
|
||
const cs = Math.cos(S.orbitRot);
|
||
const sn = Math.sin(S.orbitRot);
|
||
const x = lx * cs - ly * sn;
|
||
const y = lx * sn + ly * cs;
|
||
const compColor = mixHex(base, '#ffffff', 0.28);
|
||
const cr = Math.max(1.1, dot * num(tc.companion?.size, 0.5) * 0.62);
|
||
// a soft halo + core + its own mini-sparkle
|
||
prims.push({ k: 'dot', x, y, r: cr * 2.6, a: 0.14 * aCrown, c: compColor });
|
||
prims.push({ k: 'dot', x, y, r: cr * 1.5, a: 0.3 * aCrown, c: compColor });
|
||
prims.push({ k: 'dot', x, y, r: cr, a: 0.95 * aCrown, c: mixHex(compColor, '#ffffff', 0.5) });
|
||
for (let i = 0; i < 4; i++) {
|
||
const sa = S.compPhase * 1.7 + (i / 4) * TAU_;
|
||
prims.push({ k: 'tri', len: cr * 3.2, w: Math.max(0.6, cr * 0.3), ang: sa, x, y, a: 0.3 * aCrown, c: compColor });
|
||
}
|
||
} else if (tc.crown === 'lifeRing') {
|
||
// the habitable band — a soft annulus + two crisp orbits
|
||
prims.push({ k: 'ring', r: dot * 1.95, w: dot * 0.7, a: 0.12 * aCrown, c: base });
|
||
const orbits = [num(tc.ring ?? 2.2, 2.2), 1.7];
|
||
prims.push({ k: 'ring', r: dot * orbits[0], w: 1, a: 0.4 * aCrown, c: base });
|
||
prims.push({ k: 'ring', r: dot * orbits[1], w: 1, a: 0.26 * aCrown, c: base });
|
||
// orbiting world(s) with a comet trail behind
|
||
const periods = [num(tc.planetPeriodMs, 21000), num(tc.planet2PeriodMs, 33000)];
|
||
[0, 1].forEach((i) => {
|
||
const rr = dot * orbits[i];
|
||
const period = Math.max(1000, periods[i]);
|
||
const ang = (S.planetPhases[i] ?? i * 2.2) + (TAU_ * time) / period;
|
||
const x = Math.cos(ang) * rr;
|
||
const y = Math.sin(ang) * rr;
|
||
prims.push({ k: 'arc', r: rr, a0: ang - 0.85, a1: ang, w: 1.2, a: 0.3 * aCrown, c: base });
|
||
prims.push({ k: 'dot', x, y, r: Math.max(1.3, dot * num(i === 0 ? tc.planetSize : tc.planet2Size, i === 0 ? 0.16 : 0.11)), a: 0.95 * aCrown, c: mixHex(base, '#ffffff', 0.35) });
|
||
});
|
||
} else if (tc.crown === 'horizon') {
|
||
// the void — a dark horizon with a photon ring that slowly shimmers
|
||
const shim = 0.8 + 0.2 * Math.sin(time * 0.0012 + S.glintAngle * 3);
|
||
const voidC = typeof tc.voidColor === 'string' ? tc.voidColor : '#040810';
|
||
prims.push({ k: 'darkdot', r: dot * 1.02, a: 0.92 * aCrown, c: voidC });
|
||
prims.push({ k: 'ring', r: dot * 1.2, w: 1.4, a: 0.8 * aCrown * shim, c: mixHex(base, '#ffffff', 0.25) });
|
||
// a faint outer cage (lensing ticks, seeded)
|
||
prims.push({ k: 'ring', r: dot * 1.9, w: 1, a: 0.22 * aCrown, c: base, dash: 10, dashA: 0.5 });
|
||
S.lensAngles.forEach((la) => {
|
||
const a = la + slow * 0.6;
|
||
prims.push({ k: 'tick', r0: dot * 2.05, r1: dot * 2.4, ang: a, w: 1, a: 0.3 * aCrown, c: base });
|
||
});
|
||
}
|
||
}
|
||
|
||
/* — SURFACE: the core gains texture at high zoom — */
|
||
if (aSurf > 0) {
|
||
prims.push({
|
||
k: 'wobble',
|
||
r: dot * 1.0,
|
||
amp: 0.07,
|
||
n: 3,
|
||
rot: S.wobblePhase + slow * 1.5,
|
||
w: 1,
|
||
a: 0.4 * aSurf,
|
||
c: mixHex(base, '#ffffff', 0.3),
|
||
});
|
||
// the glint — a fixed hot spot on the surface (seeded bearing)
|
||
const gx = Math.cos(S.glintAngle) * dot * 0.34;
|
||
const gy = Math.sin(S.glintAngle) * dot * 0.34;
|
||
prims.push({ k: 'dot', x: gx, y: gy, r: Math.max(0.9, dot * 0.16), a: 0.7 * aSurf, c: '#ffffff' });
|
||
}
|
||
|
||
return { prims };
|
||
}
|
||
|
||
/** The art config for one type (falls back to the shared defaults). */
|
||
function typeCfg(art, type) {
|
||
const t = art?.[type] ?? {};
|
||
return {
|
||
...art,
|
||
...t,
|
||
spikes: num(t.spikes, art?.spikes ?? 4),
|
||
flareAt: num(t.flareAt, art?.flareAt ?? 1.6),
|
||
crownAt: num(t.crownAt, art?.crownAt ?? 2.8),
|
||
surfaceAt: num(t.surfaceAt, art?.surfaceAt ?? 4.5),
|
||
};
|
||
}
|