orbit/js/galaxy/Galaxy.js

304 lines
11 KiB
JavaScript

import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.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: ~40,000 systems is a fraction of a second and a few
* MB. 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.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 ?? 40000);
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);
}
}
/** 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;
}
/** @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,
};
}
}