/** * 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} [o.visited] system ids the run has ENTERED * @param {Iterable} [o.used] lane keys (edgeKey) the run has * TRAVELED * @param {Iterable} [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] * @param {Iterable} [o.route] edge keys (edgeKey) on the active * route to the destination (GameScene.routeEdgeKeys) — the lanes the * player will fly; marked `route: true` so the plate draws them orange. * @param {string|null} [o.destinationId] the destination SYSTEM id — its * star is marked `isDestination: true` so the plate circles it. * @returns {{name:string, seed:string, homeSystemId:string|null, * currentSystemId:string|null, destinationId:string|null, * systems:Array, edges:Array, * stats:{systems:number, visited:number, lanes:number, lanesUsed:number}}} */ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null, route = null, destinationId = null } = {}) { const visitedSet = new Set(visited ?? []); const usedSet = new Set(used ?? []); const liveSet = new Set(live ?? []); const routeSet = new Set(route ?? []); 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, // The DESTINATION star — the route's final stop. The plate circles // it (GalaxyView) so the "where am I going" target is unmistakable. isDestination: s.id === destinationId, 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, // ROUTE — a lane on the active route to the destination: the // plate's "where am I going" path, drawn in the route's orange // (data/gates.json → route.compassColor), above used/frontier. route: routeSet.has(key), // 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, destinationId, 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.: `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..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), }; }