orbit/js/galaxy/SystemChart.js

267 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SystemChart — the pure geometry + stats behind the deck's MAP console
* (js/ui/MapWindow.js draws on it; GameScene feeds it).
*
* chartBounds(objects, padding) the system's chart frame: the furthest
* X/Y extents of EVERY object (planets,
* stations, jump gates AND asteroid
* clusters — discovered or not) plus
* `padding` px (data/map.json →
* bounds.padding, ~1024) on each edge.
* fitToRect(bounds, w, h) the world → plate transform that fits
* that frame into the map plate, centred.
* navDiscoveryStats(d, sysId, c) the SYSTEM DISCOVERY share — the NAV
* objects (planets + free-space stations
* + jump gates; the central body is the
* chart's anchor, not a count) vs. the
* player's Discovery state.
* resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the
* asteroid fields vs. Discovery (the only
* resource kind for now).
* systemChartSnapshot(id, c, o) the SYSTEM tab's snapshot (the
* 'system' socket of js/ui/MapWindow.js)
* — the SAME shape GameScene.
* mapChartSnapshot() returns for the
* CURRENT system, built purely from a
* REMOTE system's generated content
* (no ship, no tethers — they live in
* the current system only).
*
* Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV
* point set is SystemCategory.navPoints (the same discoverable set the
* jumpgate chart gate uses) minus the central body, per the readout's
* rule: planets, stations, gates.
*/
import { navPoints } from '../research/SystemCategory.js';
import { config } from '../config/Config.js';
/**
* The chart's bounding frame from a list of objects.
*
* @param {Array<{x:number, y:number, radius:number}>} objects — the system's
* objects (any discoverable set; `radius` = the object's extent from its
* center — planet disc radius, station/gate keepout, cluster bound)
* @param {number} [padding=1024] — px of margin added on every edge
* @returns {{minX:number, minY:number, maxX:number, maxY:number,
* w:number, h:number, cx:number, cy:number}}
* (a degenerate empty list yields a `2×padding` box about the origin)
*/
export function chartBounds(objects, padding = 1024) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const o of objects ?? []) {
if (!o || typeof o.x !== 'number' || typeof o.y !== 'number') continue;
const r = Math.max(0, Number(o.radius) || 0);
if (o.x - r < minX) minX = o.x - r;
if (o.y - r < minY) minY = o.y - r;
if (o.x + r > maxX) maxX = o.x + r;
if (o.y + r > maxY) maxY = o.y + r;
}
if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) {
// No objects: a zero-size box about the origin (the padding then makes
// the chart a 2×padding box about it).
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
}
/**
* Fit a world-space rect into a plate of `w × h`, centred, uniform scale.
*
* @param {ReturnType<typeof chartBounds>} bounds
* @param {number} w — plate width (px)
* @param {number} h — plate height (px)
* @returns {{scale:number, ox:number, oy:number, toX:(x:number)=>number,
* toY:(y:number)=>number}}
* `toX`/`toY` map world → plate; `scale` = plate px per world px.
*/
export function fitToRect(bounds, w = 100, h = 100) {
const scale = Math.min(w, h) / Math.max(1, Math.max(bounds.w, bounds.h));
const ox = w / 2 - bounds.cx * scale;
const oy = h / 2 - bounds.cy * scale;
return {
scale,
ox,
oy,
toX: (x) => x * scale + ox,
toY: (y) => y * scale + oy,
};
}
/**
* The SYSTEM DISCOVERY share (data/map.json → stats): the player's found /
* total over the system's NAV objects — planets, free-space stations and
* jump gates (SystemCategory.navPoints minus the central body 'home',
* which is the chart's anchor rather than a discoverable count — and
* minus the asteroid clusters, which are the RESOURCE side of the readout).
*
* @param {object} discovery — the Discovery state (isDiscovered)
* @param {string} systemId
* @param {object} content — the generated system content
* @returns {{total:number, found:number, pct:number}} — `pct` is 0..1
* (0 when the system has no NAV objects, which the readout renders as —)
*/
export function navDiscoveryStats(discovery, systemId, content) {
const points = navPoints(content).filter((p) => p.kind !== 'home');
const canQuery = typeof discovery?.isDiscovered === 'function';
let found = 0;
for (const p of points) if (canQuery && discovery.isDiscovered(systemId, p.id)) found++;
const total = points.length;
return { total, found, pct: total > 0 ? found / total : 0 };
}
/**
* The SYSTEM RESOURCES share (data/map.json → stats): the player's found /
* total over the system's asteroid fields (content.asteroids — the only
* resource kind for now).
*
* @param {object} discovery — the Discovery state (isDiscovered)
* @param {string} systemId
* @param {object} content — the generated system content
* @returns {{total:number, found:number, pct:number}}
*/
export function resourceStats(discovery, systemId, content) {
const clusters = (content?.asteroids ?? []).filter((c) => c && typeof c.id === 'string');
const canQuery = typeof discovery?.isDiscovered === 'function';
let found = 0;
for (const c of clusters) if (canQuery && discovery.isDiscovered(systemId, c.id)) found++;
const total = clusters.length;
return { total, found, pct: total > 0 ? found / total : 0 };
}
/**
* The SYSTEM tab's chart snapshot — a CHARTED star picked on the GALAXY
* tab, drawn by the same painter as the CURRENT SYSTEM tab (the plate
* covers EVERY object of that system; the chart shows the discovered
* ones). Same shape as GameScene.mapChartSnapshot(), minus what cannot
* exist in a remote system: the ship (it is in the CURRENT system) and
* the tether union (the player's built-in tethers are current-system
* state).
*
* Object ids are the DISCOVERY identities (navPoints + asteroid cluster
* ids), so a later jump to the system repaints an identical chart from
* the same Discovery footprint.
*
* @param {string} systemId
* @param {object} content — the system's generated content (ensureContent)
* @param {object} [o={}] — hooks
* @param {(id:string)=>boolean} [o.isDiscovered] — discovery check for
* `systemId` (pass `(id) => discovery.isDiscovered(systemId, id)`);
* absent → nothing discovered (the safe default for a REMOTE system —
* the stats share the same footing)
* @param {object} [o.discovery] — the Discovery state (for the stats)
* @returns {?{systemId:string, systemName:string, isHome:false,
* central:object, objects:object[], tethers:number[],
* ship:null, stats:{nav:object, res:object}}}
* (null when the inputs are missing)
*/
export function systemChartSnapshot(systemId, content, o = {}) {
if (!systemId || !content) return null;
const isDisc = (id) => (typeof o.isDiscovered === 'function' ? !!o.isDiscovered(id) : false);
const frameWidth = config.get('planets.frameWidth', 1024);
const planetScale = config.get('planets.scale', 1);
const objects = [];
// planets — the discovery identity is the planet's NAME (navPoints)
for (const rec of content.planets ?? []) {
if (!rec || typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
objects.push({
id: rec.name,
kind: 'planet',
x: rec.x,
y: rec.y,
radius: (frameWidth * planetScale * (rec.scale ?? 1)) / 2,
name: rec.name,
typeLabel: config.get(`planets.typeLabels.${rec.class}`, rec.class),
discovered: isDisc(rec.name),
tint: config.get(`planets.classTint.${rec.class}`, '#9fb6d8'),
});
}
// free-space stations (a settlement's id — navPoints)
for (const s of content.settlements ?? []) {
if (!s || s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue;
const kind = s.kind ?? 'deepSpaceStation';
objects.push({
id: s.id,
kind: 'station',
x: s.x,
y: s.y,
radius: config.get(`stations.kinds.${kind}.size`, kind === 'waypoint' ? 46 : 108),
name: s.name,
typeLabel: kind === 'waypoint' ? 'Waypoint' : 'Station',
discovered: isDisc(s.id),
});
}
// jump gates (their id — navPoints)
for (const j of content.jumps ?? []) {
if (!j || typeof j.x !== 'number' || typeof j.y !== 'number') continue;
objects.push({
id: j.id,
kind: 'gate',
x: j.x,
y: j.y,
radius: j.size ?? config.get('gates.size', 96),
name: j.name,
typeLabel: j.toName ? `Gate → ${j.toName}` : 'Jump Gate',
discovered: isDisc(j.id),
rotation: j.rotation,
active: j.active === true,
});
}
// asteroid clusters (their id — resourceStats)
for (const c of content.asteroids ?? []) {
if (!c || typeof c.x !== 'number' || typeof c.y !== 'number') continue;
objects.push({
id: c.id,
kind: 'cluster',
x: c.x,
y: c.y,
radius: c.bound,
name: c.name,
typeLabel: 'Rock Field',
discovered: isDisc(c.id),
rocks: (c.asteroids ?? []).map((m) => ({
dx: m.x,
dy: m.y,
r: Math.max(1, m.size / 2),
seed: ((m.x * 7919 + m.y * 104729) % 1000) / 1000,
})),
});
}
// central body — the same rule as the current system's chart: the star
// is invisible dossier flavor (its class feeds the plate tag + hue)
const starClass = String(content.star?.class ?? '').toUpperCase();
const central = {
name: 'Star',
isHome: false,
visible: false,
radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
color: config.get(`planets.star.classColor.${starClass}`, '#ffe9b0'),
};
return {
systemId,
systemName: content.name ?? systemId,
isHome: false,
central,
objects,
tethers: [], // the player's tethers live in the CURRENT system only
ship: null, // the ship lives in the CURRENT system only
stats: {
nav: navDiscoveryStats(o.discovery, systemId, content),
res: resourceStats(o.discovery, systemId, content),
},
};
}