131 lines
5.7 KiB
JavaScript
131 lines
5.7 KiB
JavaScript
/**
|
||
* 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).
|
||
*
|
||
* 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';
|
||
|
||
/**
|
||
* 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 };
|
||
}
|