orbit/js/galaxy/SystemGenerator.js

982 lines
44 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));
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
/**
* 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 class weights, moon/belt chances, habitability, hazard,
* and — the lived-in layer — `settlements` (per-type odds for the
* free-space kinds). Planet COUNT is a global rule (data/systems.json →
* `planetCount`): noneChance of systems are barren, the rest hold 24
* worlds. 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.
* EVERY planet hosts a settlement (for now — data/settlements.json →
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. Every
* system holds at least one planet or free-space station — a barren
* system that rolls no station gets a gate station — because the jump
* gates (below) must be tether-reachable from an anchor. Nothing here is
* hostile yet: `owner` on every settlement is a reserved seam for the
* factions and pirates we'll introduce later.
*
* The STARTING system is special: the player's home world sits at the
* origin (not a generated planet, fixed key 'home'), and the system always
* holds exactly two more planets — a gas giant and a rocky world. With the
* home world, three planets, always. And it is capped at ONE free-space
* station: five objects (the home world + four) cannot sit 6400..10240 px
* apart — the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618,
* which the home band 10240/6400 = 1.6 cannot give — so the home system
* stays a ≤ 4-object configuration (see layoutSystem).
*
* LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every
* PAIR of layout objects (planets, free-space stations, and the central
* body — the star, or the home world in the starting system — at the
* local origin) sits 6400..15360 px apart, center to center; in the home
* system the band tightens to 6400..10240 px. Normal systems lay out as a
* regular N-gon ring around the star; the home system as a regular
* (N+1)-polygon with the home world as one vertex. The rotation is chosen
* to serve the jump gates — objects bias toward the directions the system
* jumps (see layoutSystem + layoutGates).
*
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
* 13 gates per system — each placed on the side of the system facing its
* destination star on the 2-D map (an upper-right gate jumps to a star in
* the upper right), within level-1 tether (5120 px) of a planet or space
* station. The galaxy-wide network is strongly connected: no closed
* systems, no trapped sets.
*
* Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is
* built from the system id + the object's position in its generated list:
* planets `<systemId>-p<ordinal>` (p1…pn, orbital order)
* settlements `<systemId>-s<n>` (s1…, draw order: planet-bound
* kinds in orbital order, then
* free-space)
* Same seed ⇒ same ids ⇒ a reputation saved against them lines up with
* the regenerated galaxy (see js/reputation/Reputation.js). The player's
* home world is not one of the generated planets — it has the fixed key
* 'home'.
*/
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 ----------------------------------------------------------
// How many worlds the system holds (data/systems.json → planetCount):
// noneChance of systems are barren (ZERO planets); the rest get a
// uniform whole number in [min, max]. The STARTING system is the one
// exception: it always holds exactly TWO generated planets — a gas
// giant and a rocky world — which with the home world (the origin, the
// player's homestead, not a generated planet) makes its three planets.
const isHome = record.id === galaxy?.currentSystemId;
const pc = attr.planetCount ?? config.get('systems.planetCount', { noneChance: 0.2, min: 2, max: 4 });
const count = isHome
? 2
: rng.chance(pc.noneChance ?? 0.2) ? 0 : rng.int(pc.min ?? 2, pc.max ?? 4);
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 homeName = isHome ? planetDeck[0] : null;
const planets = [];
for (let i = 1; i <= count; i++) {
// The starting system's two worlds are fixed (gas giant, then rocky);
// everywhere else the class rolls from the type's weights.
const pclass = isHome ? (i === 1 ? 'gas' : 'rocky') : 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({
id: `${record.id}-p${i}`, // reputation/trading key (stable per seed)
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,
systemId: record.id,
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),
isHome,
});
// --- Jump gate targets (the galaxy's gate network) --------------------
// The other stars this system's gates jump to (Galaxy.jumpNetwork —
// data/gates.json, js/galaxy/JumpNetwork.js): 1maxGates, local (the
// system's nearest-star pool), strongly connected. Ordered with the
// "road home" (parent) edge first. Empty for a one-system galaxy.
const targets =
typeof galaxy?.jumpGatesFor === 'function' ? galaxy.jumpGatesFor(record.id) : [];
// Bearings in the 2-D map plane — the same frame the system view uses,
// so "an upper-right gate" means "a star to the upper right on the map".
const targetAngles =
targets.length > 0
? targets.map((t) => Math.atan2(t.y - record.y, t.x - record.x))
: null;
// --- Layout: the system's objects + its jump gates --------------------
// Planets and free-space stations are the system's layout objects — each
// gets an x/y (see layoutSystem for the band rule), and the gates are
// placed toward their target stars (see layoutGates).
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
layoutSystem(galaxy.seed, record.id, planets, freeSpace, isHome, targetAngles);
const jumps = layoutGates(galaxy.seed, record, planets, freeSpace, isHome, targets);
// --- Asteroid clusters ------------------------------------------------
// Loose groups of slowly tumbling rocks scattered through the void.
// Generated AFTER the layout (so every placed object — worlds, stations,
// and the jump gates — is a spacing obstacle) from the dedicated stream
// (seed, 'system', id, 'asteroids') — independent of the
// star/planet/settlement/layout draws above, so
// lazy (on-arrival) === eager (generateAll) is preserved.
const asteroids = generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps);
// --- 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,
asteroids,
belt,
hazard,
jumps, // the system's jump gates (13; [] for a one-system galaxy)
};
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 — under the SOLAR SYSTEM BAND (data/planets.json →
* solarSystem):
*
* Every PAIR of layout objects — the planets, the free-space stations,
* and the central body (the star, or the HOME world in the starting
* system — which sits at the local origin) — is at least `minSpacing`
* and at most the band's maximum apart, center to center:
*
* normal systems : minSpacing..maxSpacing (6400..15360 px)
* home system : minSpacing..homeMaxSpacing (6400..10240 px)
*
* (The old "every object within maxNeighbor of some object" rule is
* implied: ≤ 6 objects inside a 15360 px band keeps every object within
* level-2/3 tether of the others.)
*
* Shapes that satisfy a full pairwise band exactly:
* N = 1, non-home — a single point at distance R ∈ [min, max]; its only
* pair (with the star) is just R.
* N ≥ 2, non-home — a REGULAR N-GON RING around the star: every pair is
* a chord, the longest being 2·R·sin(⌊N/2⌋·π/N), so
* R ∈ [min, max / (2·sin(⌊N/2⌋·π/N))].
* home system — a REGULAR (N+1)-POLYGON with the home world as ONE
* VERTEX (the origin): every pair is a polygon chord,
* the longest ratio(N+1)·side, so side ∈ [min,
* homeMax / ratio(N+1)]. (The home world + 4 objects —
* 5 points — would need a max/min ratio ≥ φ ≈ 1.618,
* which the home band 10240/6400 = 1.6 cannot give;
* that is why the home system is capped at 3 objects:
* its 2 fixed planets + at most one free-space
* station — see generateSettlements.)
*
* 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. The central body is the
* origin (the game renders the home world there in the starting system;
* elsewhere it is the star — the visual, but part of the spacing rule).
*
* The ROTATION (ring phase / polygon orientation) is chosen to serve the
* jump gates: it minimizes the worst perpendicular distance between a
* gate's target ray and the nearest object, so layoutGates can sit each
* gate on the true target ray while staying tether-reachable. Without
* targets (gates disabled, one-system galaxy) it falls back to a seeded
* random rotation.
*
* Each planet also gains scale (size multiplier, data/planets.json →
* classScale — the band is center-to-center, but the rendered discs still
* scale by class).
*
* Determinism: the radius/side draw comes from the dedicated fork
* (seed, 'system', id, 'layout'); the rotation is a pure function of
* (shape, target angles). Same seed ⇒ same layout, and lazy
* (on-arrival) === eager (generateAll).
*/
function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles) {
const band = config.get('planets.solarSystem', {});
if (band.enabled === false) return;
const MIN = Math.max(1, band.minSpacing ?? 6400);
const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
// Rendered size per class (visual only — the band is center-to-center).
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 — stable per system, so lazy === eager.
const objects = [...planets, ...freeSpace];
const N = objects.length;
if (N === 0) return;
const lay = Rng.derive(seed, 'system', systemId, 'layout');
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
let place;
if (isHome) {
// Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is
// Rc·(u(φ + 2πk/m) u(φ)) — a chord of length 2·Rc·sin(πk/m).
const m = N + 1;
const sideMax = MAX / ratioOf(m);
if (sideMax < MIN) {
console.warn(
`[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`,
);
}
const side = lay.range(MIN, Math.max(MIN, sideMax));
const Rc = side / (2 * Math.sin(Math.PI / m));
place = (phi) => {
const out = [];
for (let k = 1; k < m; k++) {
out.push({
x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)),
y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)),
});
}
return out;
};
} else {
// Regular N-gon ring around the star (origin). N = 1: one point at
// distance R — its only pair (with the star) is just R.
const rMax = N === 1 ? MAX : MAX / (2 * Math.sin((Math.floor(N / 2) * Math.PI) / N));
const R = lay.range(MIN, Math.max(MIN, rMax));
place = (phi) => {
const out = [];
for (let k = 0; k < N; k++) {
const a = phi + (TAU * k) / N;
out.push({ x: R * Math.cos(a), y: R * Math.sin(a) });
}
return out;
};
}
const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU);
const slots = place(phi);
for (let i = 0; i < N; i++) {
objects[i].x = slots[i].x;
objects[i].y = slots[i].y;
}
}
/** Max-chord / side ratio of a regular `m`-gon (m ≥ 3); 1 for m < 3. */
function ratioOf(m) {
if (m < 3) return 1;
const half = Math.floor(m / 2);
return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m);
}
/**
* The rotation that best serves the jump gates: minimize the WORST
* perpendicular distance between a gate's target ray and the NEAREST
* object (the gate then sits on the ray whenever that distance is within
* the anchor tether range — layoutGates). Coarse scan + local refine; the
* first minimum on the deterministic grid wins, so the result is exact
* for the seed (no Math.random anywhere).
*/
function bestRotation(place, targetAngles) {
const score = (phi) => {
const pts = place(phi);
let worst = 0;
for (const t of targetAngles) {
const st = Math.sin(t);
const ct = Math.cos(t);
let best = Infinity;
for (const p of pts) {
const h = Math.abs(p.x * st - p.y * ct); // perpendicular distance to the ray line
if (h < best) best = h;
}
if (best > worst) worst = best;
}
return worst;
};
const STEPS = 4096;
const span = TAU / STEPS;
let bi = 0;
let bv = Infinity;
for (let i = 0; i < STEPS; i++) {
const v = score(i * span);
if (v < bv) {
bv = v;
bi = i;
}
}
let bestPhi = bi * span;
for (let j = -16; j <= 16; j++) {
const i = bi + j;
if (i < 0 || i >= STEPS) continue;
const phi = i * span;
const v = score(phi);
if (v < bv) {
bv = v;
bestPhi = phi;
}
}
return bestPhi;
}
/**
* JUMP GATES — the physical gates of the system, one per gate-network
* target (Galaxy.jumpNetwork; data/gates.json). Each gate:
*
* - sits on the system's side of its DESTINATION star — the bearing is
* computed in the 2-D map plane, so "an upper-right gate" means "a
* star to the upper right on the map";
* - is within level-`anchorTetherLevel` tether (5120 px for level 1) of
* an ANCHOR — a planet or space station (the home world counts) — on
* the anchor's tether circle, chosen in order of facing quality:
* (1) the far ray-circle intersection (the gate exactly on the target
* ray — system, gate, and star collinear), (2) the point of the
* circle aimed exactly at the target star, (3) a forward-hemisphere
* scan of the circle (±75°). Every candidate is exactly `range` from
* its anchor, so tether-reachability holds by construction and the
* facing deviation never exceeds 90° (in practice a few degrees);
* - stays `minRadius..maxRadius` from the star, `size` + `clearance`
* clear of every anchor disc, and 2·`size` + `gateGap` from every
* other gate.
*
* The target list arrives ordered (the "road home" parent edge first) and
* anchors iterate in content order, so the placement is exact for the
* seed — same seed ⇒ same gates (dev/jumps.test.mjs).
*
* Record shape (one per gate):
* { id: `<systemId>-j<n>`, name: `<Star> Gate`, to, toName,
* x, y, size, rotation }
*/
function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const g = config.section('gates', {});
if (g.enabled === false) return [];
if (!Array.isArray(targets) || targets.length === 0) return [];
const size = Math.max(1, Math.floor(g.size ?? 96));
const clearance = Math.max(0, g.clearance ?? 256);
const gap = Math.max(0, g.gateGap ?? 192);
const minR = Math.max(0, g.minRadius ?? 2048);
const maxR = Math.max(minR, g.maxRadius ?? 20480);
const range = anchorTetherRange(g.anchorTetherLevel ?? 1);
// Anchors: the planets, the free-space stations, and (home only) the
// home world at the origin — the bodies a gate's tether may hang from.
// (Every system holds at least one — generateSettlements guarantees it.)
const anchors = [
...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })),
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
];
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
const jumps = [];
const gateNames = new Map(); // star name → how many gates named after it
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
const th = Math.atan2(t.y - record.y, t.x - record.x);
const ux = Math.cos(th);
const uy = Math.sin(th);
// Candidate points, best first:
// tier 1 — for every anchor, the FAR ray-circle intersection: the
// gate exactly ON the target ray (system center, gate, and
// star collinear), outside the anchor. Always faces the
// target (≤ 90° from the anchor's point of view).
// tier 2 — for every anchor, the point on the anchor's tether circle
// that faces the target: a + range·u — exactly `range` from
// the anchor (tether-reachable) and aimed exactly at the
// star (zero deviation from the anchor's point of view).
// tier 3 — a forward-hemisphere scan around each anchor's circle
// (32 points, ± up to 75° from the target direction) — the
// clearance search for tight systems.
// Every candidate is exactly `range` from an anchor, so the level-N
// tether rule (hard) is met by construction; the direction rule (soft)
// is honored by the tier order: on-ray → aimed → near-aimed.
const cands = [];
anchors.forEach((a, ai) => {
const proj = a.x * ux + a.y * uy; // signed distance along the ray
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
if (h <= range) {
const off = Math.sqrt(Math.max(0, range * range - h * h));
cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy });
}
cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy });
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy });
}
});
cands.sort((p, q) => p.tier - q.tier || p.order - q.order);
const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py;
if (d2c < minR * minR || d2c > maxR * maxR) return false;
for (const b of anchors) {
const need = b.r + size + clearance;
const dx = c.px - b.x;
const dy = c.py - b.y;
if (dx * dx + dy * dy < need * need) return false;
}
for (const j of jumps) {
const need = 2 * size + gap;
const dx = c.px - j.x;
const dy = c.py - j.y;
if (dx * dx + dy * dy < need * need) return false;
}
return true;
};
let chosen = null;
for (const c of cands) {
if (ok(c)) {
chosen = c;
break;
}
}
if (!chosen) {
// Every candidate failed clearance (nearly impossible — an anchor's
// tether circle is 5120 px across, the discs under a thousand): take
// the first anchor's aimed point anyway — the tether and facing rules
// outrank cosmetics.
chosen = cands.find((c) => c.tier === 2) ?? cands[0];
console.warn(
`[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`,
);
}
// Unique gate name (two targets can share a star name — star names are
// syllable-generated): "Avidy Gate", "Avidy Gate II", "Avidy Gate III".
let name = `${t.name} Gate`;
const k = (gateNames.get(name) ?? 0) + 1;
gateNames.set(name, k);
if (k > 1) name = `${name} ${k === 2 ? 'II' : 'III'}`;
jumps.push({
id: `${record.id}-j${i + 1}`,
name,
to: t.id,
toName: t.name,
x: chosen.px,
y: chosen.py,
size,
// The gate's visual bearing — from the gate's own position to the
// destination star, so it always points exactly at where it jumps.
rotation: Math.atan2(t.y - chosen.py, t.x - chosen.px),
});
}
return jumps;
}
/** Level-N tether radius (data/tether.json): level1Radius × growth^(N1). */
function anchorTetherRange(level) {
const lv = Math.max(1, Math.floor(level ?? 1));
const base = Math.max(1, config.get('tether.level1Radius', 5120));
const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0));
return base * Math.pow(growth, lv - 1);
}
/** A planet's rendered disc radius (the anchor keepout for gate clearance). */
function planetRenderRadius(p) {
const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024)));
const scale = Math.max(0.01, config.get('planets.scale', 1.0));
const classScale = Math.max(0.01, p.scale ?? config.get(`planets.classScale.${p.class}`, 1));
return (frame * scale * classScale) / 2;
}
/** A free-space station's keepout radius (data/stations.json). */
function stationKeepout(kind) {
return Math.max(1, Math.floor(config.get(`stations.kinds.${kind}.size`, 108)));
}
/** The home world's disc radius (data/planets.json). */
function homeWorldRadius() {
const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024)));
const scale = Math.max(0.01, config.get('planets.scale', 1.0));
return (frame * scale) / 2;
}
/**
* Asteroid clusters — the system's loose rock fields (data/asteroids.json).
*
* A cluster is a GROUP of 48 rocks (data → cluster.groupSize), each 64128
* px (cluster.sizes) with at least one full-size rock, sitting within
* `cluster.spread` of the cluster center, and kept a small gap apart from
* each other (cluster.gapFactor — the generator relaxes any overlap so no
* two rocks interpenetrate). Every rock tumbles on its own
* slow spin (its own speed and direction — cluster.spin), and the whole
* group drifts slowly around its center (cluster.groupSpin) — the render
* (js/entities/AsteroidCluster.js) reads these straight off the record.
*
* The rules (all from data/asteroids.json):
* - COUNT vs PLANETS — clusterCount ≈ targetObjects planetCount (±
* jitter, clamped to [minClusters, maxClusters]): the more planets a
* system has, the fewer asteroid clusters, and vice versa. The
* STARTING system always gets at least startingSystemMinClusters, and
* those first ones are placed inside the player's initial tether
* (tether.level1Radius × radiusGrowth^(homeLevel1), whole cluster,
* minus placement.tetherMargin) so the player can reach them.
* - SPACING — no cluster center may sit closer than
* placement.minObjectSpacing (1024 px, center-to-center) to ANY other
* object: the home world (origin), every planet, every free-space
* station, and every other cluster.
* - SCATTER — other clusters land anywhere in the annulus
* [placement.minRadius, placement.maxRadius] around the origin, picked
* by seeded rejection sampling (uniform in area) — sprinkled through
* the void, inside and outside the planet orbits.
* - FRAMES — each rock is a random sheet frame, with NO frame repeated
* twice in the same cluster (a seeded shuffle of the frame pool).
* - NAMES — synthesised (NameGenerator.asteroid) and never repeating a
* name already used in the system (planets, stations, other clusters).
*
* Determinism: every draw comes from the dedicated forks
* Rng.derive(seed, 'system', id, 'asteroids') — members, spins, placement
* Rng.derive(seed, 'system', id, 'names', 'asteroids') — names
* — so clusters are seed-deterministic, independent of the other streams,
* and lazy === eager (see generateSystemContent).
*
* Record shape (one per cluster):
* {
* id, name, x, y, // cluster center + identity
* bound, // max extent from center = discovery radius
* tint, // per-cluster starlight tint (int, or null)
* groupSpin, groupPhase, // the loose group's slow drift (rad/s, rad)
* debrisPhase, debris: [], // dust motes (local px, size, alpha)
* asteroids: [{ frame, x, y, size, spin, phase }] // rocks, local px
* }
*/
function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) {
const cfg = config.section('asteroids', {});
if (cfg.enabled === false) return [];
// Without the solar-system layout there are no placed objects to space
// against — no clusters either (the scene renders nothing else anyway).
if (config.get('planets.solarSystem.enabled', true) === false) return [];
const clusterCfg = cfg.cluster ?? {};
const dist = cfg.distribution ?? {};
const placement = cfg.placement ?? {};
const isHome = record.id === galaxy?.currentSystemId;
// --- How many clusters: the inverse of the planet count ---------------
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
const minC = Math.max(1, Math.floor(dist.minClusters ?? 1));
const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
const count = clamp(
Math.round(target - planets.length + rng.range(-jitter, jitter)),
homeMin, maxC,
);
if (count === 0) return [];
// --- Parameter plumbing (every value steerable from data/asteroids.json)
const frameCount = Math.max(1, Math.floor(cfg.frameCount ?? 10));
const sizeMin = Math.floor(clusterCfg.sizes?.min ?? 64);
const sizeMax = Math.max(sizeMin, Math.floor(clusterCfg.sizes?.max ?? 128));
const groupMin = Math.max(1, Math.floor(clusterCfg.groupSize?.min ?? 4));
const groupMax = Math.max(groupMin, Math.floor(clusterCfg.groupSize?.max ?? 8));
const spread = clusterCfg.spread ?? 140;
const spinMin = Math.max(0, clusterCfg.spin?.minDegPerSec ?? 0.3) * DEG;
const spinMax = Math.max(spinMin, clusterCfg.spin?.maxDegPerSec ?? 1.6) * DEG;
const gspinMin = Math.max(0, clusterCfg.groupSpin?.minDegPerSec ?? 0.12) * DEG;
const gspinMax = Math.max(gspinMin, clusterCfg.groupSpin?.maxDegPerSec ?? 0.4) * DEG;
const tintAnchors =
clusterCfg.tint?.enabled === false ? [] : (clusterCfg.tint?.anchors ?? ['#dfe9ff', '#ffe9d6', '#e8e4f8']);
const tintStrength = clamp(clusterCfg.tint?.strength ?? 0.5, 0, 1);
const debrisCfg = clusterCfg.debris ?? {};
// --- Placement rules ---------------------------------------------------
const rMin = placement.minRadius ?? 2048;
const rMax = Math.max(rMin, placement.maxRadius ?? 18432);
const minSep = placement.minObjectSpacing ?? 1024;
const maxAttempts = Math.max(1, Math.floor(placement.maxPlacementAttempts ?? 400));
const tetherMargin = placement.tetherMargin ?? 96;
const tetherRadius = homeTetherRadius();
const homeSlots = isHome ? Math.min(count, homeMin) : 0;
// Names already taken in this system (planets + stations).
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
const usedNames = new Set();
for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object
// (planets, free-space stations, and the jump gates).
const placed = [{ x: 0, y: 0 }];
for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y });
for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y });
for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y });
const clusters = [];
for (let i = 0; i < count; i++) {
// --- The group's rocks ---------------------------------------------
const memberCount = rng.int(groupMin, groupMax);
// Frames: a random subset of the sheet — NO frame twice in this cluster.
const frames = rng.shuffle(Array.from({ length: frameCount }, (_, k) => k)).slice(0, memberCount);
// Sizes: random in [sizeMin, sizeMax]; at least minFullSize full-size.
const sizes = Array.from({ length: memberCount }, () => rng.int(sizeMin, sizeMax));
const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount);
for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax;
// Offsets: a dense random blob around the center (uniform in area),
// then a relaxation pass enforces a small gap between EVERY pair of
// rocks (cluster.gapFactor — 1.12 ≈ a subtle gap): only pairs that
// would overlap move, each by half the shortfall, so the group keeps
// its random look and stays compact while no rocks interpenetrate.
const gapFactor = Math.max(1, clusterCfg.gapFactor ?? 1.12);
const members = [];
for (let k = 0; k < memberCount; k++) {
const a = rng.range(0, TAU);
const r = spread * Math.sqrt(rng.next()); // uniform in area
members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] });
}
relaxRockGaps(members, gapFactor);
const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2));
// Spins: each rock tumbles on its own — its own slow speed, its own
// direction, its own starting phase.
const spins = members.map(() => (rng.chance(0.5) ? 1 : -1) * rng.range(spinMin, spinMax));
const phases = members.map(() => rng.range(0, TAU));
// Dust: a fine halo of motes orbiting just outside the rocks.
const debris = [];
if (debrisCfg.enabled !== false) {
const n = rng.int(debrisCfg.count?.[0] ?? 14, debrisCfg.count?.[1] ?? 30);
const rIn = bound * (debrisCfg.inner ?? 1.0);
const rOut = Math.max(rIn, bound * (debrisCfg.outer ?? 2.1));
for (let k = 0; k < n; k++) {
const a = rng.range(0, TAU);
const r = Math.sqrt(rng.range(rIn * rIn, rOut * rOut));
debris.push({
x: Math.cos(a) * r,
y: Math.sin(a) * r,
size: rng.range(debrisCfg.size?.[0] ?? 0.8, debrisCfg.size?.[1] ?? 2.4),
alpha: rng.range(debrisCfg.alpha?.[0] ?? 0.12, debrisCfg.alpha?.[1] ?? 0.4),
});
}
}
// Starlight: a subtle warm/cool shift, per cluster.
const tint = tintAnchors.length
? mixTint(tintAnchors[rng.int(0, tintAnchors.length - 1)] ?? tintAnchors[0], tintStrength)
: null;
// Name: synthesised, unique within the system.
let name = NameGenerator.asteroid(nameRng);
for (let tries = 0; tries < 10 && usedNames.has(name); tries++) name = NameGenerator.asteroid(nameRng);
usedNames.add(name);
// --- Where: sprinkle it in the void --------------------------------
// The starting system's first clusters live INSIDE the initial tether
// (whole group: center + bound + margin ≤ tether radius); the rest —
// and every cluster elsewhere — go anywhere in the scatter annulus.
const zoneMax =
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
placed.push(pos);
clusters.push({
id: `asteroid-${i + 1}`,
name,
x: pos.x,
y: pos.y,
bound,
tint,
groupSpin: (rng.chance(0.5) ? 1 : -1) * rng.range(gspinMin, gspinMax),
groupPhase: rng.range(0, TAU),
debrisPhase: rng.range(0, TAU),
// The dust glides on its OWN slow orbit (0.20.5 deg/s, random way)
// — fine particles drifting around the rocks, not locked to them.
debrisSpin: (rng.next() < 0.5 ? 1 : -1) * rng.range(0.2, 0.5) * DEG,
debris,
asteroids: members.map((m, k) => ({
frame: frames[k],
x: m.x,
y: m.y,
size: m.size,
spin: spins[k],
phase: phases[k],
})),
});
}
return clusters;
}
/**
* A random point in the annulus [rMin, rMax] around the origin (uniform in
* AREA) that is ≥ minSep from every placed object — seeded rejection
* sampling. If the annulus is hopelessly crowded (it isn't, at these
* numbers) it returns the best candidate rather than failing.
*/
function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) {
let best = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const a = rng.range(0, TAU);
const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax));
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
let ok = true;
let worst = Infinity;
for (const p of placed) {
const d = Math.hypot(x - p.x, y - p.y);
if (d < minSep) {
ok = false;
if (d < worst) worst = d;
}
}
if (ok) return { x, y };
if (best === null || worst > best.worst) best = { x, y, worst }; // best = furthest from the closest object
}
return { x: best.x, y: best.y };
}
/**
* Enforce the generator's gap rule: after the pass, every pair of rocks is
* ≥ gapFactor × (r1 + r2) apart (centers) — a small visible edge-gap
* (1.0 = touching). Iterative pairwise separation: each violator moves half
* the shortfall, only violating pairs move (minimal displacement), and a
* few dozen sweeps settle any rock count. Fully deterministic (fixed pair
* order, fixed iteration cap).
*/
function relaxRockGaps(members, gapFactor, maxIter = 96) {
for (let it = 0; it < maxIter; it++) {
let worst = 0;
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const a = members[i];
const b = members[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let d = Math.hypot(dx, dy);
const need = gapFactor * (a.size / 2 + b.size / 2);
if (d >= need) continue;
if (d < 1e-9) { dx = 1; dy = 0; d = 1; } // coincident: deterministic axis
const push = (need - d) / 2;
a.x -= (dx / d) * push;
a.y -= (dy / d) * push;
b.x += (dx / d) * push;
b.y += (dy / d) * push;
worst = Math.max(worst, need - d);
}
}
if (worst <= 1e-6) break;
}
}
/** The starting system's initial tether radius (home world's level). */
function homeTetherRadius() {
const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1)));
const base = config.get('tether.level1Radius', 5120);
const growth = config.get('tether.radiusGrowth', 2.0);
return base * Math.pow(growth, level - 1);
}
/**
* Blend a hex tint anchor toward white by `strength` (0 = white, 1 = the
* anchor) → a 24-bit canvas tint. Pure (no Phaser — this runs in Node).
*/
function mixTint(hex, strength) {
let h = String(hex ?? '').trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
if (!Number.isFinite(n)) return null;
const mix = (c) => Math.round(255 + (c - 255) * strength);
return (mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255);
}
/**
* 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
* settlements in orbital order, then free-floating (deep-space station,
* waypoint). Every roll goes through the system's own stream. Each
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
* this order) — the reputation/factions key.
*
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is
* fully settled, so EVERY planet hosts the one kind that fits its class
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a
* habitable rocky world earns a colony, every other world gets its mining
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and
* the old per-type odds (spec.chance + needs) take over again. The
* free-space kinds still roll per type (core→rim scaled).
*/
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, isHome = false }) {
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({
id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed)
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.
if (config.get('settlements.allPlanetsSettled', true) === true) {
// Fully settled: every world hosts the kind that fits its class.
const byClass = config.get('settlements.settledKindByClass', {});
for (const p of planets) {
const kind = settledKindFor(p, byClass, kindDefs);
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal });
}
} else {
// The old probabilistic layer (per-type chances + class needs).
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 (per-type odds, core→rim scaled).
// The HOME system is capped at ONE free-space station: with the home
// world and its two fixed planets it must stay a ≤ 4-object
// configuration — the home band (6400..10240 px) cannot hold 5 points
// (see layoutSystem).
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' });
}
if (!isHome && roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' });
}
// JUMP-GATE ANCHOR GUARANTEE — every system must hold at least one
// planet or space station: the jump gates sit within level-1 tether of
// an anchor, so a system with neither would be unreachable (a closed
// system). A barren system that rolled no free-space station gets a
// gate station.
if (planets.length === 0 && out.length === 0) {
make('deepSpaceStation', { type: 'space' });
}
return out;
}
/** The settled kind a planet hosts (settledKindByClass entry → kind id). */
function settledKindFor(planet, byClass, kindDefs) {
const entry = byClass[planet.class];
if (!entry) return null;
const kind = typeof entry === 'string'
? entry
: (planet.habitable ? (entry.habitable ?? entry.default) : entry.default);
return kind && kindDefs[kind] ? kind : null;
}
/** 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))));
}