114 lines
4.9 KiB
JavaScript
114 lines
4.9 KiB
JavaScript
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 0–2, gas 3–5, rocky 6–8 —
|
||
* 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 };
|
||
}
|