orbit/js/galaxy/SystemGenerator.js

344 lines
14 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';
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/**
* Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a
* fully generated system: star, planets, moons, SETTLEMENTS, debris belt,
* hazard flag.
*
* Deterministic contract:
* same galaxy seed + same record id ⇒ identical content, every time.
* The draw stream is derived from (seed, 'system', id) — NOT from the
* galaxy-level sequence — so a system generated when the player arrives
* is byte-for-byte identical to one generated during an up-front
* generateAll(). That's what makes lazy generation safe.
*
* What the system contains is steered by the TYPE'S ATTRIBUTES in
* data/systems.json (`types.<id>.attributes`): star classes, binary
* chance, planet count spread, planet class weights, moon/belt chances,
* habitability, hazard, and — the lived-in layer — `settlements` (chance
* + required planet class per settlement kind). Settlement kinds and their
* population ranges live in data/settlements.json; the core→rim density
* gradient in data/galaxy.json (`settlements.gradient`).
*
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
* Colonies on habitable worlds, mining stations over resource worlds,
* cloud bases riding gas giants, stations adrift in open space — and a
* fair number of charted-but-unclaimed systems. Nothing here is hostile
* yet: `owner` on every settlement is a reserved seam for the factions
* and pirates we'll introduce later.
*/
export function generateSystemContent(galaxy, record, typeDefs = null) {
const defs = typeDefs ?? config.get('systems.types', {});
const type = defs[record.type] ?? { label: record.type, attributes: {} };
const attr = type.attributes ?? {};
const rng = Rng.derive(galaxy.seed, 'system', record.id);
// --- Star -------------------------------------------------------------
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
const massTable = attr.star?.mass ?? {};
const starClass = rng.weighted(starClasses, 'M');
const mass = Array.isArray(massTable[starClass]) ? massTable[starClass] : [0.3, 1.2];
const star = {
name: NameGenerator.star(rng),
class: starClass,
mass: Number(rng.range(mass[0], mass[1]).toFixed(2)),
binary: false,
};
if (rng.chance(attr.binaryChance ?? 0.05)) {
star.binary = true;
star.secondary = {
name: NameGenerator.star(rng),
class: rng.weighted(starClasses, starClass),
};
}
// --- Planets ----------------------------------------------------------
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 };
const count = Math.round(
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
);
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
// Planet names come from a curated bank (data/naming.json → banks.planet),
// dealt out per-system without repeats (see NameGenerator.planetDeck).
// A dedicated derived stream keeps this order-independent (lazy === eager).
const planetDeck = NameGenerator.planetDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'planets')
);
// The player's home world sits at the origin of the STARTING system and is
// not one of the generated planets. It takes the first name off that
// system's deck so it can never clash with a planet; the planets then draw
// from the rest of the deck (all distinct within the system).
const isHome = record.id === galaxy?.currentSystemId;
const homeName = isHome ? planetDeck[0] : null;
const planets = [];
for (let i = 1; i <= count; i++) {
const pclass = rng.weighted(classWeights, 'rocky');
let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
}
planets.push({
name: planetDeck[(isHome ? i : i - 1) % planetDeck.length],
ordinal: i,
class: pclass,
moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
});
}
// --- Settlements (the lived-in layer) ---------------------------------
const settlements = generateSettlements({
rng,
kindDefs: config.get('settlements.kinds', {}),
spec: attr.settlements ?? {},
planets,
stationDeck: NameGenerator.stationDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
),
density: settlementDensity(galaxy, record),
});
// --- Layout: orbits around the home world -----------------------------
// Planets and free-space stations are the system's layout objects — each
// gets an x/y (see layoutSystem for the spacing rules and the N=11 case).
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
layoutSystem(galaxy.seed, record.id, planets, freeSpace);
// --- Debris belt & system-level hazard --------------------------------
const belt = {
present: rng.chance(attr.beltChance ?? 0.35),
kind: rng.pick(['asteroid', 'debris']) ?? 'asteroid',
};
const hazard = rng.chance(attr.hazard ?? 0.1);
const content = {
name: record.name,
type: record.type,
star,
planets,
settlements,
belt,
hazard,
};
if (isHome) content.homeName = homeName; // the player's home world (starting system only)
return content;
}
/**
* Top-down layout of a system's objects — its planets and its free-space
* stations — as one or two ORBITS (rings) around the home world, which
* always sits at the system origin (the game renders a solid world there;
* see GameScene).
*
* Each object gains x, y (world position). Planets also gain scale (size
* multiplier, data/planets.json → classScale — the spacing rules below are
* center-to-center, but the rendered discs still scale by class).
*
* The hard spacing rules (data/planets.json → solarSystem):
* minSpacing — no two objects (planets, space stations, the home world)
* may be closer than this, center to center;
* maxNeighbor — whenever a system holds more than one object, every
* object must be within this of at least one other.
*
* A ring of k objects at radius R around the origin satisfies both at once:
* - object ⇄ home-world distance is R, so R ∈ [minSpacing, maxNeighbor]
* takes care of the home world's own pair of constraints;
* - the closest object-object pair on a regular k-gon is an edge,
* 2·R·sin(π/k) — an edge is the shortest chord (vertices further around
* are further), so edge ≥ minSpacing covers every pair;
* - every object's nearest neighbor is then the home world, at
* R ≤ maxNeighbor.
* That radius range is non-empty for k ≤ 10 (k = 11 would need
* R ≥ 10905, which already strands the home world beyond maxNeighbor). So
* the 11-object maximum (9 planets + 2 stations — the most the current
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring.
* The outer ring's near neighbor is its ring-mate (edge stays in
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner ≥
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond
* 11 objects the orbits chain outward — rings of ≤ 10, and a lone world
* always fits radially outside the previous orbit — so this terminates
* for any N.)
*
* What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' — deep-space stations, waypoints).
* Planet-bound settlements are features OF their planet (a colony sits on
* it) and so are not layout objects of their own.
*
* Determinism: draws come from the dedicated fork (seed, 'system', id,
* 'layout') — layout never perturbs the star/planet/settlement draws
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
*/
function layoutSystem(seed, systemId, planets, freeSpace) {
const band = config.get('planets.solarSystem', {});
if (band.enabled === false) return;
const MIN_SEP = band.minSpacing ?? 6144;
const MAX_NBR = band.maxNeighbor ?? 10240;
// Rendered size per class (visual only — the spacing rules are
// center-to-center, not edge-to-edge).
for (const p of planets) {
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
}
// Slot assignment order: planets in orbital order, then free-space
// stations in generation order (deep-space station, then waypoint) —
// stable per system, so lazy === eager.
const objects = [...planets, ...freeSpace];
const N = objects.length;
if (N === 0) return;
const TAU = Math.PI * 2;
const lay = Rng.derive(seed, 'system', systemId, 'layout');
const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k));
const slots = [];
let prevR = 0;
let prevAngle = 0;
const placeRing = (k, lo, hi) => {
const R = lay.range(lo, hi);
// A LONE object on an outer orbit sits radially outside an
// already-placed one: its guaranteed neighbor is then exactly
// R prevR away, whereas a random angle could leave it more than
// maxNeighbor from every inner object.
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU);
for (let i = 0; i < k; i++) {
const th = th0 + (k === 1 ? 0 : (TAU * i) / k);
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) });
}
prevR = R;
prevAngle = th0;
};
if (N <= 10) {
// One orbit around the home world (k = 1: a single object, whose only
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]).
const f = edgeFactor(N);
placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR);
} else if (N === 11) {
// Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤
// MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it
// at a radius where its edge is still ≤ MAX_NBR:
// R1 ≤ MAX_NBR / edgeFactor(8) MIN_SEP.
const f8 = edgeFactor(8);
placeRing(
3,
Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)),
Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP),
);
const R1 = prevR;
// Outer 8-ring: ring-mates are its near neighbors (edge in
// [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 R1 ≥ MIN_SEP away.
placeRing(
8,
Math.max(MIN_SEP / f8, R1 + MIN_SEP),
Math.min(MAX_NBR / f8, R1 + MAX_NBR),
);
} else {
// Beyond the data maximum: chain orbits outward. A ring of ≤ 10
// objects always has a valid radius as ring 1, a lone world always
// fits on any outer orbit — the loop terminates for any N.
let rem = N;
while (rem > 0) {
const k = Math.min(rem, 10);
const f = edgeFactor(k);
const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP);
const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR);
if (lo <= hi) {
placeRing(k, lo, hi);
rem -= k;
} else {
placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR);
rem -= 1;
}
}
}
for (let i = 0; i < objects.length; i++) {
objects[i].x = slots[i].x;
objects[i].y = slots[i].y;
}
}
/**
* Core→rim density: the settled heart of the galaxy has more activity per
* system; the rim is thinner, lonelier. `factor` scales every settlement
* chance (clamped to a floor so the rim isn't dead). 0 = no gradient.
*/
function settlementDensity(galaxy, record) {
const g = galaxy?.params?.settlements?.gradient ?? {};
const falloff = Math.max(0, g.falloff ?? 0.7);
const floor = clamp(g.floor ?? 0.22, 0, 1);
const rNorm = clamp(record?.rNorm ?? 0, 0, 1);
return clamp(1 - rNorm * falloff, floor, 1);
}
/**
* Draw settlements for one system. Stable draw order: planet-bound kinds in
* orbital order (colony, mining, cloud), then free-floating (deep-space
* station, waypoint). Every roll goes through the system's own stream.
*/
function generateSettlements({ rng, kindDefs, spec, planets, stationDeck, density }) {
const out = [];
let nameIndex = 0; // next station name from the system's deck (no repeats)
const make = (kind, anchor) => {
const def = kindDefs[kind] ?? {};
out.push({
kind,
name: stationDeck[nameIndex++ % stationDeck.length],
anchor,
population: logPopulation(rng, def.population),
owner: null, // reserved: factions / pirates claim settlements later
});
};
// Planet-bound, in orbital order.
for (const p of planets) {
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
make('colony', { type: 'planet', ordinal: p.ordinal });
}
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
make('miningStation', { type: 'planet', ordinal: p.ordinal });
}
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
}
}
// Free-floating, out in the dark.
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' });
}
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' });
}
return out;
}
/** One deterministic roll, scaled by the core→rim density factor. */
function roll(rng, chance, density) {
return rng.chance(chance * density);
}
/** Does the planet satisfy the kind's requirements? */
function needs(planet, needsList, defaults) {
const list = Array.isArray(needsList) && needsList.length ? needsList : defaults;
return list.includes(planet.class);
}
/** Log-uniform population in [min, max] (a few towns to a few megacities). */
function logPopulation(rng, pop) {
const min = pop?.min ?? 1;
const max = Math.max(min, pop?.max ?? min);
if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints
return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max))));
}