orbit/js/galaxy/SystemGenerator.js

253 lines
9.6 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')
);
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[(i - 1) % planetDeck.length],
ordinal: i,
class: pclass,
moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
});
}
layoutSystemPlanets(galaxy.seed, record.id, planets);
// --- 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),
});
// --- 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);
return {
name: record.name,
type: record.type,
star,
planets,
settlements,
belt,
hazard,
};
}
/**
* Top-down layout of a system's worlds (the play field around the origin,
* where the player's home world sits).
*
* Each planet record gains:
* x, y — world position (the game renders it there),
* scale — size multiplier (data/planets.json → classScale, so gas
* giants read bigger than rockies).
*
* Worlds are scattered in the annulus minOrbitmaxOrbit px from the
* origin and kept at least minEdgeGap px (edge-to-edge) apart from each
* other and from the origin (data/planets.json → solarSystem).
*
* Determinism: draws come from a DEDICATED fork — (seed, 'system', id,
* 'layout') — so layout never perturbs the star/planet/settlement draws
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
*/
function layoutSystemPlanets(seed, systemId, planets) {
const band = config.get('planets.solarSystem', {});
const minOrbit = band.minOrbit ?? 2600;
const maxOrbit = band.maxOrbit ?? 8800;
const minGap = band.minEdgeGap ?? 1200;
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
const lay = Rng.derive(seed, 'system', systemId, 'layout');
// The origin is occupied by a world (the home world) — respect it.
const placed = [{ x: 0, y: 0, r: baseR }];
for (const p of planets) {
const scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
const r = baseR * scale;
let ok = false;
let x = 0;
let y = 0;
for (let attempt = 0; attempt < 24 && !ok; attempt++) {
const ang = lay.range(0, Math.PI * 2);
const d = lay.range(minOrbit, maxOrbit);
x = d * Math.cos(ang);
y = d * Math.sin(ang);
ok = placed.every((q) => Math.hypot(x - q.x, y - q.y) >= q.r + r + minGap);
}
if (!ok) {
// The band is (nearly) full — spiral outward. A deterministic
// escape hatch: with the default band and ≤ 9 worlds per system
// this never actually triggers.
const ang = lay.range(0, Math.PI * 2);
const d = maxOrbit + r + minGap + placed.length * (r + minGap);
x = d * Math.cos(ang);
y = d * Math.sin(ang);
}
p.scale = scale;
p.x = x;
p.y = y;
placed.push({ x, y, r });
}
}
/**
* 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))));
}