360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
import { config } from '../config/Config.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
import { NameGenerator } from '../utils/NameGenerator.js';
|
||
import { buildJumpNetwork } from './JumpNetwork.js';
|
||
import { assignPlanetFrames } from './PlanetFrames.js';
|
||
import { generateSystemContent } from './SystemGenerator.js';
|
||
|
||
const TAU = Math.PI * 2;
|
||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||
const wrapPI = (a) => {
|
||
const t = (a + Math.PI) % TAU;
|
||
return (t < 0 ? t + TAU : t) - Math.PI;
|
||
};
|
||
|
||
/**
|
||
* The Galaxy — an immense, procedurally generated collection of star
|
||
* systems, fully determined by its seed.
|
||
*
|
||
* Two-level generation (the "grand scale" design):
|
||
*
|
||
* 1. GALAXY ROSTER — generated once, up front, when New Game is pressed:
|
||
* `systemCount` lightweight records ({ id, name, type, x, y }).
|
||
* This is cheap: 200 systems is a few ms and a few hundred KB.
|
||
* It fixes the shape of the galaxy, where every system sits, and
|
||
* what KIND each one is — for the entire galaxy, from the seed alone.
|
||
*
|
||
* 2. SYSTEM CONTENTS — planets, moons, belts, hazards… generated LAZILY,
|
||
* the first time the player arrives (`ensureContent(id)`), then
|
||
* cached. Because each system's draw stream is derived from
|
||
* (seed, 'system', id), a lazy generation is identical to an eager
|
||
* one — so this is a pure performance choice, never a correctness one.
|
||
* `generateAll()` exists for exactly that, if it's ever "just as easy".
|
||
*
|
||
* Determinism contract:
|
||
* same seed ⇒ same roster (positions, types, names), same contents,
|
||
* in any order of generation. Dev/test tools rely on this.
|
||
*
|
||
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
|
||
* - data/galaxy.json `distribution.rules[]` — proximity/clustering rules
|
||
* (e.g. "void systems cluster in the outer rim", faction borders).
|
||
* Read in _generate(); today only per-type weight + radiusBand
|
||
* (from data/systems.json) apply.
|
||
* - more layout knobs in data/galaxy.json `layout`.
|
||
*/
|
||
export class Galaxy {
|
||
constructor(seed, params, typeDefs) {
|
||
this.seed = seed;
|
||
this.params = params;
|
||
this.typeDefs = typeDefs;
|
||
this.records = [];
|
||
this.byId = new Map();
|
||
this.contentCache = new Map();
|
||
this.currentSystemId = null;
|
||
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
|
||
this.homeWorldFrame = null; // the starting system's home world frame
|
||
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
|
||
}
|
||
|
||
/**
|
||
* Build a galaxy from a seed (trimmed; ' abc ' and 'abc' are the same
|
||
* galaxy). `overrides` shallow-merges over data/galaxy.json — used by
|
||
* dev tools (e.g. a 300-system galaxy for brute-force tests).
|
||
*/
|
||
static create(seed, overrides = {}) {
|
||
if (seed === undefined || seed === null || String(seed).trim().length === 0) {
|
||
throw new Error('Galaxy.create() needs a non-empty seed');
|
||
}
|
||
const params = { ...config.section('galaxy', {}), ...overrides };
|
||
const typeDefs = config.get('systems.types', {});
|
||
if (Object.keys(typeDefs).length === 0) {
|
||
throw new Error('No system types found — is data/systems.json listed in data/manifest.json?');
|
||
}
|
||
const count = Math.floor(params.systemCount ?? 200);
|
||
if (!(count >= 1)) {
|
||
throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`);
|
||
}
|
||
const galaxy = new Galaxy(String(seed).trim(), params, typeDefs);
|
||
galaxy._generate(count);
|
||
return galaxy;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Roster generation (level 1)
|
||
// ------------------------------------------------------------------
|
||
|
||
_generate(count) {
|
||
const g = Rng.derive(this.seed, 'layout');
|
||
const L = this.params.layout ?? {};
|
||
const R = Math.max(1, this.params.radius ?? 20000);
|
||
const flatten = clamp(L.flatten ?? 0.62, 0.05, 1);
|
||
|
||
// Spiral arms (optional; `enabled: false` or strength 0 = off).
|
||
const spiral = L.spiral ?? {};
|
||
const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0;
|
||
const twist = spiral.twist ?? 2.5;
|
||
const strength = clamp(spiral.strength ?? 0.5, 0, 1);
|
||
const armPhase = g.next() * TAU; // one phase for the whole galaxy
|
||
|
||
// Type selection: weights from data/systems.json (distribution.weight).
|
||
// FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in
|
||
// here, before position sampling.
|
||
const typeIds = Object.keys(this.typeDefs);
|
||
const typeWeights = {};
|
||
for (const id of typeIds) {
|
||
typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1);
|
||
}
|
||
|
||
let centerId = null;
|
||
let centerD2 = Infinity;
|
||
const records = this.records;
|
||
|
||
for (let i = 1; i <= count; i++) {
|
||
const id = `S${String(i).padStart(6, '0')}`;
|
||
const type = g.weighted(typeWeights, typeIds[0]);
|
||
|
||
// Radius: a center-weighted shape sample, re-anchored into the
|
||
// type's radial band (the first "proximity" rule: e.g. void systems
|
||
// live out in the rim, habitable ones in the mid-galaxy).
|
||
const shape = this._sampleShape(g, L); // [0,1], dense toward center
|
||
const band = this.typeDefs[type]?.distribution?.radiusBand;
|
||
const rNorm = Array.isArray(band) && band.length === 2
|
||
? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1)
|
||
: shape;
|
||
|
||
let theta = g.next() * TAU;
|
||
if (arms >= 2) {
|
||
theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength);
|
||
}
|
||
|
||
const r = R * rNorm;
|
||
const x = r * Math.cos(theta);
|
||
const y = r * Math.sin(theta) * flatten;
|
||
const d2 = x * x + y * y;
|
||
if (d2 < centerD2) {
|
||
centerD2 = d2;
|
||
centerId = id;
|
||
}
|
||
|
||
// Name comes from a per-record fork so roster generation order can
|
||
// never leak into it. rNorm (0 = galactic center, 1 = rim) is kept on
|
||
// the record: the settlement generator uses it (core→rim density),
|
||
// and it's handy for any future "where am I in the galaxy" rules.
|
||
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
|
||
const rec = { id, name, type, x, y, rNorm };
|
||
records.push(rec);
|
||
this.byId.set(id, rec);
|
||
}
|
||
|
||
// Starting system (the player's home port).
|
||
const policy = this.params.startingSystem?.policy ?? 'center';
|
||
this.currentSystemId =
|
||
policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id);
|
||
|
||
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
|
||
// the eventual star map).
|
||
const area = Math.PI * R * R * flatten;
|
||
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
|
||
this.grid = new Map();
|
||
for (const rec of records) {
|
||
const key = `${Math.floor(rec.x / this.cellSize)},${Math.floor(rec.y / this.cellSize)}`;
|
||
let cell = this.grid.get(key);
|
||
if (!cell) {
|
||
cell = [];
|
||
this.grid.set(key, cell);
|
||
}
|
||
cell.push(rec);
|
||
}
|
||
|
||
// The JUMP NETWORK (data/gates.json): which star each system's jump
|
||
// gates reach. A bidirected, degree-limited spanning tree of the
|
||
// nearest-star graph (plus local shortcuts) — STRONGLY CONNECTED, so
|
||
// there are no closed systems and no trapped sets: from any star the
|
||
// player can reach any other, and every system holds
|
||
// minGates–maxGates gates (1–3 in data/gates.json). Deterministic:
|
||
// same roster ⇒ same network.
|
||
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
|
||
this.jumpNetwork = buildJumpNetwork({
|
||
records,
|
||
knn: (id) => this.neighborsOf(id, pool),
|
||
minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
|
||
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
|
||
shortcuts: config.get('gates.shortcuts', true) === true,
|
||
rootId: this.currentSystemId,
|
||
});
|
||
// Defensive repairs that had to fire (0 on a healthy kNN graph).
|
||
this.jumpNetworkRepaired = this.jumpNetwork.repaired;
|
||
if (this.jumpNetworkRepaired > 0) {
|
||
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
|
||
}
|
||
|
||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
|
||
// (class, frame) assignment — each planet's sheet frame avoids what
|
||
// the NEAREST stars already wear for that class, so the same face is
|
||
// spread across the galaxy instead of clustering in one region. Fixed
|
||
// roster order ⇒ visit-order independent; read back by the content
|
||
// generator (planet.frame / content.homeFrame) — the lazy === eager
|
||
// contract is preserved.
|
||
const pool8 = Math.max(1, Math.floor(this.params.neighbors ?? 8));
|
||
const { frames, homeFrame } = assignPlanetFrames({
|
||
seed: this.seed,
|
||
records,
|
||
homeId: this.currentSystemId,
|
||
params: this.params,
|
||
neighborsOf: (id) => this.neighborsOf(id, pool8),
|
||
});
|
||
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
|
||
this.homeWorldFrame = homeFrame;
|
||
}
|
||
|
||
/** Center-weighted radius sample in [0,1]: core bulge + disk. */
|
||
_sampleShape(g, L) {
|
||
if (g.chance(L.coreFraction ?? 0.25)) {
|
||
const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09);
|
||
return Math.min(1, Math.abs(g.normal(0, sigma)));
|
||
}
|
||
return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7)));
|
||
}
|
||
|
||
/** Ease `theta` toward the nearest spiral arm (by `strength`). */
|
||
_snapToArm(theta, rNorm, armPhase, arms, twist, strength) {
|
||
if (strength <= 0) return theta;
|
||
const step = TAU / arms;
|
||
const base = armPhase + twist * rNorm;
|
||
const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step);
|
||
let bestD = Infinity;
|
||
let bestA = base + k * step;
|
||
for (const cand of [k - 1, k, k + 1]) {
|
||
const a = base + cand * step;
|
||
const d = Math.abs(wrapPI(a - theta));
|
||
if (d < bestD) {
|
||
bestD = d;
|
||
bestA = a;
|
||
}
|
||
}
|
||
return theta + wrapPI(bestA - theta) * strength;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Queries
|
||
// ------------------------------------------------------------------
|
||
|
||
/**
|
||
* The k nearest systems to a world-space point (k=1 by default).
|
||
* Ring-expands the spatial hash; exact as long as k systems exist.
|
||
*/
|
||
nearest(x, y, k = 1) {
|
||
const c = this.cellSize;
|
||
const cx = Math.floor(x / c);
|
||
const cy = Math.floor(y / c);
|
||
const best = [];
|
||
const consider = (rec) => {
|
||
const d2 = (rec.x - x) ** 2 + (rec.y - y) ** 2;
|
||
best.push({ d2, record: rec });
|
||
best.sort((a, b) => a.d2 - b.d2);
|
||
if (best.length > k) best.length = k;
|
||
};
|
||
const maxRing = Math.min(1024, Math.ceil((4 * this.params.radius) / c) + 1);
|
||
for (let ring = 0; ring <= maxRing; ring++) {
|
||
for (let dx = -ring; dx <= ring; dx++) {
|
||
for (let dy = -ring; dy <= ring; dy++) {
|
||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
|
||
const cell = this.grid.get(`${cx + dx},${cy + dy}`);
|
||
if (cell) for (const rec of cell) consider(rec);
|
||
}
|
||
}
|
||
// Everything left unscanned is at least ring·c away; if the kth
|
||
// best is already closer, the answer is final.
|
||
if (best.length >= k) {
|
||
const bound = ring * c;
|
||
if (best[k - 1].d2 <= bound * bound) break;
|
||
}
|
||
}
|
||
return best.slice(0, k).map((e) => e.record);
|
||
}
|
||
|
||
/** The k nearest OTHER systems to a system (jump-range candidate list). */
|
||
neighborsOf(id, k = null) {
|
||
const rec = this.byId.get(id);
|
||
if (!rec) throw new Error(`Unknown system "${id}"`);
|
||
const need = k ?? Math.floor(this.params.neighbors ?? 8);
|
||
const out = [];
|
||
for (const cand of this.nearest(rec.x, rec.y, need + 1)) {
|
||
if (cand.id !== id) out.push(cand);
|
||
if (out.length >= need) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The other systems this system's jump gates jump to — the star
|
||
* RECORDS ({ id, name, x, y }), ordered with the "road home" (parent)
|
||
* edge first, then the local shortcuts. minGates–maxGates entries for
|
||
* n > 1 (data/gates.json); empty for a one-system galaxy.
|
||
*/
|
||
jumpGatesFor(id) {
|
||
const rec = this.byId.get(id);
|
||
if (!rec) throw new Error(`Unknown system "${id}"`);
|
||
return (this.jumpNetwork.gates.get(id) ?? []).map((tid) => this.byId.get(tid)).filter(Boolean);
|
||
}
|
||
|
||
/** @returns {object} the player's current (starting) system record */
|
||
currentSystem() {
|
||
return this.byId.get(this.currentSystemId) ?? this.records[0];
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Contents (level 2, lazy)
|
||
// ------------------------------------------------------------------
|
||
|
||
/**
|
||
* Generate (once) and return a system's full contents. Safe to call from
|
||
* "the player arrived here" — the result is identical to what an
|
||
* up-front generateAll() would have produced.
|
||
*/
|
||
ensureContent(id) {
|
||
const cached = this.contentCache.get(id);
|
||
if (cached) return cached;
|
||
const record = this.byId.get(id);
|
||
if (!record) throw new Error(`Unknown system "${id}"`);
|
||
const content = generateSystemContent(this, record);
|
||
this.contentCache.set(id, content);
|
||
return content;
|
||
}
|
||
|
||
/** Alias of ensureContent() — reads nicer at call sites. */
|
||
contentOf(id) {
|
||
return this.ensureContent(id);
|
||
}
|
||
|
||
/**
|
||
* Eager mode: generate every system now. Deterministically identical to
|
||
* lazy generation (per-system seeded streams) — use it if profiling ever
|
||
* shows "just do it all at once" is fine.
|
||
*/
|
||
generateAll() {
|
||
for (const rec of this.records) this.ensureContent(rec.id);
|
||
return this;
|
||
}
|
||
|
||
get generatedCount() {
|
||
return this.contentCache.size;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
|
||
/** Debug/console summary. */
|
||
summary() {
|
||
const byType = {};
|
||
for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1;
|
||
return {
|
||
name: this.name,
|
||
seed: this.seed,
|
||
systems: this.records.length,
|
||
byType,
|
||
generated: this.generatedCount,
|
||
currentSystemId: this.currentSystemId,
|
||
};
|
||
}
|
||
}
|