267 lines
11 KiB
JavaScript
267 lines
11 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).
|
||
* 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),
|
||
},
|
||
};
|
||
}
|