orbit/js/galaxy/PlanetFrames.js

114 lines
4.9 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.

import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
/**
* FRAME DIVERSITY — the galaxy-wide (class, frame) assignment.
*
* data/planets.json → frames maps each planet class to a pool of
* spritesheet frames (terran/ice/lava share 02, gas 35, rocky 68 —
* the same face can be worn by different classes; that's fine). Picking
* a random frame per system at render time would let neighboring stars
* wear the same face; this pass spreads each (class, frame) across the
* galaxy instead: when a system's class-C planet needs a frame, it
* AVOIDS frames class C already wears among the NEAREST stars (the
* galaxy's neighbor pool, data/galaxy.json → neighbors), so the same
* face reappears only far away.
*
* Why the pass is order-independent (determinism): systems are assigned
* in a FIXED order — sorted by (x, y), a pure function of the seeded
* roster — and each system only counts frames ALREADY assigned to its
* neighbors (plus its own earlier planets). Nothing depends on generation
* timing or visit order, so lazy (on-arrival) content generation and
* eager generateAll stamp the same frames. The spatial order also makes
* the "already assigned" set spatially consistent, which spreads the
* faces a little better than roster order. Ties are broken by a
* derived, per-pick Rng (seeded).
*
* Shared rolls: the pass re-derives each system's planet classes via the
* same exported roll and forks as the content generator
* (SystemGenerator.rollSystemComposition) — guaranteed to agree, since
* the forks are pure functions of (seed, id, type, density).
*
* Cost: one weighted draw + a few pool scans per planet — trivial at this
* galaxy size (data/galaxy.json → systemCount), computed once at galaxy
* build time (js/galaxy/Galaxy.js).
*/
export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) {
const defs = typeDefs ?? config.get('systems.types', {});
const shim = { params: params ?? {} }; // settlementDensity's shape
const poolFor = (cls) => {
const pool = config.get(`planets.frames.${cls}`);
return Array.isArray(pool) && pool.length > 0 ? pool : [0];
};
const assigned = new Map(); // id → [[class, frame], ...] (assigned so far)
const frames = new Map(); // id → [frame, ...] (one per planet, ordinal order)
let homeFrame = null;
// FIXED spatial order (x, then y): a pure function of the seeded roster,
// so the assignment never depends on generation timing or visit order.
const ordered = records.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
for (const rec of ordered) {
const isHome = rec.id === homeId;
const attr = defs[rec.type]?.attributes ?? {};
const density = settlementDensity(shim, rec);
const { classes } = rollSystemComposition(
seed, rec, isHome, attr.settlements ?? {}, density, attr,
);
const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? [];
// usage(class, frame) = [own, nb]: same-class planets already wearing
// that frame in THIS system (own) and among the ALREADY-ASSIGNED
// neighbor systems (nb — the spread objective).
const list = [];
const usage = (cls, f) => {
let own = 0;
let nb = 0;
for (const [c2, f2] of list) if (c2 === cls && f2 === f) own++;
for (const n2 of neighbors) {
for (const [c2, f2] of assigned.get(n2.id) ?? []) if (c2 === cls && f2 === f) nb++;
}
return [own, nb];
};
const less = (a, b) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
// Pick the frame with the lowest (own, nb) usage — lexicographic: a
// free face in this system always beats a used-but-neighborly one —
// ties broken by a deterministic derived pick (seed, id, ordinal, class).
const pick = (cls, idx) => {
const pool = poolFor(cls);
if (pool.length === 1) return pool[0];
let best = null;
for (const f of pool) {
const u = usage(cls, f);
if (best === null || less(u, best)) best = u;
}
const tied = pool.filter((f) => {
const u = usage(cls, f);
return u[0] === best[0] && u[1] === best[1];
});
if (tied.length === 1) return tied[0];
return Rng.derive(seed, 'frames', rec.id, String(idx), cls).pick(tied);
};
classes.forEach((cls, i) => {
const f = pick(cls, i);
list.push([cls, f]);
});
frames.set(rec.id, list.map(([, f]) => f));
if (isHome) {
// The home world (the origin) is a class-terran body — it takes a
// frame from the same pass so its face is spread too.
const homeCls = config.get('planets.homePlanet', 'terran');
homeFrame = pick(homeCls, 99);
list.push([homeCls, homeFrame]);
}
assigned.set(rec.id, list);
}
return { frames, homeFrame };
}