Compare commits
2 Commits
8bf9b2e3e6
...
856508ff01
| Author | SHA1 | Date |
|---|---|---|
|
|
856508ff01 | |
|
|
5e4f94f385 |
14
README.md
14
README.md
|
|
@ -36,11 +36,13 @@ node dev/server.mjs 8080
|
|||
(click it and type), and rerollable — and the menu shows what that seed
|
||||
builds (the galaxy's name, system count, archetype count) **before** you
|
||||
commit. Same seed ⇒ same galaxy.
|
||||
- **Procedural galaxy**: 60 star systems in a sparse seeded disk — no
|
||||
spiral arms, no core bulge, uniform-in-area (`data/galaxy.json`), typed
|
||||
into six themed archetypes (`data/systems.json`) with per-type
|
||||
distribution weights and radial bands — the first "how does the galaxy
|
||||
lay itself out" rules.
|
||||
- **Procedural galaxy**: 90 star systems in a seeded wide (2:1, plate-shaped) field —
|
||||
even, organic spacing (Poisson disk, `data/galaxy.json`), the player's
|
||||
home in the lower-right corner, and a home→far difficulty axis split
|
||||
into near/middle/far zones — typed into six themed archetypes
|
||||
(`data/systems.json`) with per-type weights and per-zone mixing
|
||||
(`galaxy.distribution.zoneMix`) — the galaxy lays itself out on a
|
||||
progression the trade/combat economy can lean on.
|
||||
- **Two-level generation**: the whole galaxy roster is generated at New
|
||||
Game (~15 ms); each system's planets/moons/belts/settlements are
|
||||
generated lazily on arrival, deterministically (seed + system id), so
|
||||
|
|
@ -186,7 +188,7 @@ orbit/
|
|||
│ ├── planets.json # home world + system layout + solid-disc rules
|
||||
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
|
||||
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, sparse disk…)
|
||||
│ ├── galaxy.json # galaxy scale & shape (count, 2:1 field, zones, corner home…)
|
||||
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
||||
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
||||
│ ├── gates.json # JUMP GATES: network (1–3 gates, local jumps, pure spanning tree — maze, no shortcuts) + placement (tether anchor, facing, radii, gaps)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,18 +1,27 @@
|
|||
{
|
||||
"_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout = a sparse seeded DISK: no spiral arms (spiral.enabled = false, arms = 0 — the arm snap is skipped), no center bulge (coreFraction = 0), diskSkew = 0.5 so the radial spread is uniform-in-area (rNorm = √x — stars spread out sparsely across the disk instead of crowding the core), flatten = y squash (ellipse). settlements.gradient = free-space settlement density by galactic radius (rNorm 0 = center, 1 = rim): chance = 1 − rNorm×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).",
|
||||
"systemCount": 60,
|
||||
"radius": 20000,
|
||||
"_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout.field = the galaxy is a seeded WIDE (2:1) field of stars (center at the world origin, ±width/2 in x, ±height/2 in y — the 2:1 shape matches the map plate, js/ui/MapWindow.js at the 1280×720 design size, so the fully-zoomed-out galaxy fills the plate): Bridson Poisson-disk sampling keeps every pair of systems at least minSpacing·√(width·height/systemCount) apart — an even, organic field (no clumps, no voids, not a grid). startingSystem: policy 'corner' puts the player's home in the star NEAREST the configured corner (corner: NE/NW/SE/SW, screen orientation — SE = lower right); 'center' (nearest the origin) and 'random' still work. distribution.zones slices the HOME→FAR diagonal into named zones (d: 0 at the home corner, 1 at the opposite corner); distribution.zoneMix multiplies each type's distribution.weight PER ZONE (the old per-type radiusBand is gone — type flavor is regional now). settlements.gradient = free-space settlement density by diagonal position (d 0 = home corner, 1 = far corner): chance = 1 − d×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).",
|
||||
"systemCount": 90,
|
||||
"layout": {
|
||||
"coreFraction": 0,
|
||||
"bulgeSigma": 0.09,
|
||||
"diskSkew": 0.5,
|
||||
"flatten": 0.62,
|
||||
"spiral": { "enabled": false, "arms": 0, "twist": 2.6, "strength": 0.5 }
|
||||
"field": {
|
||||
"width": 32000,
|
||||
"height": 16000,
|
||||
"minSpacing": 0.8
|
||||
}
|
||||
},
|
||||
"distribution": {
|
||||
"rules": []
|
||||
"_comment": "zones slice the HOME→FAR diagonal (d) into the three regions. The diagonal bands have UNEVEN area (corner wedges vs the middle band), so the edges are set for EQUAL-AREA thirds: with half-extents (A, B) and d = (A²u + B²v)/(A²+B²) over uniform (u, v), the area CDF is f(t) = (t − β/2)/α with α = A²/(A²+B²), β = 1−α for the middle band — for the 2:1 field (α = 4/5) that's f(t) = 1.25t − 0.125, giving equal thirds at t = 11/30 ≈ 0.367 and 19/30 ≈ 0.633. Re-derive these edges if the field aspect changes. zoneMix multiplies each type's global distribution.weight per zone (missing type = ×1).",
|
||||
"zones": [
|
||||
{ "name": "near", "d": [0, 0.37] },
|
||||
{ "name": "middle", "d": [0.37, 0.63] },
|
||||
{ "name": "far", "d": [0.63, 1] }
|
||||
],
|
||||
"zoneMix": {
|
||||
"near": { "main": 1.0, "redDwarf": 1.25, "binary": 0.8, "habitable": 1.5, "nebula": 0.4, "void": 0.3 },
|
||||
"middle": { "main": 1.15, "redDwarf": 1.0, "binary": 1.0, "habitable": 0.85, "nebula": 0.9, "void": 0.75 },
|
||||
"far": { "main": 0.85, "redDwarf": 0.55, "binary": 1.1, "habitable": 0.25, "nebula": 1.8, "void": 2.6 }
|
||||
}
|
||||
},
|
||||
"startingSystem": { "policy": "center" },
|
||||
"startingSystem": { "policy": "corner", "corner": "SE" },
|
||||
"settlements": {
|
||||
"gradient": { "falloff": 0.7, "floor": 0.22 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
"stationVideos": [
|
||||
{ "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": null },
|
||||
{ "land": "ss-land-02.mp4", "surface": null, "takeoff": "ss-takeoff-02.mp4", "shop": null },
|
||||
{ "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": null },
|
||||
{ "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": null }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@
|
|||
"galaxy": {
|
||||
"_comment": "THE GALAXY TAB (js/ui/GalaxyView.js) — the whole-galaxy chart on the plate: one GLOWING STAR per system (colored by its archetype, data/systems.json → types, pulsing on that archetype's heartbeat in `pulse`), THIN LANES where the jump gates connect (the JumpNetwork spanning tree — a maze), the CHARTED REGION (the convex hull of the visited systems, inflated — the discovered-area shading), HOME + SHIP markers, ambient dust + the core glow. TRAVELED lanes (the run jumped them — saved with the run) glow brighter + carry a flow packet; FRONTIER lanes (one end visited) are the 'next step'; unexplored lanes are faint threads. Hover = the star's readout + its link state to the current system — STARS ONLY, and the zone hugs each star's drawn dot (glow radius + a few px of mouse pad), so the lanes between the stars never trigger it; click a CHARTED star = its chart in the SYSTEM tab; uncharted stars are readouts only. FACTIONS (planned, not yet implemented): the snapshot carries per-system `faction: null` — the reserved seams are a faction color layer over the stars + a per-faction territory region (the same hull/fill recipe as `hull`, per faction color + relation alpha).",
|
||||
"name": "GALAXY",
|
||||
"padding": 90,
|
||||
"platePadding": 26,
|
||||
"_platePadding": "margin kept between the outermost stars and the plate edge at 1× zoom, in PLATE px (js/ui/GalaxyView.js → fitPadded). The field is 2:1 to match the plate, so the fully-zoomed-out galaxy fills the plate with this much breathing room on all sides.",
|
||||
"stars": {
|
||||
"_comment": "Per-system star rendering. minPx = the core dot at 1× zoom (a tad bigger than a pixel — the fully-zoomed-out dot); zoomGrow = px gained per zoom step (the star grows as you approach). glow/core = legacy world-unit bases (kept for the glow texture's swing). visitedBoost = how much brighter a charted star's glow reads; unknownMul/unknownAlpha = the dimmer read on uncharted stars (the galaxy is bigger than the run). art = the ZOOM-BLOOM design: the star is a plain dot until flareAt, then diffraction spikes fade in; at crownAt its type's signature appears (main = granulation rim + corona ticks, redDwarf = breathing corona + prominence arcs, binary = an orbiting companion on a faint ellipse, habitable = the life-zone rings + orbiting world(s), nebula = a tilted accretion disc + drifting speckles, void = a dark horizon + shimmering photon ring + lensing ticks); at surfaceAt the core gains a surface wobble + a glint. All sizes are × the dot; all angles/phases are seeded per system (deterministic); everything animates on scene time. spikes = 0 for the void (no sparkle around a horizon).",
|
||||
"glow": 30,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).",
|
||||
"objectCount": {
|
||||
"barren": 0.10,
|
||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"label": "Main Sequence",
|
||||
"description": "An ordinary star and its worlds — the galaxy's working majority.",
|
||||
"theme": { "color": "#9fb4e8" },
|
||||
"distribution": { "weight": 34, "radiusBand": null },
|
||||
"distribution": { "weight": 34 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "G": 30, "K": 40, "M": 30 },
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
"label": "Red Dwarf",
|
||||
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
|
||||
"theme": { "color": "#e8927c" },
|
||||
"distribution": { "weight": 26, "radiusBand": [0.0, 0.6] },
|
||||
"distribution": { "weight": 26 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "M": 85, "K": 15 },
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"label": "Binary",
|
||||
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
|
||||
"theme": { "color": "#c9a7ff" },
|
||||
"distribution": { "weight": 10, "radiusBand": [0.15, 0.95] },
|
||||
"distribution": { "weight": 10 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "F": 25, "G": 40, "K": 35 },
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"label": "Habitable",
|
||||
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
|
||||
"theme": { "color": "#7ce8a4" },
|
||||
"distribution": { "weight": 10, "radiusBand": [0.2, 0.75] },
|
||||
"distribution": { "weight": 10 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "G": 70, "K": 30 },
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
"label": "Nebula",
|
||||
"description": "Young, bright, and still messy — debris where planets should be.",
|
||||
"theme": { "color": "#5fd4d0" },
|
||||
"distribution": { "weight": 12, "radiusBand": [0.4, 1.0] },
|
||||
"distribution": { "weight": 12 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "A": 20, "F": 30, "G": 50 },
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"label": "Void",
|
||||
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
|
||||
"theme": { "color": "#7d88a8" },
|
||||
"distribution": { "weight": 8, "radiusBand": [0.7, 1.0] },
|
||||
"distribution": { "weight": 8 },
|
||||
"attributes": {
|
||||
"star": {
|
||||
"classes": { "M": 90, "K": 10 },
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
* contract the whole game rests on:
|
||||
* - same seed ⇒ identical roster (ids, names, types, positions);
|
||||
* - different seed ⇒ different galaxy;
|
||||
* - type distribution matches the weights in data/systems.json;
|
||||
* - type radius bands (the first proximity rule) are respected;
|
||||
* - type distribution matches the weights × per-zone mix (zoneMix, ±4σ);
|
||||
* - field bounds + even field (Poisson disk), every record carries d + zone;
|
||||
* - starting system = the star nearest the configured home corner;
|
||||
* - every generated system obeys its type's attribute bounds;
|
||||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||||
* non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
* - lazy (on-arrival) content === eager (generateAll) content;
|
||||
* - spatial-hash neighbor queries agree with brute force;
|
||||
* - starting system policy works.
|
||||
* Also reports generation timing for the 60-system galaxy.
|
||||
* Also reports generation timing for the 90-system galaxy.
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -108,7 +109,8 @@ let big;
|
|||
const tCreate = performance.now() - t0;
|
||||
|
||||
const n = config.get('galaxy.systemCount', 0);
|
||||
check(`roster size = galaxy.systemCount (${n})`, big.records.length === n);
|
||||
const nSys = big.records.length;
|
||||
check(`roster size = galaxy.systemCount (${n})`, nSys === n);
|
||||
check('ids are stable & unique', new Set(big.records.map((r) => r.id)).size === n);
|
||||
check('current system is part of the roster', big.byId.has(big.currentSystemId));
|
||||
|
||||
|
|
@ -120,35 +122,80 @@ let big;
|
|||
const other = Galaxy.create('totally-different');
|
||||
check('different seed ⇒ different galaxy', !deepEq(big.records, other.records));
|
||||
|
||||
// Type distribution vs configured weights.
|
||||
const counts = {};
|
||||
for (const r of big.records) counts[r.type] = (counts[r.type] ?? 0) + 1;
|
||||
const nSys = big.records.length;
|
||||
// Type distribution vs configured weights — PER ZONE (the region layer:
|
||||
// distribution.zoneMix in data/galaxy.json multiplies each type's global
|
||||
// distribution.weight in data/systems.json; the old per-type radiusBand
|
||||
// is gone — type flavor is regional now).
|
||||
const zoneCfg = big.params.distribution?.zones ?? [];
|
||||
const zoneMix = big.params.distribution?.zoneMix ?? {};
|
||||
const baseW = Object.fromEntries(typeIds.map((id) => [id, Math.max(0, types[id].distribution?.weight ?? 1)]));
|
||||
const zoneRecs = {};
|
||||
for (const r of big.records) (zoneRecs[r.zone] ??= []).push(r);
|
||||
let distOk = true;
|
||||
for (const id of typeIds) {
|
||||
const obs = (counts[id] ?? 0) / nSys;
|
||||
const sd = Math.sqrt((expected[id] * (1 - expected[id])) / nSys);
|
||||
if (Math.abs(obs - expected[id]) > 4 * sd + 0.004) {
|
||||
distOk = false;
|
||||
console.log(` type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(expected[id] * 100).toFixed(1)}%`);
|
||||
for (const z of zoneCfg) {
|
||||
const zrecs = zoneRecs[z.name] ?? [];
|
||||
const nZ = zrecs.length;
|
||||
if (nZ < 3) continue; // too few to be meaningful
|
||||
const zw = {};
|
||||
let tot = 0;
|
||||
for (const id of typeIds) {
|
||||
const mul = zoneMix[z.name]?.[id];
|
||||
zw[id] = baseW[id] * (mul === undefined ? 1 : Math.max(0, mul));
|
||||
tot += zw[id];
|
||||
}
|
||||
const counts = {};
|
||||
for (const r of zrecs) counts[r.type] = (counts[r.type] ?? 0) + 1;
|
||||
for (const id of typeIds) {
|
||||
const p = zw[id] / tot;
|
||||
if (p <= 0) continue;
|
||||
const obs = (counts[id] ?? 0) / nZ;
|
||||
const sd = Math.sqrt(p * (1 - p) / nZ);
|
||||
if (Math.abs(obs - p) > 4 * sd + 0.004) {
|
||||
distOk = false;
|
||||
console.log(` zone ${z.name} type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
check('type distribution matches configured weights (±4σ)', distOk);
|
||||
check('type distribution matches configured weights × zoneMix per zone (±4σ)', distOk);
|
||||
|
||||
// Radius bands (the first "proximity" rule).
|
||||
const R = Math.max(1, big.params.radius ?? 20000);
|
||||
const flatten = big.params.layout?.flatten ?? 0.62;
|
||||
let bandOk = true;
|
||||
for (const r of big.records) {
|
||||
const band = types[r.type].distribution?.radiusBand;
|
||||
if (!Array.isArray(band) || band.length !== 2) continue;
|
||||
const rNorm = Math.sqrt(r.x * r.x + (r.y / flatten) ** 2) / R;
|
||||
if (rNorm < band[0] - 1e-9 || rNorm > band[1] + 1e-9) {
|
||||
bandOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
check('radius bands (proximity rule) respected by every system', bandOk);
|
||||
// FIELD BOUNDS + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract).
|
||||
const fieldW = Math.max(2, Math.floor(Number(big.params.layout?.field?.width) || 32000));
|
||||
const fieldH = Math.max(2, Math.floor(Number(big.params.layout?.field?.height) || 16000));
|
||||
check(
|
||||
'every system sits inside the 2:1 field (±width/2 × ±height/2)',
|
||||
big.records.every((r) => Math.abs(r.x) <= fieldW / 2 + 1e-9 && Math.abs(r.y) <= fieldH / 2 + 1e-9),
|
||||
);
|
||||
let minPair = Infinity;
|
||||
for (let i = 0; i < big.records.length; i++)
|
||||
for (let j = i + 1; j < big.records.length; j++)
|
||||
minPair = Math.min(minPair, Math.hypot(big.records[i].x - big.records[j].x, big.records[i].y - big.records[j].y));
|
||||
const spacingFloor = 0.7 * Math.sqrt((fieldW * fieldH) / big.records.length);
|
||||
check(
|
||||
`even field (Poisson disk): min pair distance ${Math.round(minPair)} px ≥ ${Math.round(spacingFloor)} px — no clumps, no voids`,
|
||||
minPair >= spacingFloor - 1e-6,
|
||||
);
|
||||
check(
|
||||
'every record carries d in [0,1] + a zone name',
|
||||
big.records.every((r) => Number.isFinite(r.d) && r.d >= 0 && r.d <= 1 && typeof r.zone === 'string'),
|
||||
);
|
||||
check(
|
||||
'record.zone matches its d against the configured zones',
|
||||
big.records.every((r) => {
|
||||
const z = zoneCfg.find((zz) => r.d >= zz.d[0] && r.d < zz.d[1]) ?? zoneCfg[zoneCfg.length - 1];
|
||||
return z?.name === r.zone;
|
||||
}),
|
||||
);
|
||||
// CORNER HOME (startingSystem.policy 'corner', corner SE — lower right,
|
||||
// screen y-down): the starting system is the star NEAREST the home corner.
|
||||
const homeCorner = { x: fieldW / 2, y: fieldH / 2 };
|
||||
const nearestToCorner = big.records
|
||||
.slice()
|
||||
.sort((a, b) => (a.x - homeCorner.x) ** 2 + (a.y - homeCorner.y) ** 2 - ((b.x - homeCorner.x) ** 2 + (b.y - homeCorner.y) ** 2))[0].id;
|
||||
check('corner policy: the starting system is the star nearest the home (SE) corner', big.currentSystemId === nearestToCorner);
|
||||
check(
|
||||
'corner policy: the home system sits in the NEAR zone (d ≈ 0 at the home corner)',
|
||||
big.currentSystem().zone === zoneCfg[0]?.name,
|
||||
);
|
||||
|
||||
// Lazy contents: the global OBJECT-COMPOSITION rule across the WHOLE
|
||||
// galaxy (data/systems.json → objectCount: 0/2/3/4/5 objects, ≈ 10%
|
||||
|
|
@ -178,7 +225,7 @@ let big;
|
|||
);
|
||||
check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys);
|
||||
// (The barren-share check below uses the same ±4σ band as the other
|
||||
// composition checks — at 60 systems the count is small, so an absolute
|
||||
// composition checks — at 90 systems the count is small, so an absolute
|
||||
// 0.08 band was tighter than the sampling noise.)
|
||||
|
||||
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
|
||||
|
|
@ -230,14 +277,24 @@ let big;
|
|||
const wantP = bruteNearest(point.x, point.y, 3).sort();
|
||||
check('nearest(point, k) matches brute force', deepEq(gotP, wantP));
|
||||
|
||||
const centerPolicy = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
|
||||
check('random starting policy picks a roster member', centerPolicy.byId.has(centerPolicy.currentSystemId));
|
||||
const randomPolicy = Galaxy.create('random-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
|
||||
check('random starting policy picks a roster member', randomPolicy.byId.has(randomPolicy.currentSystemId));
|
||||
|
||||
const center = Galaxy.create('center-policy', { systemCount: 250 });
|
||||
const center = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'center' } });
|
||||
const centerRecs = center.records;
|
||||
const trueCenter = centerRecs.slice().sort((a, b) => (a.x ** 2 + a.y ** 2) - (b.x ** 2 + b.y ** 2))[0].id;
|
||||
const gridNearest = center.nearest(0, 0, 1)[0].id;
|
||||
check('center starting policy picks the record nearest the origin', center.currentSystem().id === trueCenter && gridNearest === trueCenter);
|
||||
|
||||
// The CORNER policy honors whichever corner is configured (here: NW —
|
||||
// upper left, screen y-down), not just the default SE.
|
||||
const nw = Galaxy.create('nw-policy', { systemCount: 250, startingSystem: { policy: 'corner', corner: 'NW' } });
|
||||
const halfNWx = Math.max(2, Math.floor(Number(nw.params.layout?.field?.width) || 32000)) / 2;
|
||||
const halfNWy = Math.max(2, Math.floor(Number(nw.params.layout?.field?.height) || 16000)) / 2;
|
||||
const trueNW = nw.records
|
||||
.slice()
|
||||
.sort((a, b) => (a.x + halfNWx) ** 2 + (a.y + halfNWy) ** 2 - ((b.x + halfNWx) ** 2 + (b.y + halfNWy) ** 2))[0].id;
|
||||
check('corner policy honors the configured corner (NW)', nw.currentSystemId === trueNW);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -344,20 +401,21 @@ let big;
|
|||
}),
|
||||
);
|
||||
|
||||
// Core→rim gradient: the free-space STATION odds scale with the core→rim
|
||||
// density (data/galaxy.json → settlements.gradient) — the planets are
|
||||
// settled regardless, so the gradient lives in how many of the objects
|
||||
// are stations: the settled heart is denser in stations than the rim.
|
||||
// Home→far gradient: the free-space STATION odds scale with the home→far
|
||||
// density (data/galaxy.json → settlements.gradient, keyed on d: 0 at the
|
||||
// home corner, 1 at the far corner) — the planets are settled regardless,
|
||||
// so the gradient lives in how many of the objects are stations: the
|
||||
// settled heart is denser in stations than the deep corner.
|
||||
const withCount = sample.map((r) => ({
|
||||
rNorm: r.rNorm,
|
||||
d: r.d,
|
||||
n: (big2.ensureContent(r.id).settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
|
||||
}));
|
||||
withCount.sort((a, b) => a.rNorm - b.rNorm);
|
||||
withCount.sort((a, b) => a.d - b.d);
|
||||
const third = Math.floor(withCount.length / 3);
|
||||
const inner = withCount.slice(0, third);
|
||||
const outer = withCount.slice(-third);
|
||||
const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length;
|
||||
check(`core→rim station gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
|
||||
check(`home→far station gradient (near ${avg(inner).toFixed(2)}/system > far ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
|
||||
|
||||
// Station + planet naming now draws from the curated BANKS (no repeats
|
||||
// within a system until the pool is exhausted). The deck is a seeded
|
||||
|
|
|
|||
|
|
@ -142,6 +142,22 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
}
|
||||
check('reachable from home: every system (no forward dead ends)', fwd.size === g.records.length, `${fwd.size}/${g.records.length}`);
|
||||
|
||||
// HOP SCALE (the trade-economy property): with the even star field,
|
||||
// hop counts track MAP distance — the journey to the far corner is a
|
||||
// normal trip (a fraction of the roster), not a winding labyrinth.
|
||||
const depth = new Map([[HOME, 0]]);
|
||||
const qd = [HOME];
|
||||
while (qd.length) {
|
||||
const u = qd.pop();
|
||||
for (const t of g.jumpGatesFor(u)) if (!depth.has(t.id)) depth.set(t.id, depth.get(u) + 1), qd.push(t.id);
|
||||
}
|
||||
const maxHops = Math.max(...depth.values());
|
||||
check(
|
||||
`hop scale: the farthest system is ${maxHops} hops from home (hops ≈ map distance)`,
|
||||
maxHops < g.records.length / 2,
|
||||
`max ${maxHops} of ${g.records.length} systems`,
|
||||
);
|
||||
|
||||
// Strong connectivity, backward: every system can reach home
|
||||
// (no closed systems, no trapped sets).
|
||||
const radj = new Map(g.records.map((r) => [r.id, []]));
|
||||
|
|
|
|||
|
|
@ -84,37 +84,42 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space
|
|||
// 2. The pass beats a naive random assignment
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
// The pass's variant collision rate: for each station, count its
|
||||
// The pass's variant collision count: for each station, count its
|
||||
// POOL-nearest stars whose station wears the SAME frame (directed).
|
||||
const collisionRate = (frameOf) => {
|
||||
let coll = 0;
|
||||
let checks = 0;
|
||||
for (const [id, st] of stations) {
|
||||
for (const nb of g.neighborsOf(id, POOL)) {
|
||||
// Aggregated over THREE seeds — a single galaxy is sparse in
|
||||
// station adjacency (most stars carry no station), and the naive
|
||||
// random baseline can be 0 in any given seed; summed, it is a
|
||||
// stable floor the pass must beat.
|
||||
const SEEDS = [SEED, 'sf-seed-b', 'sf-seed-c'];
|
||||
let passColl = 0;
|
||||
let naiveColl = 0;
|
||||
let checks = 0;
|
||||
for (const sd of SEEDS) {
|
||||
const gg = Galaxy.create(sd);
|
||||
const sts = new Map(); // id → stamped frame
|
||||
for (const rec of gg.records)
|
||||
for (const s of gg.ensureContent(rec.id).settlements)
|
||||
if (s.kind === 'deepSpaceStation') sts.set(rec.id, s.stationFrame);
|
||||
// Naive: independent random pool picks (seeded, per station).
|
||||
const naiveF = {};
|
||||
for (const id of sts.keys())
|
||||
naiveF[id] = POOLV.length === 1
|
||||
? POOLV[0]
|
||||
: POOLV[Math.floor(Rng.derive(sd, 'naive-station', id).next() * POOLV.length)];
|
||||
for (const [id, fr] of sts) {
|
||||
for (const nb of gg.neighborsOf(id, POOL)) {
|
||||
checks++;
|
||||
const nbFrame = frameOf(nb.id);
|
||||
if (nbFrame !== null && nbFrame === st.frame) coll++;
|
||||
const nfr = sts.get(nb.id);
|
||||
if (nfr !== undefined && nfr === fr) passColl++;
|
||||
const nnfr = naiveF[nb.id];
|
||||
if (nnfr !== undefined && nnfr === fr) naiveColl++;
|
||||
}
|
||||
}
|
||||
return { coll, checks, rate: checks === 0 ? 0 : coll / checks };
|
||||
};
|
||||
|
||||
const passed = collisionRate((id) => stations.get(id)?.frame ?? null);
|
||||
|
||||
// Naive: independent random pool picks (seeded, per station).
|
||||
const naive = new Map();
|
||||
for (const id of stations.keys()) {
|
||||
const f = POOLV.length === 1
|
||||
? POOLV[0]
|
||||
: POOLV[Math.floor(Rng.derive(SEED, 'naive-station', id).next() * POOLV.length)];
|
||||
naive.set(id, f);
|
||||
}
|
||||
const na = collisionRate((id) => naive.get(id) ?? null);
|
||||
|
||||
check(
|
||||
`variant collisions vs the ${POOL}-nearest pool: pass ${(passed.rate * 100).toFixed(1)}% < naive ${(na.rate * 100).toFixed(1)}%`,
|
||||
passed.rate < na.rate,
|
||||
`${passed.coll}/${passed.checks} vs ${na.coll}/${na.checks} (station density sets the floor — most stars carry none)`,
|
||||
`variant collisions vs the ${POOL}-nearest pool (3 seeds): pass ${passColl} < naive ${naiveColl}`,
|
||||
passColl < naiveColl && naiveColl >= 1,
|
||||
`${passColl}/${checks} vs ${naiveColl}/${checks} (station density sets the floor — most stars carry none)`,
|
||||
);
|
||||
|
||||
// The spread should also produce ALL variants across the galaxy
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
|
||||
* (content.asteroids) — found/total + pct; missing list → 0/0.
|
||||
*/
|
||||
import { chartBounds, fitToRect, navDiscoveryStats, resourceStats, systemChartSnapshot } from '../js/galaxy/SystemChart.js';
|
||||
import { chartBounds, fitPadded, fitToRect, navDiscoveryStats, resourceStats, systemChartSnapshot } from '../js/galaxy/SystemChart.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -91,6 +91,29 @@ console.log('fitToRect');
|
|||
ok(near(tf.toX(100) - tf.toX(-100), 200), 'frame width preserved');
|
||||
}
|
||||
|
||||
// ── fitPadded (the galaxy plate's framing) ────────────────────────────────
|
||||
console.log('fitPadded');
|
||||
{
|
||||
// a 2:1 box into the 2:1 map plate (830.7×414 at the 1280×720 design
|
||||
// size) with a 26-plate-px margin — a TRUE rectangle fit: the
|
||||
// constrained axis fills the plate minus exactly padPx, the other axis
|
||||
// keeps ≥ padPx. (fitToRect would inscribe the box's bounding square
|
||||
// and leave most of the plate empty.)
|
||||
const bounds = { minX: -100, minY: -50, maxX: 100, maxY: 50, w: 200, h: 100, cx: 0, cy: 0 };
|
||||
const f = fitPadded(bounds, 830.7, 414, 26);
|
||||
ok(near(f.scale, Math.min(778.7 / 200, 362 / 100)), 'scale = min((w−2p)/bw, (h−2p)/bh)');
|
||||
ok(near(f.scale * 100, 362, 1e-6) && f.scale * 200 <= 778.7 + 1e-6, 'constrained axis fills plate−margin; the other axis stays inside');
|
||||
ok(near(f.scale * f.bounds.h, 414, 1e-6), 'inflated bounds (the pan/zoom clamp) reach exactly the plate edge on the constrained axis — the margin survives panning');
|
||||
ok(f.scale * f.bounds.w <= 830.7 + 1e-6, '...and never overflow the plate');
|
||||
ok(near(f.bounds.cx, 0) && near(f.bounds.cy, 0), 'centre preserved');
|
||||
}
|
||||
{
|
||||
// zero padding → plain true rectangle fit
|
||||
const bounds = { minX: 0, minY: 0, maxX: 100, maxY: 100, w: 100, h: 100, cx: 50, cy: 50 };
|
||||
const f = fitPadded(bounds, 830, 414, 0);
|
||||
ok(near(f.scale, 414 / 100), 'square bounds into a wide plate: limited by the shorter side (like fitToRect)');
|
||||
}
|
||||
|
||||
// ── navDiscoveryStats ──────────────────────────────────────────────────────
|
||||
console.log('navDiscoveryStats');
|
||||
{
|
||||
|
|
|
|||
|
|
@ -81,9 +81,9 @@ The galaxy is **seeded and two-level**. The seed is chosen on the main
|
|||
menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
||||
|
||||
1. **Roster** — `Galaxy.create(seed)` builds every system's *identity*
|
||||
(id, name, type, x, y) up front. 60 systems is ~10 ms, so the whole
|
||||
galaxy is always known: the player can never "discover" a layout that
|
||||
wasn't already implied by the seed.
|
||||
(id, name, type, x, y, d, zone) up front. 90 systems is ~10 ms, so the
|
||||
whole galaxy is always known: the player can never "discover" a layout
|
||||
that wasn't already implied by the seed.
|
||||
2. **Contents** — planets/moons/belts/**settlements**/hazards are
|
||||
generated **lazily** on first arrival (`galaxy.ensureContent(id)`),
|
||||
then cached. Each system's draw stream is `Rng.derive(seed, 'system', id)`
|
||||
|
|
@ -132,15 +132,27 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
|||
nearest stars, tie-broken by a derived seeded Rng. The stamps land on
|
||||
each planet record (`planet.frame`) and the home world's face on
|
||||
`content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`.
|
||||
- **distributed** — `distribution.weight` (how common) and
|
||||
`distribution.radiusBand` (first proximity rule: e.g. `void` systems
|
||||
live in the outer rim). Richer galaxy-level rules (clustering,
|
||||
faction borders, adjacency affinity) will slot into
|
||||
`data/galaxy.json` → `distribution.rules[]`, read in
|
||||
`Galaxy._generate()` — the hook is marked in code.
|
||||
- **distributed** — a seeded WIDE (2:1) field of stars
|
||||
(`galaxy.layout.field`: `width`, `height`, `minSpacing`) placed by
|
||||
Bridson Poisson-disk sampling — even, organic spacing (no clumps, no
|
||||
voids, not a grid). The 2:1 shape matches the map plate (830×414 at
|
||||
the 1280×720 design size), and the plate fit is a true rectangle fit
|
||||
with a plate-px margin on all sides (`map.galaxy.platePadding`) — the
|
||||
fully-zoomed-out galaxy fills the plate and no star sits on its edge. The player's home sits in the star NEAREST the configured
|
||||
corner (`startingSystem: policy "corner"`, `corner: SE` = lower right;
|
||||
`center`/`random` still work), and the HOME→FAR diagonal is the
|
||||
progression axis: every record carries `d` (0 = home corner, 1 = far
|
||||
corner) and its `zone` (`galaxy.distribution.zones`, equal-area thirds:
|
||||
near/middle/far). Types mix PER ZONE — `galaxy.distribution.zoneMix`
|
||||
multiplies each type's global `distribution.weight` per zone (the old
|
||||
per-type `radiusBand` is gone: `void`/`nebula` favor the deep corner,
|
||||
`habitable` the home corner). Richer galaxy-level rules (faction
|
||||
territories, trade hubs, combat difficulty by zone) will read
|
||||
`record.zone` / `record.d` — the seams are in place now.
|
||||
|
||||
The player's **current system** starts at `galaxy.currentSystem()`
|
||||
(`startingSystem.policy`: `center` or `random`). Jumping between systems
|
||||
(`startingSystem.policy`: `corner` (default — the home corner, SE),
|
||||
`center`, or `random`). Jumping between systems
|
||||
now has its NETWORK (see "Jump gates" below — `data/gates.json` +
|
||||
`js/galaxy/JumpNetwork.js`, built on `galaxy.neighborsOf(id)` and the
|
||||
spatial hash); the in-flight jump drive is the next mechanic on top.
|
||||
|
|
@ -169,10 +181,12 @@ branch is now the normal case, not a defensive fallback. Model & seams:
|
|||
- **Per-type free-space rates** — `types.<id>.attributes.settlements` in
|
||||
`data/systems.json`: `chance` per free-space kind (deep-space station,
|
||||
waypoint). Same attribute-driven pattern as everything else.
|
||||
- **Core→rim gradient** — `galaxy.settlements.gradient` in
|
||||
`data/galaxy.json`: the settled heart is denser (factor 1.0), the rim
|
||||
is thinner (clamped to `floor`). Each record carries `rNorm` (0 =
|
||||
center, 1 = rim) so density is per-system, not global.
|
||||
- **Home→far gradient** — `galaxy.settlements.gradient` in
|
||||
`data/galaxy.json`: the settled heart (the home corner, `d = 0`) is
|
||||
denser (factor 1.0), the deep corner (`d = 1`) is thinner (clamped to
|
||||
`floor`). Each record carries `d` (0 = home corner, 1 = far corner) and
|
||||
its `zone` so density — and later faction strength, hazard, and trade
|
||||
value — is per-system, not global.
|
||||
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's
|
||||
where **factions and pirates** will plug in later (claim, flag,
|
||||
relations). Deliberately absent for now — no factions yet.
|
||||
|
|
@ -1265,9 +1279,15 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
capacities; upgrades (ship-category builds) will layer deltas on top
|
||||
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
|
||||
(ship-category builds) from one place
|
||||
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
|
||||
clustering by type, borders, adjacency affinity) — hook marked in
|
||||
`Galaxy._generate()`
|
||||
- [x] Galaxy regions: wide 2:1 field (plate-shaped) + home→far `d` coordinate +
|
||||
near/middle/far zones + per-zone type mix (data/galaxy.json →
|
||||
distribution.zones/zoneMix) — the region layer is live
|
||||
- [ ] Factions: Voronoi territories around seeded capitals → each
|
||||
system's `owner` (the reserved seam in reputation + the galaxy
|
||||
plate), faction strength and hazard keyed off `zone`/`d`; the
|
||||
middle zone (where the faction borders cross) = the contested space
|
||||
- [ ] Trade: trade hubs on every planet/station, goods priced by zone +
|
||||
jump distance (hops already ≈ map distance)
|
||||
- [ ] World model in play: the ship still flies unbounded open space;
|
||||
wire in current-system boundaries, jumps between systems (use
|
||||
`galaxy.neighborsOf`), and a star map scene
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ import { generateSystemContent, rollSystemComposition, settlementDensity } from
|
|||
|
||||
const TAU = Math.PI * 2;
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
const wrapPI = (a) => {
|
||||
const t = (a + Math.PI) % TAU;
|
||||
return (t < 0 ? t + TAU : t) - Math.PI;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Galaxy — an immense, procedurally generated collection of star
|
||||
|
|
@ -20,8 +16,8 @@ const wrapPI = (a) => {
|
|||
* Two-level generation (the "grand scale" design):
|
||||
*
|
||||
* 1. GALAXY ROSTER — generated once, up front, when New Game is pressed:
|
||||
* `systemCount` lightweight records ({ id, name, type, x, y }).
|
||||
* This is cheap: the 60-system default is a few ms and a few hundred KB.
|
||||
* `systemCount` lightweight records ({ id, name, type, x, y, d, zone }).
|
||||
* This is cheap: the 90-system default is a few ms and a few hundred KB.
|
||||
* It fixes the shape of the galaxy, where every system sits, and
|
||||
* what KIND each one is — for the entire galaxy, from the seed alone.
|
||||
*
|
||||
|
|
@ -32,16 +28,37 @@ const wrapPI = (a) => {
|
|||
* one — so this is a pure performance choice, never a correctness one.
|
||||
* `generateAll()` exists for exactly that, if it's ever "just as easy".
|
||||
*
|
||||
* The SHAPE (data/galaxy.json):
|
||||
* - a seeded WIDE (2:1) field of stars (layout.field.width × height,
|
||||
* center at the world origin) placed by Bridson Poisson-disk
|
||||
* sampling — an even, organic field: every pair of systems stays at
|
||||
* least minSpacing·√(width·height/N) apart, no clumps, no voids, not
|
||||
* a grid. The 2:1 aspect matches the map plate (js/ui/MapWindow.js at
|
||||
* the 1280×720 design size), so the fully-zoomed-out galaxy fills
|
||||
* the plate instead of letterboxing;
|
||||
* - the player's HOME system sits in the star nearest the configured
|
||||
* corner (startingSystem: policy "corner", corner NE/NW/SE/SW,
|
||||
* screen orientation — SE = lower right); "center" / "random" still
|
||||
* work; the HOME→FAR diagonal is the galaxy's progression axis:
|
||||
* each record carries `d` (0 at the home corner, 1 at the opposite
|
||||
* corner) and its `zone` (near/middle/far, distribution.zones);
|
||||
* - types mix PER ZONE (distribution.zoneMix × the type's global
|
||||
* distribution.weight — the old per-type radiusBand is gone: type
|
||||
* flavor is regional now), and free-space settlement density thins
|
||||
* home→far (settlements.gradient, keyed on `d`).
|
||||
*
|
||||
* Determinism contract:
|
||||
* same seed ⇒ same roster (positions, types, names), same contents,
|
||||
* in any order of generation. Dev/test tools rely on this.
|
||||
*
|
||||
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
|
||||
* - data/galaxy.json `distribution.rules[]` — proximity/clustering rules
|
||||
* (e.g. "void systems cluster in the outer rim", faction borders).
|
||||
* Read in _generate(); today only per-type weight + radiusBand
|
||||
* (from data/systems.json) apply.
|
||||
* - more layout knobs in data/galaxy.json `layout`.
|
||||
* - FACTIONS: assign each system a faction id (or null = wild space)
|
||||
* at roster time — the record is the seam (reputation's `owner`
|
||||
* resolution and the galaxy plate's `faction: null` are reserved).
|
||||
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
|
||||
* and `record.zone` (the middle zone is where the zone borders cross
|
||||
* — the planned contested space + transit trade hubs).
|
||||
* - more layout knobs in data/galaxy.json `layout.field`.
|
||||
*/
|
||||
export class Galaxy {
|
||||
constructor(seed, params, typeDefs) {
|
||||
|
|
@ -94,79 +111,98 @@ export class Galaxy {
|
|||
// ------------------------------------------------------------------
|
||||
|
||||
_generate(count) {
|
||||
const g = Rng.derive(this.seed, 'layout');
|
||||
const L = this.params.layout ?? {};
|
||||
const R = Math.max(1, this.params.radius ?? 20000);
|
||||
const flatten = clamp(L.flatten ?? 0.62, 0.05, 1);
|
||||
const F = L.field ?? {};
|
||||
const W = Math.max(4, Math.floor(Number(F.width) || 32000));
|
||||
const H = Math.max(4, Math.floor(Number(F.height) || 16000));
|
||||
this.fieldW = W; // the field's extent (world px, center at origin)
|
||||
this.fieldH = H;
|
||||
const halfW = W / 2;
|
||||
const halfH = H / 2;
|
||||
const minSpacing = clamp(Number(F.minSpacing) || 0.8, 0.4, 1.2);
|
||||
|
||||
// Spiral arms (optional; `enabled: false` or strength 0 = off).
|
||||
const spiral = L.spiral ?? {};
|
||||
const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0;
|
||||
const twist = spiral.twist ?? 2.5;
|
||||
const strength = clamp(spiral.strength ?? 0.5, 0, 1);
|
||||
const armPhase = g.next() * TAU; // one phase for the whole galaxy
|
||||
// The HOME CORNER (data/galaxy.json → startingSystem.corner; screen
|
||||
// orientation — y down, so SE = lower right). Two things key off it:
|
||||
// the starting-system policy (home = the star NEAREST this corner),
|
||||
// and the difficulty diagonal (d = 0 here, d = 1 at the opposite
|
||||
// corner — the galaxy's progression axis, whatever policy picks the
|
||||
// starting system).
|
||||
const start = this.params.startingSystem ?? {};
|
||||
const corner = this._cornerPoint(start.corner ?? 'SE', { halfW, halfH });
|
||||
const opp = { x: -corner.x, y: -corner.y };
|
||||
const diag2 = (corner.x - opp.x) ** 2 + (corner.y - opp.y) ** 2; // W² + H²
|
||||
const dOf = (x, y) => clamp(
|
||||
((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2,
|
||||
0, 1,
|
||||
);
|
||||
|
||||
// Type selection: weights from data/systems.json (distribution.weight).
|
||||
// FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in
|
||||
// here, before position sampling.
|
||||
// ZONES — slices of the home→far diagonal (distribution.zones), and
|
||||
// the PER-ZONE type mix (distribution.zoneMix × each type's global
|
||||
// distribution.weight). Regional flavor replaces the old per-type
|
||||
// radiusBand: which KINDS of stars favor which region of the galaxy.
|
||||
const zones = this._zones();
|
||||
const zoneOf = (d) => (zones.find((z) => d >= z.d[0] && d < z.d[1]) ?? zones[zones.length - 1]).name;
|
||||
const mix = this.params.distribution?.zoneMix ?? {};
|
||||
const typeIds = Object.keys(this.typeDefs);
|
||||
const typeWeights = {};
|
||||
for (const id of typeIds) {
|
||||
typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1);
|
||||
}
|
||||
const weightsFor = (zone) => {
|
||||
const m = mix[zone] ?? {};
|
||||
const w = {};
|
||||
for (const id of typeIds) {
|
||||
const mul = m[id] === undefined ? 1 : Math.max(0, Number(m[id]) || 0);
|
||||
w[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1) * mul;
|
||||
}
|
||||
return w;
|
||||
};
|
||||
|
||||
const g = Rng.derive(this.seed, 'layout');
|
||||
|
||||
// EVEN PLACEMENT — a Bridson Poisson-disk (blue-noise) field: every
|
||||
// pair of systems stays at least dmin apart (no clumps, no voids)
|
||||
// while the field stays organic (not a grid). dmin is a fraction of
|
||||
// the mean inter-star spacing √(width·height/N)
|
||||
// (layout.field.minSpacing).
|
||||
// Even spacing is what keeps the jump network's hop counts
|
||||
// proportional to map distance — the property the trade economy
|
||||
// leans on ("a hop is a hop").
|
||||
const dmin = minSpacing * Math.sqrt((W * H) / Math.max(1, count));
|
||||
const points = this._poissonDisk(count, { halfW, halfH }, dmin);
|
||||
|
||||
let centerId = null;
|
||||
let centerD2 = Infinity;
|
||||
const records = this.records;
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const id = `S${String(i).padStart(6, '0')}`;
|
||||
const type = g.weighted(typeWeights, typeIds[0]);
|
||||
|
||||
// Radius: a center-weighted shape sample, re-anchored into the
|
||||
// type's radial band (the first "proximity" rule: e.g. void systems
|
||||
// live out in the rim, habitable ones in the mid-galaxy).
|
||||
const shape = this._sampleShape(g, L); // [0,1], dense toward center
|
||||
const band = this.typeDefs[type]?.distribution?.radiusBand;
|
||||
const rNorm = Array.isArray(band) && band.length === 2
|
||||
? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1)
|
||||
: shape;
|
||||
|
||||
let theta = g.next() * TAU;
|
||||
if (arms >= 2) {
|
||||
theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength);
|
||||
}
|
||||
|
||||
const r = R * rNorm;
|
||||
const x = r * Math.cos(theta);
|
||||
const y = r * Math.sin(theta) * flatten;
|
||||
const d2 = x * x + y * y;
|
||||
if (d2 < centerD2) {
|
||||
centerD2 = d2;
|
||||
centerId = id;
|
||||
}
|
||||
|
||||
const p = points[i - 1] ?? { x: g.range(-halfW, halfW), y: g.range(-halfH, halfH) };
|
||||
const d = dOf(p.x, p.y);
|
||||
const zone = zoneOf(d);
|
||||
const type = g.weighted(weightsFor(zone), typeIds[0]);
|
||||
// Name comes from a per-record fork so roster generation order can
|
||||
// never leak into it. rNorm (0 = galactic center, 1 = rim) is kept on
|
||||
// the record: the settlement generator uses it (core→rim density),
|
||||
// and it's handy for any future "where am I in the galaxy" rules.
|
||||
// never leak into it. d (0 = home corner, 1 = far corner) and zone
|
||||
// stay on the record: the settlement generator uses d (home→far
|
||||
// density), and factions/trade/combat will read difficulty off both.
|
||||
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
|
||||
const rec = { id, name, type, x, y, rNorm };
|
||||
const rec = { id, name, type, x: p.x, y: p.y, d, zone };
|
||||
records.push(rec);
|
||||
this.byId.set(id, rec);
|
||||
}
|
||||
|
||||
// Starting system (the player's home port).
|
||||
const policy = this.params.startingSystem?.policy ?? 'center';
|
||||
const policy = start.policy ?? 'corner';
|
||||
const closestTo = (px, py) =>
|
||||
records
|
||||
.slice()
|
||||
.sort((a, b) => (a.x - px) ** 2 + (a.y - py) ** 2 - ((b.x - px) ** 2 + (b.y - py) ** 2))[0]?.id ?? records[0]?.id;
|
||||
this.currentSystemId =
|
||||
policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id);
|
||||
policy === 'random'
|
||||
? `S${String(g.int(1, count)).padStart(6, '0')}`
|
||||
: policy === 'center'
|
||||
? closestTo(0, 0)
|
||||
: closestTo(corner.x, corner.y); // 'corner' (default)
|
||||
// Frozen copy of the starting system — `currentSystemId` will track
|
||||
// the player from here on (jumps, saves); the home rules must not.
|
||||
this.homeSystemId = this.currentSystemId;
|
||||
|
||||
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
|
||||
// the eventual star map).
|
||||
const area = Math.PI * R * R * flatten;
|
||||
const area = W * H;
|
||||
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
|
||||
this.grid = new Map();
|
||||
for (const rec of records) {
|
||||
|
|
@ -256,32 +292,120 @@ export class Galaxy {
|
|||
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
|
||||
}
|
||||
|
||||
/** Center-weighted radius sample in [0,1]: core bulge + disk. */
|
||||
_sampleShape(g, L) {
|
||||
if (g.chance(L.coreFraction ?? 0.25)) {
|
||||
const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09);
|
||||
return Math.min(1, Math.abs(g.normal(0, sigma)));
|
||||
}
|
||||
return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7)));
|
||||
/**
|
||||
* The home→far zones (data/galaxy.json → distribution.zones),
|
||||
* validated and sorted by `d`. A single fallback zone when the config
|
||||
* is missing/malformed — the galaxy still generates.
|
||||
*/
|
||||
_zones() {
|
||||
const raw = this.params.distribution?.zones;
|
||||
const out = (Array.isArray(raw) ? raw : [])
|
||||
.filter(
|
||||
(z) =>
|
||||
z &&
|
||||
typeof z.name === 'string' &&
|
||||
Array.isArray(z.d) &&
|
||||
z.d.length === 2 &&
|
||||
Number.isFinite(z.d[0]) &&
|
||||
Number.isFinite(z.d[1]) &&
|
||||
z.d[1] > z.d[0],
|
||||
)
|
||||
.map((z) => ({ name: z.name, d: [clamp(z.d[0], 0, 1), clamp(z.d[1], 0, 1)] }))
|
||||
.sort((a, b) => a.d[0] - b.d[0]);
|
||||
return out.length ? out : [{ name: 'all', d: [0, 1] }];
|
||||
}
|
||||
|
||||
/** Ease `theta` toward the nearest spiral arm (by `strength`). */
|
||||
_snapToArm(theta, rNorm, armPhase, arms, twist, strength) {
|
||||
if (strength <= 0) return theta;
|
||||
const step = TAU / arms;
|
||||
const base = armPhase + twist * rNorm;
|
||||
const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step);
|
||||
let bestD = Infinity;
|
||||
let bestA = base + k * step;
|
||||
for (const cand of [k - 1, k, k + 1]) {
|
||||
const a = base + cand * step;
|
||||
const d = Math.abs(wrapPI(a - theta));
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestA = a;
|
||||
}
|
||||
/** A corner of the field (screen orientation — y DOWN). */
|
||||
_cornerPoint(name, { halfW, halfH }) {
|
||||
const CORNERS = { NE: [1, -1], NW: [-1, -1], SE: [1, 1], SW: [-1, 1] };
|
||||
const key = String(name ?? 'SE').toUpperCase();
|
||||
const [sx, sy] = CORNERS[key] ?? CORNERS.SE;
|
||||
return { x: sx * halfW, y: sy * halfH };
|
||||
}
|
||||
|
||||
/**
|
||||
* Even star placement: exactly `n` points in the field
|
||||
* [−halfW, halfW] × [−halfH, halfH], every pair at least `d0` apart
|
||||
* (Bridson / Poisson disk). If the field can't hold `n` points at
|
||||
* `d0` (dense config), retry a few times with a relaxed spacing; as a
|
||||
* last resort pad with random points — a working galaxy beats a
|
||||
* perfect one. Deterministic: each attempt draws from its own seeded
|
||||
* fork (seed, 'layout', 'poisson', attempt).
|
||||
*/
|
||||
_poissonDisk(n, { halfW, halfH }, d0) {
|
||||
if (n <= 0) return [];
|
||||
let d = Math.max(1, d0);
|
||||
let pts = null;
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const rng = Rng.derive(this.seed, 'layout', 'poisson', attempt);
|
||||
pts = this._bridson(n, { halfW, halfH }, d, rng);
|
||||
if (pts.length >= n) break;
|
||||
d *= 0.88; // not enough room — loosen the spacing and retry
|
||||
}
|
||||
return theta + wrapPI(bestA - theta) * strength;
|
||||
const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad');
|
||||
while (pts.length < n) {
|
||||
pts.push({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
|
||||
}
|
||||
return pts.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridson's algorithm: grow a Poisson-disk of points in the field,
|
||||
* stopping once `n` points are placed (or the frontier is exhausted).
|
||||
* Pure — deterministic for a given (n, halfW, halfH, d, rng stream).
|
||||
*/
|
||||
_bridson(n, { halfW, halfH }, d, rng) {
|
||||
if (n <= 0) return [];
|
||||
const cell = d / Math.SQRT2;
|
||||
const grid = new Map(); // "cx,cy" → [point, …]
|
||||
const pts = [];
|
||||
const active = []; // indices into pts (Bridson's active list)
|
||||
const inside = (p) =>
|
||||
p.x >= -halfW && p.x <= halfW && p.y >= -halfH && p.y <= halfH;
|
||||
const keyOf = (p) =>
|
||||
`${Math.floor((p.x + halfW) / cell)},${Math.floor((p.y + halfH) / cell)}`;
|
||||
const free = (p) => {
|
||||
const cx = Math.floor((p.x + halfW) / cell);
|
||||
const cy = Math.floor((p.y + halfH) / cell);
|
||||
for (let ax = -2; ax <= 2; ax++) {
|
||||
for (let ay = -2; ay <= 2; ay++) {
|
||||
const bucket = grid.get(`${cx + ax},${cy + ay}`);
|
||||
if (!bucket) continue;
|
||||
for (const q of bucket) {
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
if (dx * dx + dy * dy < d * d) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const place = (p) => {
|
||||
pts.push(p);
|
||||
active.push(pts.length - 1);
|
||||
const k = keyOf(p);
|
||||
const bucket = grid.get(k);
|
||||
if (bucket) bucket.push(p);
|
||||
else grid.set(k, [p]);
|
||||
};
|
||||
// Seed the frontier with one random interior point.
|
||||
place({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
|
||||
while (active.length > 0 && pts.length < n) {
|
||||
const i = rng.int(0, active.length - 1);
|
||||
const p = pts[active[i]];
|
||||
let placed = false;
|
||||
for (let t = 0; t < 30 && !placed; t++) {
|
||||
// Sample a candidate in the annulus [d, 2d) around p.
|
||||
const r = d * (1 + rng.next());
|
||||
const a = rng.next() * TAU;
|
||||
const q = { x: p.x + r * Math.cos(a), y: p.y + r * Math.sin(a) };
|
||||
if (!inside(q) || !free(q)) continue;
|
||||
place(q);
|
||||
placed = true;
|
||||
}
|
||||
if (!placed) active.splice(i, 1); // p can never yield a neighbor
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -303,7 +427,7 @@ export class Galaxy {
|
|||
best.sort((a, b) => a.d2 - b.d2);
|
||||
if (best.length > k) best.length = k;
|
||||
};
|
||||
const maxRing = Math.min(1024, Math.ceil((4 * this.params.radius) / c) + 1);
|
||||
const maxRing = Math.min(1024, Math.ceil(Math.hypot(this.fieldW ?? 32000, this.fieldH ?? 16000) / c) + 1);
|
||||
for (let ring = 0; ring <= maxRing; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
|
|
@ -403,11 +527,14 @@ export class Galaxy {
|
|||
summary() {
|
||||
const byType = {};
|
||||
for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1;
|
||||
const byZone = {};
|
||||
for (const r of this.records) byZone[r.zone] = (byZone[r.zone] ?? 0) + 1;
|
||||
return {
|
||||
name: this.name,
|
||||
seed: this.seed,
|
||||
systems: this.records.length,
|
||||
byType,
|
||||
byZone,
|
||||
generated: this.generatedCount,
|
||||
currentSystemId: this.currentSystemId,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,11 +27,14 @@
|
|||
* Construction:
|
||||
* 1. The undirected neighbor graph (u~v iff v ∈ nn(u) or u ∈ nn(v)).
|
||||
* 2. A spanning tree of it with every node's degree ≤ maxGates, grown
|
||||
* BFS-outward from the home system with a "keep the frontier open"
|
||||
* child heuristic: attach the unvisited neighbors with the MOST
|
||||
* unvisited neighbors first, so rim clusters are absorbed before
|
||||
* their degree budget is spent. A BARREN node never adopts children
|
||||
* (it keeps its single gate — the maze's dead end).
|
||||
* BFS-outward from the home system with a "save the stranded first"
|
||||
* child heuristic: attach the unvisited neighbor with the FEWEST
|
||||
* unvisited non-barren neighbors (its other possible adopters)
|
||||
* first — a star nobody else can adopt must not wait for a budget
|
||||
* slot. Ties keep the "keep the frontier open" order (most unvisited
|
||||
* neighbors first — they grow the tree for others). A BARREN node
|
||||
* never adopts children (it keeps its single gate — the maze's
|
||||
* dead end).
|
||||
* 3. Tree edges run BOTH ways. A bidirected tree is strongly connected
|
||||
* by construction (the unique tree path between any two systems can
|
||||
* be walked in either direction), and every node's gate count is its
|
||||
|
|
@ -46,8 +49,10 @@
|
|||
*
|
||||
* The repair pass (below) is defensive: it fires only if the neighbor
|
||||
* graph is disconnected (effectively impossible at this scale), and it
|
||||
* still respects the degree budget (preferring non-barren attach targets
|
||||
* so a dead end stays a leaf when it can).
|
||||
* NEVER breaks the invariants: it only ever attaches to NON-BARREN nodes
|
||||
* (a dead end keeps its single gate — even in repair), and its swap
|
||||
* option re-homes a child onto a node outside the child's own subtree
|
||||
* (the tree stays a tree).
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -133,14 +138,23 @@ export function buildJumpNetwork({
|
|||
if (budget <= 0) continue;
|
||||
const cands = nbr[u].filter((v) => !visited[v]);
|
||||
if (cands.length === 0) continue;
|
||||
// "Keep the frontier open": candidates with the most unvisited
|
||||
// neighbors grow the tree for others; ties by index (determinism).
|
||||
// "Save the stranded first": attach the candidate with the FEWEST
|
||||
// unvisited non-barren neighbors — those are the stars no one else
|
||||
// can adopt (barren leaves never adopt; claimed nodes can't), and
|
||||
// letting them wait is how pockets strand. Ties keep the old
|
||||
// "keep the frontier open" order (most unvisited neighbors first —
|
||||
// they grow the tree for others), then index (determinism).
|
||||
const scored = cands.map((v) => {
|
||||
let risk = 0; // unvisited NON-BARREN neighbors of v (its other adopters)
|
||||
let unv = 0;
|
||||
for (const w of nbr[v]) if (!visited[w]) unv++;
|
||||
return { v, unv };
|
||||
for (const w of nbr[v]) {
|
||||
if (visited[w]) continue;
|
||||
unv++;
|
||||
if (!isBarren(w)) risk++;
|
||||
}
|
||||
return { v, risk, unv };
|
||||
});
|
||||
scored.sort((a, b) => b.unv - a.unv || a.v - b.v);
|
||||
scored.sort((a, b) => a.risk - b.risk || b.unv - a.unv || a.v - b.v);
|
||||
for (const { v } of scored.slice(0, budget)) {
|
||||
visited[v] = 1;
|
||||
parent[v] = u;
|
||||
|
|
@ -153,30 +167,44 @@ export function buildJumpNetwork({
|
|||
// --- Repair (defensive): attach anything the tree left behind ---------
|
||||
// A leftover node has no visited neighbor with spare degree (its whole
|
||||
// neighborhood sat in a disconnected pocket). Repair, in order of
|
||||
// preference (all deterministic — index order, strict comparisons):
|
||||
// 1. Attach to a visited neighbor with spare degree (local).
|
||||
// preference (all deterministic — index order, strict comparisons). A
|
||||
// barren node is NEVER an attach target — a dead end keeps its single
|
||||
// gate, even in repair:
|
||||
// 1. Attach to a visited NON-BARREN neighbor with spare degree (local).
|
||||
// 2. SWAP: take one of a visited node u's tree children x, re-home x
|
||||
// onto one of x's OWN visited neighbors that has spare degree, and
|
||||
// use the freed budget for w. The tree stays a tree; locality is
|
||||
// onto one of x's OWN visited NON-BARREN neighbors that has spare
|
||||
// degree (and sits outside x's own subtree — no cycles), and use
|
||||
// the freed budget for w. The tree stays a tree; locality is
|
||||
// preserved (x stays inside its own neighborhood).
|
||||
// 3. Last resort (should never fire on a kNN graph): attach w to the
|
||||
// nearest visited node and accept one over-budget degree — a working
|
||||
// network beats a broken one.
|
||||
// nearest visited NON-BARREN node and accept one over-budget degree
|
||||
// — a working network beats a broken one.
|
||||
const dist2 = (a, b) => {
|
||||
const dx = records[a].x - records[b].x;
|
||||
const dy = records[a].y - records[b].y;
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
let repaired = 0;
|
||||
// Is `a` inside `anc`'s subtree (a ≠ anc)? — the swap must not re-home
|
||||
// a child onto one of its own descendants (that would close a cycle).
|
||||
const inSubtree = (a, anc) => {
|
||||
let p = parent[a];
|
||||
while (p >= 0) {
|
||||
if (p === anc) return true;
|
||||
p = parent[p];
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let w = 0; w < n; w++) {
|
||||
if (visited[w]) continue;
|
||||
// (1) local attach — prefer a non-barren target (a dead end should
|
||||
// keep its single gate when the graph lets us).
|
||||
// (1) local attach — a barren neighbor is NEVER a target: a dead end
|
||||
// keeps its single gate, even in repair.
|
||||
let u = -1;
|
||||
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates && !isBarren(c)) { u = c; break; }
|
||||
if (u === -1) for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { u = c; break; }
|
||||
if (u === -1) {
|
||||
// (2) swap: best (u, x) pair by dist(w, u), then indices
|
||||
// (2) swap: best (u, x) pair by dist(w, u), then indices. The new
|
||||
// parent (bestNew) must have spare degree, be non-barren, and sit
|
||||
// outside x's subtree.
|
||||
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!visited[c] || c === w || deg[c] < 2) continue; // needs a child to free
|
||||
|
|
@ -186,7 +214,9 @@ export function buildJumpNetwork({
|
|||
for (let x = 0; x < n; x++) {
|
||||
if (parent[x] !== c) continue;
|
||||
for (const nn of nbr[x]) {
|
||||
if (nn === c || nn === w || !visited[nn] || deg[nn] >= maxGates) continue;
|
||||
if (nn === c || nn === w || nn === x) continue;
|
||||
if (!visited[nn] || isBarren(nn) || deg[nn] >= maxGates) continue;
|
||||
if (inSubtree(nn, x)) continue; // re-homing here would close a cycle
|
||||
if (d < bestD || (d === bestD && (c < bestU || (c === bestU && x < bestX)))) {
|
||||
bestD = d; bestU = c; bestX = x; bestNew = nn;
|
||||
}
|
||||
|
|
@ -201,18 +231,26 @@ export function buildJumpNetwork({
|
|||
}
|
||||
}
|
||||
if (u === -1) {
|
||||
// (3) nearest visited node, budget be damned (prefer non-barren)
|
||||
// (3) nearest visited NON-BARREN node, budget be damned
|
||||
let bestD = Infinity;
|
||||
for (const pass of [1, 0]) {
|
||||
let found = -1;
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!visited[c] || c === w || isBarren(c)) continue;
|
||||
const d = dist2(w, c);
|
||||
if (d < bestD) { bestD = d; found = c; }
|
||||
}
|
||||
if (found === -1) {
|
||||
// (4) truly nothing else (effectively unreachable on a kNN graph):
|
||||
// strong connectivity beats the leaf invariant — take ANY visited
|
||||
// node, budget be damned.
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!visited[c] || c === w) continue;
|
||||
if (Boolean(isBarren(c)) !== (pass === 1)) continue;
|
||||
const d = dist2(w, c);
|
||||
if (d < bestD) { bestD = d; u = c; }
|
||||
if (d < bestD) { bestD = d; found = c; }
|
||||
}
|
||||
if (u !== -1) break;
|
||||
}
|
||||
if (u === -1) continue; // nothing to attach to (n === 1 handled above)
|
||||
if (found === -1) continue; // nothing to attach to (n === 1 handled above)
|
||||
u = found;
|
||||
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);
|
||||
}
|
||||
visited[w] = 1;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,39 @@ export function fitToRect(bounds, w = 100, h = 100) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* fitPadded(bounds, w, h, padPx) — fit `bounds` (the chartBounds() shape)
|
||||
* into a w×h plate with `padPx` of margin on ALL four sides, measured in
|
||||
* PLATE pixels (not world units — world padding is meaningless for a
|
||||
* 32k-wide galaxy field): the scale that leaves exactly padPx at each
|
||||
* edge is s = min((w−2p)/bw, (h−2p)/bh) — a TRUE rectangle fit (fitToRect
|
||||
* inscribes the bounds' bounding square instead, so it only fills a plate
|
||||
* whose aspect matches a square's). Returns { scale, bounds } where
|
||||
* bounds is the ORIGINAL box inflated by padPx/scale in world units —
|
||||
* feed THAT inflated box to the pan/zoom clamp so the drawn content
|
||||
* never touches the plate edge, even at the clamped extremes.
|
||||
*/
|
||||
export function fitPadded(bounds, w = 100, h = 100, padPx = 0) {
|
||||
const bw = Math.max(1e-6, (bounds?.maxX ?? 0) - (bounds?.minX ?? 0));
|
||||
const bh = Math.max(1e-6, (bounds?.maxY ?? 0) - (bounds?.minY ?? 0));
|
||||
const p = Math.max(0, Number(padPx) || 0);
|
||||
const scale = Math.min((w - 2 * p) / bw, (h - 2 * p) / bh);
|
||||
const padW = p / scale;
|
||||
return {
|
||||
scale,
|
||||
bounds: {
|
||||
minX: bounds.minX - padW,
|
||||
minY: bounds.minY - padW,
|
||||
maxX: bounds.maxX + padW,
|
||||
maxY: bounds.maxY + padW,
|
||||
cx: bounds.cx,
|
||||
cy: bounds.cy,
|
||||
w: bw + 2 * padW,
|
||||
h: bh + 2 * padW,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The SYSTEM DISCOVERY share (data/map.json → stats): the player's found /
|
||||
* total over the system's NAV objects — planets, free-space stations and
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const TAU = Math.PI * 2;
|
|||
const DEG = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a
|
||||
* Turns a lightweight galaxy record (id, name, type, x, y, d, zone) into a
|
||||
* fully generated system: star, planets, moons, SETTLEMENTS, debris belt,
|
||||
* hazard flag.
|
||||
*
|
||||
|
|
@ -28,9 +28,9 @@ const DEG = Math.PI / 180;
|
|||
* 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
|
||||
* Stations roll first (per-type odds × the home→far 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
|
||||
* ranges live in data/settlements.json; the home→far density gradient in
|
||||
* data/galaxy.json (`settlements.gradient`).
|
||||
*
|
||||
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
|
||||
|
|
@ -150,7 +150,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
// 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
|
||||
// 15/30/30/15%). Stations roll next (per-type odds × the home→far
|
||||
// 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.
|
||||
|
|
@ -1057,7 +1057,7 @@ function mixTint(hex, strength) {
|
|||
|
||||
/**
|
||||
* The system's OBJECT COMPOSITION (data/systems.json → objectCount +
|
||||
* attributes.settlements + the core→rim density gradient —
|
||||
* attributes.settlements + the home→far density gradient —
|
||||
* data/galaxy.json → settlements.gradient), in one deterministic roll:
|
||||
*
|
||||
* home system → fixed: 2 planets (gas giant + rocky) beside the home
|
||||
|
|
@ -1140,9 +1140,10 @@ export function rollStationCount(seed, record, isHome, spec, density) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Home-corner → far-corner density: the settled heart (the home corner,
|
||||
* d = 0) has more free-space activity per system; the deep corner
|
||||
* (d = 1) is thinner, lonelier. `factor` scales every settlement chance
|
||||
* (clamped to a floor so the deep corner isn't dead). 0 = no gradient.
|
||||
* Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same
|
||||
* values from the same inputs.
|
||||
*/
|
||||
|
|
@ -1150,8 +1151,8 @@ 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);
|
||||
const d = clamp(record?.d ?? 0, 0, 1);
|
||||
return clamp(1 - d * falloff, floor, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1167,7 +1168,7 @@ export function settlementDensity(galaxy, record) {
|
|||
* 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).
|
||||
* free-space kinds still roll per type (home→far scaled).
|
||||
*/
|
||||
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) {
|
||||
const out = [];
|
||||
|
|
@ -1211,7 +1212,7 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
|||
}
|
||||
|
||||
// Free-floating, out in the dark — the PRE-ROLLED flags (rollStationCount:
|
||||
// per-type odds × the core→rim density gradient; the home system never
|
||||
// per-type odds × the home→far 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.
|
||||
|
|
@ -1231,7 +1232,7 @@ function settledKindFor(planet, byClass, kindDefs) {
|
|||
return kind && kindDefs[kind] ? kind : null;
|
||||
}
|
||||
|
||||
/** One deterministic roll, scaled by the core→rim density factor. */
|
||||
/** One deterministic roll, scaled by the home→far density factor. */
|
||||
function roll(rng, chance, density) {
|
||||
return rng.chance(chance * density);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ import { fontStack, themeColor } from '../utils/Theme.js';
|
|||
import { setInteractiveEnabled } from '../utils/Input.js';
|
||||
import { canvasTexture } from '../utils/Textures.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
import { chartBounds, fitToRect } from '../galaxy/SystemChart.js';
|
||||
import { chartBounds, fitPadded } from '../galaxy/SystemChart.js';
|
||||
import { paddedHullPolygon, starPulse, starTypeColor, clipLineToRect, clipPolygonToRect, starDotPx, starArtSpec } from '../galaxy/GalaxyChart.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
|
@ -334,14 +334,23 @@ export class GalaxyView {
|
|||
const first = this._snap == null;
|
||||
this._snap = snap;
|
||||
if (first) {
|
||||
// frame + fit (world → plate), like the system chart
|
||||
// frame + fit (world → plate), like the system chart — but a TRUE
|
||||
// rectangle fit with a PLATE-PIXEL margin on all four sides
|
||||
// (map.galaxy.platePadding): the field is 2:1 to match the plate, so
|
||||
// it fills the plate at 1× instead of letterboxing, and the old
|
||||
// 90-WORLD-unit padding (negligible on a 32k field — the stars sat
|
||||
// on the plate edges) is a 26-plate-px margin. The inflated bounds
|
||||
// drive the pan/zoom clamp, so the stars keep that margin even when
|
||||
// panned to the edge.
|
||||
const glowW = Math.max(10, Number(this._cfg.stars.glow ?? 30));
|
||||
const pad = Number(config.get('map.galaxy.padding', 90)) || 90;
|
||||
this._bounds = chartBounds(
|
||||
const platePad = Number(config.get('map.galaxy.platePadding', 26)) || 26;
|
||||
const b0 = chartBounds(
|
||||
snap.systems.map((s) => ({ x: s.x, y: s.y, radius: glowW / 2 })),
|
||||
pad
|
||||
0
|
||||
);
|
||||
this._fit = fitToRect(this._bounds, this.pw, this.ph);
|
||||
const fit = fitPadded(b0, this.pw, this.ph, platePad);
|
||||
this._bounds = fit.bounds;
|
||||
this._fit = { scale: fit.scale, cx: fit.bounds.cx, cy: fit.bounds.cy };
|
||||
this._view = { z: 1, cx: this._bounds.cx, cy: this._bounds.cy };
|
||||
// charted-region inflation, in WORLD units (a fraction of the plate
|
||||
// width at 1× zoom) — stable across zoom levels
|
||||
|
|
|
|||
Loading…
Reference in New Issue