1252 lines
58 KiB
JavaScript
1252 lines
58 KiB
JavaScript
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). The system's OBJECT COUNT is a global rule
|
||
* (data/systems.json → `objectCount`): the starting system is exempt
|
||
* (fixed two planets beside the home world + at most one station);
|
||
* every other system rolls its TOTAL object count — planets +
|
||
* free-space stations together — from the table (default: 10% barren —
|
||
* a jump-gate-only system — then 2/3/4/5 objects at 15/30/30/15%).
|
||
* Stations roll first (per-type odds × the core→rim gradient), planets
|
||
* fill the rest of the budget. 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. The
|
||
* BARREN systems (objectCount → 0) are the deliberate exception: jump
|
||
* gates — and, in most, an asteroid cluster drifting inside the gate's
|
||
* tether to mine at the stop — and nothing else. They are the DEAD-END
|
||
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's
|
||
* dead ends: one gate, in and out the same way), where the player's room
|
||
* to move is the activated gate's own level-1 tether (data/gates.json →
|
||
* ACTIVITY: every gate carries `active`, default false; the activation
|
||
* mechanic is future work). 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 and free-space stations — the central
|
||
* body, the home world, sits at the local origin of the STARTING system
|
||
* only; every other system has an EMPTY center — the star is invisible
|
||
* flavor, generated as `star` but never rendered) 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 (empty) center; 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):
|
||
* 1–3 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). An ANCHORED system (a planet, or the home world in
|
||
* the starting system) hosts its gates within level-1 tether (5120 px)
|
||
* of an anchor — planets ONLY (free-space stations are deliberately not
|
||
* anchors: the build console lives on a world, so every gate must be
|
||
* tether-reachable from a planet the player can build out from). A BARREN
|
||
* system (no planets — a dead-end leaf of the tree) hosts its single gate
|
||
* on the ray toward the destination, `barrenDistance` from the center.
|
||
* Every gate record carries `active` (default false — the activation
|
||
* mechanic is future work; an activated gate anchors a level-1 tether).
|
||
* The galaxy-wide network is a pure spanning tree (no loops — the maze):
|
||
* from any system the player can reach any other, and every jump has a
|
||
* return gate.
|
||
*
|
||
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
|
||
* frame is assigned by a galaxy-wide pass — the system's (class, frame)
|
||
* avoids what the NEAREST stars already wear for that class — so the same
|
||
* face (e.g. terran frame 0) is spread across the galaxy instead of
|
||
* clustering in one region. The pass runs once at galaxy build (fixed
|
||
* roster order ⇒ visit-order independent) and stamps `planet.frame` /
|
||
* `content.homeFrame` here; the renderer prefers those over a random pick.
|
||
*
|
||
* STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for
|
||
* the deep-space stations — each system's free-space station wears one of
|
||
* the spacestations.png variants (data/stations.json → variants), chosen
|
||
* to avoid what the NEAREST stars already wear. The pass stamps
|
||
* `settlement.stationFrame` here (the landing handoff reads the same
|
||
* frame — data/landing.json → stationVideos); waypoints keep their beacon
|
||
* look and take no frame.
|
||
*
|
||
* 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 (invisible flavor) ------------------------------------------
|
||
// The system's star — generated (name/class/mass/binary) as the
|
||
// dossier's identity ("Kepler Reach · star G") and the gate names, but
|
||
// never rendered: the central body exists only in the starting system
|
||
// (the home world). See data/galaxy.json.
|
||
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),
|
||
};
|
||
}
|
||
|
||
// --- Composition: the system's object budget --------------------------
|
||
// data/systems.json → objectCount. The STARTING system is exempt: it
|
||
// always holds exactly TWO generated planets — a gas giant and a rocky
|
||
// world — beside the home world (the origin, the player's homestead,
|
||
// not a generated planet), plus at most one free-space station.
|
||
// Every other system rolls its TOTAL object count N — planets +
|
||
// free-space stations together — from the configured table (default:
|
||
// 10% barren — a jump-gate-only stop — then 2/3/4/5 objects at
|
||
// 15/30/30/15%). Stations roll next (per-type odds × the core→rim
|
||
// density gradient, ≤ 2), the planets fill the rest: N − stations.
|
||
// The roll lives on dedicated forks (rollSystemComposition), so the
|
||
// galaxy-wide frame pass can reproduce it exactly.
|
||
const isHome = record.id === galaxy?.homeSystemId;
|
||
const spec = attr.settlements ?? {};
|
||
const composition = rollSystemComposition(
|
||
galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr,
|
||
);
|
||
const classes = composition.classes;
|
||
const planetN = classes.length;
|
||
// --- Planets ----------------------------------------------------------
|
||
// 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 <= planetN; i++) {
|
||
const pclass = classes[i - 1];
|
||
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),
|
||
});
|
||
}
|
||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide
|
||
// (class, frame) pass): stamp the assigned sheet frame on each world so
|
||
// the same class+face is spread across the galaxy (the renderer
|
||
// prefers planet.frame over a random pick).
|
||
const frames = galaxy?.planetFrames?.get(record.id);
|
||
if (Array.isArray(frames)) {
|
||
for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i];
|
||
}
|
||
|
||
// --- Settlements (the lived-in layer) ---------------------------------
|
||
const settlements = generateSettlements({
|
||
rng,
|
||
systemId: record.id,
|
||
kindDefs: config.get('settlements.kinds', {}),
|
||
spec,
|
||
planets,
|
||
stationDeck: NameGenerator.stationDeck(
|
||
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
|
||
),
|
||
density: settlementDensity(galaxy, record),
|
||
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
|
||
isHome,
|
||
});
|
||
|
||
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
|
||
// spread pass): stamp the assigned spacestations.png frame on this
|
||
// system's deep-space station, so the same station type is spread
|
||
// across the galaxy (the renderer prefers settlement.stationFrame over
|
||
// a fallback pick; the landing handoff reads the same frame).
|
||
const stationFrame = galaxy?.stationFrames?.get(record.id);
|
||
if (typeof stationFrame === 'number') {
|
||
for (const s of settlements) {
|
||
if (s.kind === 'deepSpaceStation') s.stationFrame = stationFrame;
|
||
}
|
||
}
|
||
|
||
// --- 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): 1–maxGates, 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 (1–3; [] for a one-system galaxy)
|
||
};
|
||
if (isHome) {
|
||
content.homeName = homeName; // the player's home world (starting system only)
|
||
// The home world's sheet frame (the galaxy-wide frame pass stamped it
|
||
// on the galaxy — js/galaxy/PlanetFrames.js).
|
||
if (typeof galaxy?.homeWorldFrame === 'number') content.homeFrame = galaxy.homeWorldFrame;
|
||
}
|
||
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";
|
||
* - ANCHORED systems (a planet — or the home world in the starting
|
||
* system): within level-`anchorTetherLevel` tether (5120 px for level
|
||
* 1) of an anchor — 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 center, gate, and destination
|
||
* 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). Candidates on the
|
||
* DESTINATION SIDE of the system center sort first — before every
|
||
* tier — so the gate faces its destination FROM THE CENTER (the rule
|
||
* the map reads) and is never placed on the far side; an on-ray
|
||
* intersection behind the center loses to any destination-side
|
||
* aimed/scan point.
|
||
* Anchors are PLANETS
|
||
* only (plus the home world at home) — free-space stations are
|
||
* deliberately excluded: the build console lives on a world, so every
|
||
* gate must be tether-reachable from a planet the player can build
|
||
* out from (reach the anchor → build the next tether level → expand).
|
||
* (Every non-barren system holds at least one planet — the
|
||
* composition roll demotes a station if the budget ran out — so an
|
||
* anchor always exists.)
|
||
* - BARREN systems (no planets — objectCount → 0, the network's
|
||
* dead-end leaves): on the ray toward the destination,
|
||
* `barrenDistance` from the center (stepped outward within the
|
||
* radius band only if the gate gap forces it). The activation
|
||
* mechanic (future work) then turns the gate itself into the system's
|
||
* level-1 tether anchor — see data/gates.json → ACTIVITY;
|
||
* - stays `minRadius..maxRadius` from the center, `size` + `clearance`
|
||
* clear of every anchor disc (planets + free-space stations), 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, active: false }
|
||
* `active` defaults to false — the gate is inert until the player
|
||
* activates it (data/gates.json → ACTIVITY); an activated gate anchors a
|
||
* level-1 tether at its own position.
|
||
*/
|
||
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 and (home only) the home world at the origin —
|
||
// the bodies a gate's tether may hang from. Free-space stations are
|
||
// deliberately NOT anchors (the build console lives on a world — the
|
||
// player must always be able to reach a planet and build out from the
|
||
// anchor); the composition roll guarantees a non-barren system holds
|
||
// at least one. A BARREN system holds none — its gate uses the on-ray
|
||
// rule below.
|
||
const anchors = planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) }));
|
||
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
|
||
// Clearance discs: every solid body the gate must keep clear of — the
|
||
// anchors plus the free-space stations.
|
||
const discs = [
|
||
...anchors,
|
||
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
|
||
];
|
||
|
||
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);
|
||
|
||
let chosen = null;
|
||
if (anchors.length === 0) {
|
||
// BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate
|
||
// sits ON the ray toward its destination, `barrenDistance` from the
|
||
// center (data/gates.json), stepped outward in 1024 px steps within the
|
||
// radius band if the gate gap forces it (two close targets). When the
|
||
// player activates it (future mechanic), the gate itself becomes the
|
||
// system's level-1 tether anchor (data/gates.json → ACTIVITY); its 1–2
|
||
// asteroid clusters drift inside that tether (data/asteroids.json →
|
||
// barren).
|
||
const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192)));
|
||
const okB = (px, py) => {
|
||
const d2c = px * px + py * py;
|
||
if (d2c < minR * minR || d2c > maxR * maxR) return false;
|
||
for (const j of jumps) {
|
||
const need = 2 * size + gap;
|
||
const dx = px - j.x;
|
||
const dy = py - j.y;
|
||
if (dx * dx + dy * dy < need * need) return false;
|
||
}
|
||
return true;
|
||
};
|
||
for (let D = base; D <= maxR + 1e-6 && !chosen; D += 1024) {
|
||
if (okB(D * ux, D * uy)) chosen = { px: D * ux, py: D * uy };
|
||
}
|
||
for (const eps of [0.05, -0.05, 0.1, -0.1, 0.2, -0.2]) {
|
||
// Last resort — a 1024 px step over the band should always clear a
|
||
// 384 px gap; angle-nudge if not (the facing rule is soft).
|
||
if (chosen) break;
|
||
const a = th + eps;
|
||
if (okB(base * Math.cos(a), base * Math.sin(a))) {
|
||
chosen = { px: base * Math.cos(a), py: base * Math.sin(a) };
|
||
}
|
||
}
|
||
if (!chosen) {
|
||
chosen = { px: base * ux, py: base * uy };
|
||
console.warn(`[orbit] ${record.id}: gate ${i + 1} could not clear the gate gap (barren)`);
|
||
}
|
||
} else {
|
||
// 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 facing rule (soft —
|
||
// the gate faces its destination FROM THE SYSTEM CENTER) is honored
|
||
// by the sort: destination-side candidates (side 0) come FIRST —
|
||
// before every tier — so a gate is never placed on the far side of
|
||
// the center (an on-ray intersection behind the center loses to any
|
||
// destination-side aimed/scan point); then on-ray → aimed →
|
||
// near-aimed.
|
||
const cands = [];
|
||
const add = (tier, order, px, py) =>
|
||
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, px, py });
|
||
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));
|
||
add(1, h * 1e6 + ai * 1000, (proj + off) * ux, (proj + off) * uy);
|
||
}
|
||
add(2, h * 1e6 + ai * 1000, a.x + range * ux, 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);
|
||
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3, a.x + range * dx, a.y + range * dy);
|
||
}
|
||
});
|
||
cands.sort((p, q) => p.side - q.side || 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 discs) {
|
||
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;
|
||
};
|
||
|
||
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 DESTINATION-SIDE aimed point anyway — the tether and
|
||
// facing rules outrank cosmetics.
|
||
chosen =
|
||
cands.find((c) => c.tier === 2 && c.side === 0) ??
|
||
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,
|
||
// Inert until the player activates it (data/gates.json → ACTIVITY):
|
||
// an activated gate anchors a level-1 tether at its own position —
|
||
// the room to move in a barren system. The activation mechanic is
|
||
// future work; the renderer dims inactive gates.
|
||
active: false,
|
||
// 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^(N−1). */
|
||
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 4–8 rocks (data → cluster.groupSize), each 64–128
|
||
* 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^(homeLevel−1), 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 isBarren = planets.length === 0 && freeSpace.length === 0;
|
||
if (isBarren && !jumps.some((j) => typeof j.x === 'number' && typeof j.y === 'number')) {
|
||
return []; // no gate to hang the cluster on (a one-system galaxy)
|
||
}
|
||
|
||
const clusterCfg = cfg.cluster ?? {};
|
||
const dist = cfg.distribution ?? {};
|
||
const placement = cfg.placement ?? {};
|
||
const isHome = record.id === galaxy?.homeSystemId;
|
||
|
||
// --- How many clusters -------------------------------------------------
|
||
// BARREN (dead-end) systems: the stop's payload — a little rock to mine
|
||
// inside the gate's tether (data/asteroids.json → barren.clusters,
|
||
// default 1–2). Everyone else: the inverse of the planet count — 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.
|
||
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
|
||
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;
|
||
let count;
|
||
if (isBarren) {
|
||
const bc = cfg.barren ?? {};
|
||
const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
|
||
const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
|
||
count = rng.int(lo, hi);
|
||
} else {
|
||
const target = dist.targetObjects ?? 9;
|
||
const jitter = dist.jitter ?? 1;
|
||
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;
|
||
|
||
// The dead end's anchor — its gate (barren systems are leaves of the
|
||
// tree, so exactly one; the cluster drifts inside its level-1 tether
|
||
// radius — an activated gate anchors a level-1 tether).
|
||
const gateAnchor = isBarren
|
||
? (jumps.find((j) => typeof j.x === 'number' && typeof j.y === 'number') ?? null)
|
||
: null;
|
||
const gateTetherRadius = anchorTetherRange(1);
|
||
|
||
// 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 — a real body in the
|
||
// starting system; every other system's center is empty) + every placed
|
||
// object (planets, free-space stations, and the jump gates).
|
||
const placed = [];
|
||
if (isHome) placed.push({ 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.
|
||
// A BARREN (dead-end) system's clusters drift INSIDE the gate's
|
||
// level-1 tether (the stop's only payload — mine it, then head back):
|
||
// an annulus around the gate, from barren.minRadius out to the tether
|
||
// rim minus the cluster's extent and the margin.
|
||
let pos;
|
||
if (isBarren && gateAnchor) {
|
||
const bc = cfg.barren ?? {};
|
||
const bMin = Math.max(0, Math.floor(bc.minRadius ?? 1024));
|
||
const bMax = Math.max(bMin, gateTetherRadius - bound - tetherMargin);
|
||
pos = placeInAnnulus(rng, bMin, bMax, placed, minSep, maxAttempts, gateAnchor);
|
||
} else {
|
||
const zoneMax =
|
||
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
|
||
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.2–0.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 `center` (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, center = { x: 0, y: 0 }) {
|
||
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 = center.x + Math.cos(a) * r;
|
||
const y = center.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);
|
||
}
|
||
|
||
/**
|
||
* The system's OBJECT COMPOSITION (data/systems.json → objectCount +
|
||
* attributes.settlements + the core→rim density gradient —
|
||
* data/galaxy.json → settlements.gradient), in one deterministic roll:
|
||
*
|
||
* home system → fixed: 2 planets (gas giant + rocky) beside the home
|
||
* world, + at most one free-space station (deepSpace only —
|
||
* a waypoint would be a 5th point the home band can't hold);
|
||
* every other → 1) the FINAL object count N from the configured table
|
||
* (0 = barren, then 2/3/4/5 at the configured weights — the
|
||
* distribution is on the FINAL count, by design);
|
||
* 2) the free-space stations (0–2, per-type odds × the
|
||
* density gradient; at most 2, which never exceeds the
|
||
* minimum non-barren budget of 2);
|
||
* 3) the planets — N − stations weighted class draws
|
||
* (a 2-object system can be 0 planets + 2 stations).
|
||
*
|
||
* Both the content generator and the galaxy-wide frame pass
|
||
* (js/galaxy/PlanetFrames.js) call this — the forks
|
||
* (seed, 'system', id, 'planets' / 'settlements') are pure functions of
|
||
* their inputs, so they always agree.
|
||
* → { objects, deepSpace, waypoint, classes }
|
||
*/
|
||
export function rollSystemComposition(seed, record, isHome, spec, density, attr) {
|
||
if (isHome) {
|
||
const rng = Rng.derive(seed, 'system', record.id, 'settlements');
|
||
const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
|
||
return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] };
|
||
}
|
||
// 1) The final object count — the configured composition table.
|
||
const oc = config.get('systems.objectCount', {
|
||
barren: 0.1,
|
||
objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 },
|
||
});
|
||
const table = { 0: Math.max(0, Number(oc.barren) || 0) };
|
||
for (const [k, w] of Object.entries(oc.objects ?? {})) {
|
||
const n = Number(k);
|
||
if (Number.isInteger(n) && n > 0) table[n] = Math.max(0, Number(w) || 0);
|
||
}
|
||
const rngP = Rng.derive(seed, 'system', record.id, 'planets');
|
||
const objects = Math.max(0, Number(rngP.weighted(table, 2)));
|
||
|
||
// BARREN (N = 0): a jump-gate-only system — no stations, no planets.
|
||
if (objects === 0) {
|
||
return { objects, deepSpace: false, waypoint: false, classes: [] };
|
||
}
|
||
|
||
// 2) The free-space stations (≤ 2 — never more than the min budget of 2).
|
||
const rngS = Rng.derive(seed, 'system', record.id, 'settlements');
|
||
const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
|
||
const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density);
|
||
|
||
// 3) The planets fill the rest of the budget (N − stations ≥ 0).
|
||
// Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors
|
||
// its tether to a world (layoutGates), and the player must be able to
|
||
// reach a planet and build out from it — a station-only budget
|
||
// (N = 2 stations) demotes one station to a planet.
|
||
let stations = Number(deepSpace) + Number(waypoint);
|
||
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
||
let wp = waypoint;
|
||
let ds = deepSpace;
|
||
while (stations >= objects && stations > 0) {
|
||
if (wp) wp = false;
|
||
else ds = false;
|
||
stations = Number(ds) + Number(wp);
|
||
}
|
||
const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky'));
|
||
return { objects, deepSpace: ds, waypoint: wp, classes };
|
||
}
|
||
|
||
/**
|
||
* The system's PLANET CLASSES — a thin wrapper over rollSystemComposition
|
||
* for callers that only need the worlds (the frame pass uses the full roll).
|
||
*/
|
||
export function rollPlanetClasses(seed, record, isHome, attr, spec, density) {
|
||
return rollSystemComposition(seed, record, isHome, spec, density, attr).classes;
|
||
}
|
||
|
||
/** Backwards-compatible roll (tests/tools) — the stations of a system. */
|
||
export function rollStationCount(seed, record, isHome, spec, density) {
|
||
const c = rollSystemComposition(seed, record, isHome, spec, density, {});
|
||
return { deepSpace: c.deepSpace, waypoint: c.waypoint, count: Number(c.deepSpace) + Number(c.waypoint) };
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same
|
||
* values from the same inputs.
|
||
*/
|
||
export 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, stations, 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 — the PRE-ROLLED flags (rollStationCount:
|
||
// per-type odds × the core→rim density gradient; the home system never
|
||
// rolls a waypoint — its band can't hold 5 points). A BARREN system
|
||
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
|
||
// deliberately.
|
||
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' });
|
||
if (stations?.waypoint) make('waypoint', { 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))));
|
||
}
|