302 lines
11 KiB
JavaScript
302 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.
|
|
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
|
|
const rec = { id, name, type, x, y };
|
|
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,
|
|
};
|
|
}
|
|
}
|