orbit/js/galaxy/Galaxy.js

398 lines
16 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 { NameGenerator } from '../utils/NameGenerator.js';
import { buildJumpNetwork } from './JumpNetwork.js';
import { assignPlanetFrames } from './PlanetFrames.js';
import { generateSystemContent, rollSystemComposition, settlementDensity } 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: the 60-system default 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;
// The STARTING system — the player's home port. Set once at roster
// build and never moves: `currentSystemId` tracks where the player IS
// NOW (jumps + saves move it), but the home world always belongs to
// the starting system (SystemGenerator's isHome rule, GameScene's
// home/star split). Without this split, lazily generated content for
// a newly arrived system would compute isHome = true (currentSystemId
// had already moved there) and deal it a home world of its own.
this.homeSystemId = 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);
// Frozen copy of the starting system — `currentSystemId` will track
// the player from here on (jumps, saves); the home rules must not.
this.homeSystemId = this.currentSystemId;
// 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 BARREN set — the gate-only dead ends (objectCount → 0). The
// SAME roll the content generator uses (dedicated per-system forks —
// rollSystemComposition), so the network and the content always agree:
// JumpNetwork keeps every barren system a LEAF of the tree (one gate —
// in and out the same way), the maze's dead ends.
const typeDefs = this.typeDefs ?? {};
const barren = new Set();
for (const record of records) {
if (record.id === this.currentSystemId) continue; // home is exempt
const type = typeDefs[record.type] ?? {};
const density = settlementDensity({ params: this.params }, record);
const composition = rollSystemComposition(
this.seed, record, false, type.attributes?.settlements ?? {}, density, type.attributes ?? {},
);
if (composition.classes.length === 0) barren.add(record.id);
}
// The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
// a MAZE: exactly one route between any two systems, no closed loops.
// Tree edges run BOTH ways, so from any system the player can reach
// any other and every jump has a RETURN gate (no trapped sets). Each
// system holds 1maxGates gates (its tree degree); a BARREN system is
// a dead-end LEAF — exactly one gate (in and out the same way).
// 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,
barren,
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. minGatesmaxGates 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];
}
/** True when the system is the player's STARTING one — the only system
* whose central body is the home world (a star, in every other). Stable
* across jumps, unlike a `currentSystemId` comparison. */
isHomeSystem(id) {
return id === this.homeSystemId;
}
// ------------------------------------------------------------------
// 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,
};
}
}