Rework solar system layout to orbit rings with minSpacing/maxNeighbor ru
- Replace the annular-band scatter (minOrbit/maxOrbit/minEdgeGap) with a ring-based placement: objects sit on one or two orbits around the home world, governed by center-to-center `minSpacing` and `maxNeighbor` - Free-space stations (deep-space stations, waypoints) are now layout objects alongside planets; planet-bound settlements stay features of their planet - Handle the N=11 case (9 planets + 2 stations) with an inner 3-ring + outer 8-ring; chain outward defensively for any larger N - Update tests to verify the new spacing invariants, the two-orbit case, and determinism including station positions - Refresh PROJECT_NOTES and the data-file comment to describe the orbit model
This commit is contained in:
parent
fb9722f028
commit
48dfc7cd8f
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down layout band: the other worlds of a system sit minOrbit–maxOrbit px from the origin, at least minEdgeGap px (edge-to-edge) apart from each other and the origin.",
|
"_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down layout: the system's other objects (planets + free-space stations) sit on one or two orbits (rings) around the origin — center-to-center at least minSpacing px from every other object (incl. the origin's home world), and, whenever the system holds more than one object, every object within maxNeighbor px of at least one other object. minSpacing/maxNeighbor are center-to-center px (the same unit as the 1024 px world disc).",
|
||||||
"texture": "assets/images/planets.png",
|
"texture": "assets/images/planets.png",
|
||||||
"frameWidth": 1024,
|
"frameWidth": 1024,
|
||||||
"frameHeight": 1024,
|
"frameHeight": 1024,
|
||||||
|
|
@ -37,8 +37,7 @@
|
||||||
"spawnDistanceFromEdge": 150,
|
"spawnDistanceFromEdge": 150,
|
||||||
"solarSystem": {
|
"solarSystem": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minOrbit": 2600,
|
"minSpacing": 6144,
|
||||||
"maxOrbit": 8800,
|
"maxNeighbor": 10240
|
||||||
"minEdgeGap": 1200
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@
|
||||||
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
|
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
|
||||||
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
|
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
|
||||||
* state round-trips through toJSON/fromJSON (saves-ready);
|
* state round-trips through toJSON/fromJSON (saves-ready);
|
||||||
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every
|
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): the
|
||||||
* system's worlds sit at finite, in-band positions around the origin,
|
* system's planets + free-space stations (+ the origin's home world)
|
||||||
* at least minEdgeGap px apart edge-to-edge (incl. the origin's home
|
* sit on orbits around the origin, no pair closer than minSpacing
|
||||||
* world), scaled by class — deterministically (same seed ⇒ same layout,
|
* (center-to-center) and — whenever a system holds more than one
|
||||||
* different seed ⇒ different);
|
* object — every object within maxNeighbor of at least one other,
|
||||||
|
* planets scaled by class — deterministically (same seed ⇒ same
|
||||||
|
* layout, different seed ⇒ different);
|
||||||
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
||||||
* the screen-edge rect (edges AND corners), circleInView is exact,
|
* the screen-edge rect (edges AND corners), circleInView is exact,
|
||||||
* lerpAngle always takes the short arc;
|
* lerpAngle always takes the short arc;
|
||||||
|
|
@ -106,62 +108,94 @@ check('toJSON/fromJSON round-trips (saves-ready)', restored.distance === D && re
|
||||||
// --- The solar system layout ---------------------------------------------
|
// --- The solar system layout ---------------------------------------------
|
||||||
|
|
||||||
const band = config.get('planets.solarSystem');
|
const band = config.get('planets.solarSystem');
|
||||||
const { minOrbit, maxOrbit, minEdgeGap } = band;
|
const MIN_SEP = band.minSpacing;
|
||||||
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
|
const MAX_NBR = band.maxNeighbor;
|
||||||
|
|
||||||
const g = Galaxy.create('discovery-layout-test');
|
const g = Galaxy.create('discovery-layout-test');
|
||||||
let layoutOk = true;
|
let layoutOk = true;
|
||||||
let layoutWhy = '';
|
let layoutWhy = '';
|
||||||
|
let sawTwoOrbits = false; // the N = 11 case (9 planets + 2 stations)
|
||||||
const probe = (systems) => {
|
const probe = (systems) => {
|
||||||
for (const rec of systems) {
|
for (const rec of systems) {
|
||||||
const c = g.ensureContent(rec.id);
|
const c = g.ensureContent(rec.id);
|
||||||
const placed = [{ x: 0, y: 0, r: baseR }]; // the origin's home world
|
|
||||||
for (const p of c.planets) {
|
for (const p of c.planets) {
|
||||||
const r = baseR * p.scale;
|
|
||||||
const d0 = Math.hypot(p.x, p.y);
|
|
||||||
const want = config.get(`planets.classScale.${p.class}`, 1);
|
|
||||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
|
||||||
layoutOk = false;
|
layoutOk = false;
|
||||||
layoutWhy = `${rec.id}: non-finite layout`;
|
layoutWhy = `${rec.id}: non-finite layout`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const want = config.get(`planets.classScale.${p.class}`, 1);
|
||||||
if (Math.abs(p.scale - want) > 1e-9) {
|
if (Math.abs(p.scale - want) > 1e-9) {
|
||||||
layoutOk = false;
|
layoutOk = false;
|
||||||
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
|
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
|
||||||
}
|
}
|
||||||
if (d0 < minOrbit - 1e-6) {
|
}
|
||||||
|
// Layout objects: the home world (origin) + planets + free-space
|
||||||
|
// stations (planet-bound settlements sit ON their planet — not
|
||||||
|
// layout objects of their own).
|
||||||
|
const objs = [{ x: 0, y: 0 }];
|
||||||
|
for (const p of c.planets) objs.push({ x: p.x, y: p.y });
|
||||||
|
for (const s of c.settlements ?? []) {
|
||||||
|
if (s.anchor?.type !== 'space') continue;
|
||||||
|
if (typeof s.x !== 'number' || typeof s.y !== 'number' || !Number.isFinite(s.x) || !Number.isFinite(s.y)) {
|
||||||
layoutOk = false;
|
layoutOk = false;
|
||||||
layoutWhy = `${rec.id}: world at ${d0.toFixed(1)} px < minOrbit ${minOrbit}`;
|
layoutWhy = `${rec.id}: free-space station "${s.name}" has no valid position`;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
for (const q of placed) {
|
objs.push({ x: s.x, y: s.y });
|
||||||
const gap = Math.hypot(p.x - q.x, p.y - q.y) - q.r - r;
|
}
|
||||||
if (gap < minEdgeGap - 1e-6) {
|
if (objs.length - 1 === 11) sawTwoOrbits = true;
|
||||||
|
// 1) No two objects closer than minSpacing (center to center).
|
||||||
|
for (let i = 0; i < objs.length && layoutOk; i++) {
|
||||||
|
for (let j = i + 1; j < objs.length; j++) {
|
||||||
|
const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y);
|
||||||
|
if (d < MIN_SEP - 1e-6) {
|
||||||
layoutOk = false;
|
layoutOk = false;
|
||||||
layoutWhy = `${rec.id}: worlds ${minEdgeGap - gap}px closer than minEdgeGap ${minEdgeGap}`;
|
layoutWhy = `${rec.id}: two objects ${Math.round(MIN_SEP - d)} px closer than minSpacing ${MIN_SEP}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) Every object has a neighbor within maxNeighbor (the home world is
|
||||||
|
// always present, so "more than one object" ⇔ objs.length ≥ 2).
|
||||||
|
if (layoutOk && objs.length >= 2) {
|
||||||
|
for (let i = 0; i < objs.length; i++) {
|
||||||
|
const near = objs.some(
|
||||||
|
(o, j) => j !== i && Math.hypot(objs[i].x - o.x, objs[i].y - o.y) <= MAX_NBR + 1e-6,
|
||||||
|
);
|
||||||
|
if (!near) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: an object with no neighbor within maxNeighbor ${MAX_NBR}`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
placed.push({ x: p.x, y: p.y, r });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
probe(g.records.slice(0, 500));
|
probe(g.records.slice(0, 5000));
|
||||||
check(
|
check(
|
||||||
`layout: 500 systems' worlds finite, scaled by class, in-band (≥${minOrbit}px), ≥${minEdgeGap}px apart edge-to-edge${layoutOk ? '' : ' — ' + layoutWhy}`,
|
`layout: 5000 systems obey minSpacing (no pair <${MIN_SEP}px center-to-center) & maxNeighbor (every object ≤${MAX_NBR}px from a neighbor)${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||||
layoutOk,
|
layoutOk,
|
||||||
);
|
);
|
||||||
|
check('layout: the 11-object two-orbit case (9 planets + 2 stations) occurs in the sample', sawTwoOrbits);
|
||||||
|
|
||||||
const rec0 = g.records[0].id;
|
// Determinism on a system that has BOTH planet and station objects.
|
||||||
|
const rec0 = g.records.find((r) =>
|
||||||
|
g.ensureContent(r.id).settlements.some((s) => s.anchor?.type === 'space'),
|
||||||
|
).id;
|
||||||
const la = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
const la = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
||||||
const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0);
|
||||||
|
const stationXY = (c) => (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => [s.x, s.y]);
|
||||||
const same =
|
const same =
|
||||||
la.planets.length === lb.planets.length &&
|
la.planets.length === lb.planets.length &&
|
||||||
la.planets.every((p, i) => p.x === lb.planets[i].x && p.y === lb.planets[i].y && p.scale === lb.planets[i].scale);
|
la.planets.every((p, i) => p.x === lb.planets[i].x && p.y === lb.planets[i].y && p.scale === lb.planets[i].scale) &&
|
||||||
check('layout is deterministic (same seed ⇒ same x/y/scale)', same);
|
JSON.stringify(stationXY(la)) === JSON.stringify(stationXY(lb));
|
||||||
|
check('layout is deterministic (same seed ⇒ same planet + station x/y/scale)', same);
|
||||||
|
|
||||||
const lc = Galaxy.create('discovery-layout-OTHER').ensureContent(rec0);
|
const lc = Galaxy.create('discovery-layout-OTHER').ensureContent(rec0);
|
||||||
const diff =
|
const diff =
|
||||||
lc.planets.length !== la.planets.length ||
|
lc.planets.length !== la.planets.length ||
|
||||||
la.planets.some((p, i) => p.x !== lc.planets[i]?.x || p.y !== lc.planets[i]?.y);
|
la.planets.some((p, i) => p.x !== lc.planets[i]?.x || p.y !== lc.planets[i]?.y) ||
|
||||||
|
JSON.stringify(stationXY(lc)) !== JSON.stringify(stationXY(la));
|
||||||
check('different seed ⇒ different layout', diff);
|
check('different seed ⇒ different layout', diff);
|
||||||
|
|
||||||
// --- The compass geometry ---------------------------------------------------
|
// --- The compass geometry ---------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -171,15 +171,22 @@ unclaimed". Model & seams:
|
||||||
The player starts in a solar system, and the other worlds **exist in the
|
The player starts in a solar system, and the other worlds **exist in the
|
||||||
world** — solid, rendered, flyable-to. Rules and seams:
|
world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
|
|
||||||
- **World layout** — `SystemGenerator.layoutSystemPlanets(seed, systemId,
|
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
|
||||||
planets)` (pure, `js/galaxy/SystemGenerator.js`) places each generated
|
freeSpace)` (pure, `js/galaxy/SystemGenerator.js`) places each system's
|
||||||
world in an annular band around the home world (origin), enforcing
|
planets and free-space stations (deep-space stations, waypoints) on one
|
||||||
edge-to-edge separation from every other disc. Draws come from the
|
or two orbits (rings) around the home world (origin), enforcing the hard
|
||||||
dedicated fork `Rng.derive(seed, 'system', id, 'layout')` — layout
|
spacing rules in `data/planets.json → solarSystem`: `minSpacing` (6144 px,
|
||||||
results are seed-deterministic *and* don't perturb the content stream
|
center-to-center, between ANY two objects — planets, stations, the home
|
||||||
(lazy === eager is preserved). Band params live in
|
world) and `maxNeighbor` (10240 px — whenever a system holds more than
|
||||||
`data/planets.json → solarSystem` (enabled, minOrbit, maxOrbit,
|
one object, every object is within it of at least one other; the inner
|
||||||
minEdgeGap); class sizes/tints in `classScale`/`classTint`.
|
orbit's near neighbor is the home world at the origin). ≤ 10 objects
|
||||||
|
share one orbit; the 11-object maximum (9 planets + 2 stations) splits
|
||||||
|
into a 3-ring + 8-ring. Planet-bound settlements (colonies, mining
|
||||||
|
stations, cloud bases) are features of their planet, not layout objects.
|
||||||
|
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
|
||||||
|
'layout')` — layout results are seed-deterministic *and* don't perturb
|
||||||
|
the content stream (lazy === eager is preserved). Class sizes/tints in
|
||||||
|
`classScale`/`classTint`.
|
||||||
- **Discovery** — `js/galaxy/Discovery.js` (pure, no Phaser — Node-
|
- **Discovery** — `js/galaxy/Discovery.js` (pure, no Phaser — Node-
|
||||||
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
|
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
|
||||||
`game.discovery.distance` (data/game.json, default 540 px) of an
|
`game.discovery.distance` (data/game.json, default 540 px) of an
|
||||||
|
|
@ -241,9 +248,11 @@ The ship starts with **one level-1 tether** anchored on the home world
|
||||||
visual/feel parameter.
|
visual/feel parameter.
|
||||||
|
|
||||||
**Upgrade economics (planned):** radius(level) = 5120 × 1.25^(level−1) —
|
**Upgrade economics (planned):** radius(level) = 5120 × 1.25^(level−1) —
|
||||||
level 4 = 10000 px, which reaches the far system orbit (maxOrbit 8800)
|
level 4 = 10000 px, which reaches every object of a ≤ 10-object system
|
||||||
plus rim clearance. Extra tethers anchored on planets/stations will be the
|
(all within 10240 px of the home world); the far 8-ring of the rare
|
||||||
"expand your reach" verb; the seams are in place.
|
11-object system (up to ~13380 px out) wants one level more. Extra
|
||||||
|
tethers anchored on planets/stations will be the "expand your reach" verb;
|
||||||
|
the seams are in place.
|
||||||
|
|
||||||
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
|
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
|
||||||
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,
|
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,6 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
layoutSystemPlanets(galaxy.seed, record.id, planets);
|
|
||||||
|
|
||||||
// --- Settlements (the lived-in layer) ---------------------------------
|
// --- Settlements (the lived-in layer) ---------------------------------
|
||||||
const settlements = generateSettlements({
|
const settlements = generateSettlements({
|
||||||
rng,
|
rng,
|
||||||
|
|
@ -104,6 +102,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
density: settlementDensity(galaxy, record),
|
density: settlementDensity(galaxy, record),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Layout: orbits around the home world -----------------------------
|
||||||
|
// Planets and free-space stations are the system's layout objects — each
|
||||||
|
// gets an x/y (see layoutSystem for the spacing rules and the N=11 case).
|
||||||
|
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
|
||||||
|
layoutSystem(galaxy.seed, record.id, planets, freeSpace);
|
||||||
|
|
||||||
// --- Debris belt & system-level hazard --------------------------------
|
// --- Debris belt & system-level hazard --------------------------------
|
||||||
const belt = {
|
const belt = {
|
||||||
present: rng.chance(attr.beltChance ?? 0.35),
|
present: rng.chance(attr.beltChance ?? 0.35),
|
||||||
|
|
@ -125,59 +129,138 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-down layout of a system's worlds (the play field around the origin,
|
* Top-down layout of a system's objects — its planets and its free-space
|
||||||
* where the player's home world sits).
|
* stations — as one or two ORBITS (rings) around the home world, which
|
||||||
|
* always sits at the system origin (the game renders a solid world there;
|
||||||
|
* see GameScene).
|
||||||
*
|
*
|
||||||
* Each planet record gains:
|
* Each object gains x, y (world position). Planets also gain scale (size
|
||||||
* x, y — world position (the game renders it there),
|
* multiplier, data/planets.json → classScale — the spacing rules below are
|
||||||
* scale — size multiplier (data/planets.json → classScale, so gas
|
* center-to-center, but the rendered discs still scale by class).
|
||||||
* giants read bigger than rockies).
|
|
||||||
*
|
*
|
||||||
* Worlds are scattered in the annulus minOrbit–maxOrbit px from the
|
* The hard spacing rules (data/planets.json → solarSystem):
|
||||||
* origin and kept at least minEdgeGap px (edge-to-edge) apart from each
|
* minSpacing — no two objects (planets, space stations, the home world)
|
||||||
* other and from the origin (data/planets.json → solarSystem).
|
* may be closer than this, center to center;
|
||||||
|
* maxNeighbor — whenever a system holds more than one object, every
|
||||||
|
* object must be within this of at least one other.
|
||||||
*
|
*
|
||||||
* Determinism: draws come from a DEDICATED fork — (seed, 'system', id,
|
* A ring of k objects at radius R around the origin satisfies both at once:
|
||||||
* 'layout') — so layout never perturbs the star/planet/settlement draws
|
* - object ⇄ home-world distance is R, so R ∈ [minSpacing, maxNeighbor]
|
||||||
|
* takes care of the home world's own pair of constraints;
|
||||||
|
* - the closest object-object pair on a regular k-gon is an edge,
|
||||||
|
* 2·R·sin(π/k) — an edge is the shortest chord (vertices further around
|
||||||
|
* are further), so edge ≥ minSpacing covers every pair;
|
||||||
|
* - every object's nearest neighbor is then the home world, at
|
||||||
|
* R ≤ maxNeighbor.
|
||||||
|
* That radius range is non-empty for k ≤ 10 (k = 11 would need
|
||||||
|
* R ≥ 10905, which already strands the home world beyond maxNeighbor). So
|
||||||
|
* the 11-object maximum (9 planets + 2 stations — the most the current
|
||||||
|
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring.
|
||||||
|
* The outer ring's near neighbor is its ring-mate (edge stays in
|
||||||
|
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
|
||||||
|
* maxNeighbor; and any inner/outer pair is at least R_outer − R_inner ≥
|
||||||
|
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond
|
||||||
|
* 11 objects the orbits chain outward — rings of ≤ 10, and a lone world
|
||||||
|
* always fits radially outside the previous orbit — so this terminates
|
||||||
|
* for any N.)
|
||||||
|
*
|
||||||
|
* What counts as an object: planets (all of them) and free-space
|
||||||
|
* settlements (anchor.type 'space' — deep-space stations, waypoints).
|
||||||
|
* Planet-bound settlements are features OF their planet (a colony sits on
|
||||||
|
* it) and so are not layout objects of their own.
|
||||||
|
*
|
||||||
|
* Determinism: draws come from the dedicated fork (seed, 'system', id,
|
||||||
|
* 'layout') — layout never perturbs the star/planet/settlement draws
|
||||||
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
|
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
|
||||||
*/
|
*/
|
||||||
function layoutSystemPlanets(seed, systemId, planets) {
|
function layoutSystem(seed, systemId, planets, freeSpace) {
|
||||||
const band = config.get('planets.solarSystem', {});
|
const band = config.get('planets.solarSystem', {});
|
||||||
const minOrbit = band.minOrbit ?? 2600;
|
if (band.enabled === false) return;
|
||||||
const maxOrbit = band.maxOrbit ?? 8800;
|
const MIN_SEP = band.minSpacing ?? 6144;
|
||||||
const minGap = band.minEdgeGap ?? 1200;
|
const MAX_NBR = band.maxNeighbor ?? 10240;
|
||||||
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
|
|
||||||
|
|
||||||
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
|
||||||
// The origin is occupied by a world (the home world) — respect it.
|
|
||||||
const placed = [{ x: 0, y: 0, r: baseR }];
|
|
||||||
|
|
||||||
|
// Rendered size per class (visual only — the spacing rules are
|
||||||
|
// center-to-center, not edge-to-edge).
|
||||||
for (const p of planets) {
|
for (const p of planets) {
|
||||||
const scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
||||||
const r = baseR * scale;
|
}
|
||||||
let ok = false;
|
|
||||||
let x = 0;
|
// Slot assignment order: planets in orbital order, then free-space
|
||||||
let y = 0;
|
// stations in generation order (deep-space station, then waypoint) —
|
||||||
for (let attempt = 0; attempt < 24 && !ok; attempt++) {
|
// stable per system, so lazy === eager.
|
||||||
const ang = lay.range(0, Math.PI * 2);
|
const objects = [...planets, ...freeSpace];
|
||||||
const d = lay.range(minOrbit, maxOrbit);
|
const N = objects.length;
|
||||||
x = d * Math.cos(ang);
|
if (N === 0) return;
|
||||||
y = d * Math.sin(ang);
|
|
||||||
ok = placed.every((q) => Math.hypot(x - q.x, y - q.y) >= q.r + r + minGap);
|
const TAU = Math.PI * 2;
|
||||||
|
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
||||||
|
const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k));
|
||||||
|
const slots = [];
|
||||||
|
let prevR = 0;
|
||||||
|
let prevAngle = 0;
|
||||||
|
|
||||||
|
const placeRing = (k, lo, hi) => {
|
||||||
|
const R = lay.range(lo, hi);
|
||||||
|
// A LONE object on an outer orbit sits radially outside an
|
||||||
|
// already-placed one: its guaranteed neighbor is then exactly
|
||||||
|
// R − prevR away, whereas a random angle could leave it more than
|
||||||
|
// maxNeighbor from every inner object.
|
||||||
|
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU);
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
const th = th0 + (k === 1 ? 0 : (TAU * i) / k);
|
||||||
|
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) });
|
||||||
}
|
}
|
||||||
if (!ok) {
|
prevR = R;
|
||||||
// The band is (nearly) full — spiral outward. A deterministic
|
prevAngle = th0;
|
||||||
// escape hatch: with the default band and ≤ 9 worlds per system
|
};
|
||||||
// this never actually triggers.
|
|
||||||
const ang = lay.range(0, Math.PI * 2);
|
if (N <= 10) {
|
||||||
const d = maxOrbit + r + minGap + placed.length * (r + minGap);
|
// One orbit around the home world (k = 1: a single object, whose only
|
||||||
x = d * Math.cos(ang);
|
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]).
|
||||||
y = d * Math.sin(ang);
|
const f = edgeFactor(N);
|
||||||
|
placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR);
|
||||||
|
} else if (N === 11) {
|
||||||
|
// Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤
|
||||||
|
// MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it
|
||||||
|
// at a radius where its edge is still ≤ MAX_NBR:
|
||||||
|
// R1 ≤ MAX_NBR / edgeFactor(8) − MIN_SEP.
|
||||||
|
const f8 = edgeFactor(8);
|
||||||
|
placeRing(
|
||||||
|
3,
|
||||||
|
Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)),
|
||||||
|
Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP),
|
||||||
|
);
|
||||||
|
const R1 = prevR;
|
||||||
|
// Outer 8-ring: ring-mates are its near neighbors (edge in
|
||||||
|
// [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 − R1 ≥ MIN_SEP away.
|
||||||
|
placeRing(
|
||||||
|
8,
|
||||||
|
Math.max(MIN_SEP / f8, R1 + MIN_SEP),
|
||||||
|
Math.min(MAX_NBR / f8, R1 + MAX_NBR),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Beyond the data maximum: chain orbits outward. A ring of ≤ 10
|
||||||
|
// objects always has a valid radius as ring 1, a lone world always
|
||||||
|
// fits on any outer orbit — the loop terminates for any N.
|
||||||
|
let rem = N;
|
||||||
|
while (rem > 0) {
|
||||||
|
const k = Math.min(rem, 10);
|
||||||
|
const f = edgeFactor(k);
|
||||||
|
const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP);
|
||||||
|
const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR);
|
||||||
|
if (lo <= hi) {
|
||||||
|
placeRing(k, lo, hi);
|
||||||
|
rem -= k;
|
||||||
|
} else {
|
||||||
|
placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR);
|
||||||
|
rem -= 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.scale = scale;
|
}
|
||||||
p.x = x;
|
|
||||||
p.y = y;
|
for (let i = 0; i < objects.length; i++) {
|
||||||
placed.push({ x, y, r });
|
objects[i].x = slots[i].x;
|
||||||
|
objects[i].y = slots[i].y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||||||
|
|
||||||
// The rest of the solar system — the generated worlds, placed by the
|
// The rest of the solar system — the generated worlds, placed by the
|
||||||
// generator (data/planets.json → solarSystem) in a band around the home
|
// generator (data/planets.json → solarSystem) on orbits around the
|
||||||
// world. Same solid-disc rules as home: fly close, never through.
|
// home world. Same solid-disc rules as home: fly close, never through.
|
||||||
this.systemPlanets = [];
|
this.systemPlanets = [];
|
||||||
if (config.get('planets.solarSystem.enabled', true)) {
|
if (config.get('planets.solarSystem.enabled', true)) {
|
||||||
for (const rec of this.systemContent.planets ?? []) {
|
for (const rec of this.systemContent.planets ?? []) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue