Change galaxy layout to a 2:1 field and harden jump-network construction
- Replace the square star field with a wide (width × height) field whose aspect matches the map plate, so the fully-zoomed-out galaxy fills the plate instead of letterboxing; recompute zone edges for equal-area thirds under the new diagonal CDF. - Add fitPadded() in SystemChart.js and use it in GalaxyView.js to frame the galaxy as a true rectangle fit with a plate-pixel margin, replacing the old world-unit padding that left stars on the plate edge. - Rework the JumpNetwork spanning-tree heuristic to "save the stranded first" (attach the candidate with the fewest unvisited non-barren neighbors) and strengthen the repair pass: never attach to barren nodes, guard swaps against cycles via inSubtree(), and add a last-resort fallback that preserves strong connectivity. - Update tests (galaxy.test.mjs, system-chart.test.mjs) for the field bounds and the new fitPadded behavior; refresh README, PROJECT_NOTES, and data comments to document the 2:1 field and platePadding knob.
This commit is contained in:
parent
5e4f94f385
commit
856508ff01
|
|
@ -36,7 +36,7 @@ node dev/server.mjs 8080
|
||||||
(click it and type), and rerollable — and the menu shows what that seed
|
(click it and type), and rerollable — and the menu shows what that seed
|
||||||
builds (the galaxy's name, system count, archetype count) **before** you
|
builds (the galaxy's name, system count, archetype count) **before** you
|
||||||
commit. Same seed ⇒ same galaxy.
|
commit. Same seed ⇒ same galaxy.
|
||||||
- **Procedural galaxy**: 90 star systems in a seeded square field —
|
- **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
|
even, organic spacing (Poisson disk, `data/galaxy.json`), the player's
|
||||||
home in the lower-right corner, and a home→far difficulty axis split
|
home in the lower-right corner, and a home→far difficulty axis split
|
||||||
into near/middle/far zones — typed into six themed archetypes
|
into near/middle/far zones — typed into six themed archetypes
|
||||||
|
|
@ -188,7 +188,7 @@ orbit/
|
||||||
│ ├── planets.json # home world + system layout + solid-disc rules
|
│ ├── 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
|
│ ├── 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
|
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||||||
│ ├── galaxy.json # galaxy scale & shape (count, square field, zones, corner home…)
|
│ ├── galaxy.json # galaxy scale & shape (count, 2:1 field, zones, corner home…)
|
||||||
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
||||||
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
│ ├── 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)
|
│ ├── 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,19 @@
|
||||||
{
|
{
|
||||||
"_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout.square = the galaxy is a seeded SQUARE field of stars (center at the world origin, ±side/2 in x and y): Bridson Poisson-disk sampling keeps every pair of systems at least minSpacing·√(side²/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).",
|
"_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,
|
"systemCount": 90,
|
||||||
"layout": {
|
"layout": {
|
||||||
"square": {
|
"field": {
|
||||||
"side": 32000,
|
"width": 32000,
|
||||||
|
"height": 16000,
|
||||||
"minSpacing": 0.8
|
"minSpacing": 0.8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"distribution": {
|
"distribution": {
|
||||||
"_comment": "zones slice the HOME→FAR diagonal (d) into the three regions. The diagonal bands have UNEVEN area (corner triangles vs the middle band), so the edges are set for EQUAL-AREA thirds: area(d < a) = 2a² of the square for a ≤ ½ — a = 0.40 and 0.60 give ≈ 32/36/32. zoneMix multiplies each type's global distribution.weight per zone (missing type = ×1).",
|
"_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": [
|
"zones": [
|
||||||
{ "name": "near", "d": [0, 0.4] },
|
{ "name": "near", "d": [0, 0.37] },
|
||||||
{ "name": "middle", "d": [0.4, 0.6] },
|
{ "name": "middle", "d": [0.37, 0.63] },
|
||||||
{ "name": "far", "d": [0.6, 1] }
|
{ "name": "far", "d": [0.63, 1] }
|
||||||
],
|
],
|
||||||
"zoneMix": {
|
"zoneMix": {
|
||||||
"near": { "main": 1.0, "redDwarf": 1.25, "binary": 0.8, "habitable": 1.5, "nebula": 0.4, "void": 0.3 },
|
"near": { "main": 1.0, "redDwarf": 1.25, "binary": 0.8, "habitable": 1.5, "nebula": 0.4, "void": 0.3 },
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@
|
||||||
"galaxy": {
|
"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).",
|
"_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",
|
"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": {
|
"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).",
|
"_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,
|
"glow": 30,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* - same seed ⇒ identical roster (ids, names, types, positions);
|
* - same seed ⇒ identical roster (ids, names, types, positions);
|
||||||
* - different seed ⇒ different galaxy;
|
* - different seed ⇒ different galaxy;
|
||||||
* - type distribution matches the weights × per-zone mix (zoneMix, ±4σ);
|
* - type distribution matches the weights × per-zone mix (zoneMix, ±4σ);
|
||||||
* - square domain + even field (Poisson disk), every record carries d + zone;
|
* - field bounds + even field (Poisson disk), every record carries d + zone;
|
||||||
* - starting system = the star nearest the configured home corner;
|
* - starting system = the star nearest the configured home corner;
|
||||||
* - every generated system obeys its type's attribute bounds;
|
* - every generated system obeys its type's attribute bounds;
|
||||||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||||||
|
|
@ -158,18 +158,18 @@ let big;
|
||||||
}
|
}
|
||||||
check('type distribution matches configured weights × zoneMix per zone (±4σ)', distOk);
|
check('type distribution matches configured weights × zoneMix per zone (±4σ)', distOk);
|
||||||
|
|
||||||
// SQUARE DOMAIN + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract).
|
// FIELD BOUNDS + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract).
|
||||||
const side = Math.max(2, Math.floor(Number(big.params.layout?.square?.side) || 32000));
|
const fieldW = Math.max(2, Math.floor(Number(big.params.layout?.field?.width) || 32000));
|
||||||
const half = side / 2;
|
const fieldH = Math.max(2, Math.floor(Number(big.params.layout?.field?.height) || 16000));
|
||||||
check(
|
check(
|
||||||
'every system sits inside the square domain (±side/2)',
|
'every system sits inside the 2:1 field (±width/2 × ±height/2)',
|
||||||
big.records.every((r) => Math.abs(r.x) <= half + 1e-9 && Math.abs(r.y) <= half + 1e-9),
|
big.records.every((r) => Math.abs(r.x) <= fieldW / 2 + 1e-9 && Math.abs(r.y) <= fieldH / 2 + 1e-9),
|
||||||
);
|
);
|
||||||
let minPair = Infinity;
|
let minPair = Infinity;
|
||||||
for (let i = 0; i < big.records.length; i++)
|
for (let i = 0; i < big.records.length; i++)
|
||||||
for (let j = i + 1; j < big.records.length; j++)
|
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));
|
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((side * side) / big.records.length);
|
const spacingFloor = 0.7 * Math.sqrt((fieldW * fieldH) / big.records.length);
|
||||||
check(
|
check(
|
||||||
`even field (Poisson disk): min pair distance ${Math.round(minPair)} px ≥ ${Math.round(spacingFloor)} px — no clumps, no voids`,
|
`even field (Poisson disk): min pair distance ${Math.round(minPair)} px ≥ ${Math.round(spacingFloor)} px — no clumps, no voids`,
|
||||||
minPair >= spacingFloor - 1e-6,
|
minPair >= spacingFloor - 1e-6,
|
||||||
|
|
@ -187,7 +187,7 @@ let big;
|
||||||
);
|
);
|
||||||
// CORNER HOME (startingSystem.policy 'corner', corner SE — lower right,
|
// CORNER HOME (startingSystem.policy 'corner', corner SE — lower right,
|
||||||
// screen y-down): the starting system is the star NEAREST the home corner.
|
// screen y-down): the starting system is the star NEAREST the home corner.
|
||||||
const homeCorner = { x: half, y: half };
|
const homeCorner = { x: fieldW / 2, y: fieldH / 2 };
|
||||||
const nearestToCorner = big.records
|
const nearestToCorner = big.records
|
||||||
.slice()
|
.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;
|
.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;
|
||||||
|
|
@ -289,10 +289,11 @@ let big;
|
||||||
// The CORNER policy honors whichever corner is configured (here: NW —
|
// The CORNER policy honors whichever corner is configured (here: NW —
|
||||||
// upper left, screen y-down), not just the default SE.
|
// upper left, screen y-down), not just the default SE.
|
||||||
const nw = Galaxy.create('nw-policy', { systemCount: 250, startingSystem: { policy: 'corner', corner: 'NW' } });
|
const nw = Galaxy.create('nw-policy', { systemCount: 250, startingSystem: { policy: 'corner', corner: 'NW' } });
|
||||||
const halfNW = Math.max(2, Math.floor(Number(nw.params.layout?.square?.side) || 32000)) / 2;
|
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
|
const trueNW = nw.records
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a.x + halfNW) ** 2 + (a.y + halfNW) ** 2 - ((b.x + halfNW) ** 2 + (b.y + halfNW) ** 2))[0].id;
|
.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);
|
check('corner policy honors the configured corner (NW)', nw.currentSystemId === trueNW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
|
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
|
||||||
* (content.asteroids) — found/total + pct; missing list → 0/0.
|
* (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 passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
@ -91,6 +91,29 @@ console.log('fitToRect');
|
||||||
ok(near(tf.toX(100) - tf.toX(-100), 200), 'frame width preserved');
|
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 ──────────────────────────────────────────────────────
|
// ── navDiscoveryStats ──────────────────────────────────────────────────────
|
||||||
console.log('navDiscoveryStats');
|
console.log('navDiscoveryStats');
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -132,10 +132,13 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
||||||
nearest stars, tie-broken by a derived seeded Rng. The stamps land on
|
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
|
each planet record (`planet.frame`) and the home world's face on
|
||||||
`content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`.
|
`content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`.
|
||||||
- **distributed** — a seeded SQUARE field of stars
|
- **distributed** — a seeded WIDE (2:1) field of stars
|
||||||
(`galaxy.layout.square`: `side`, `minSpacing`) placed by Bridson
|
(`galaxy.layout.field`: `width`, `height`, `minSpacing`) placed by
|
||||||
Poisson-disk sampling — even, organic spacing (no clumps, no voids,
|
Bridson Poisson-disk sampling — even, organic spacing (no clumps, no
|
||||||
not a grid). The player's home sits in the star NEAREST the configured
|
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;
|
corner (`startingSystem: policy "corner"`, `corner: SE` = lower right;
|
||||||
`center`/`random` still work), and the HOME→FAR diagonal is the
|
`center`/`random` still work), and the HOME→FAR diagonal is the
|
||||||
progression axis: every record carries `d` (0 = home corner, 1 = far
|
progression axis: every record carries `d` (0 = home corner, 1 = far
|
||||||
|
|
@ -1276,7 +1279,7 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
||||||
capacities; upgrades (ship-category builds) will layer deltas on top
|
capacities; upgrades (ship-category builds) will layer deltas on top
|
||||||
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
|
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
|
||||||
(ship-category builds) from one place
|
(ship-category builds) from one place
|
||||||
- [x] Galaxy regions: square field + home→far `d` coordinate +
|
- [x] Galaxy regions: wide 2:1 field (plate-shaped) + home→far `d` coordinate +
|
||||||
near/middle/far zones + per-zone type mix (data/galaxy.json →
|
near/middle/far zones + per-zone type mix (data/galaxy.json →
|
||||||
distribution.zones/zoneMix) — the region layer is live
|
distribution.zones/zoneMix) — the region layer is live
|
||||||
- [ ] Factions: Voronoi territories around seeded capitals → each
|
- [ ] Factions: Voronoi territories around seeded capitals → each
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,13 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
* `generateAll()` exists for exactly that, if it's ever "just as easy".
|
* `generateAll()` exists for exactly that, if it's ever "just as easy".
|
||||||
*
|
*
|
||||||
* The SHAPE (data/galaxy.json):
|
* The SHAPE (data/galaxy.json):
|
||||||
* - a seeded SQUARE field of stars (layout.square.side, center at the
|
* - a seeded WIDE (2:1) field of stars (layout.field.width × height,
|
||||||
* world origin) placed by Bridson Poisson-disk sampling — an even,
|
* center at the world origin) placed by Bridson Poisson-disk
|
||||||
* organic field: every pair of systems stays at least
|
* sampling — an even, organic field: every pair of systems stays at
|
||||||
* minSpacing·√(side²/N) apart, no clumps, no voids, not a grid;
|
* 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
|
* - the player's HOME system sits in the star nearest the configured
|
||||||
* corner (startingSystem: policy "corner", corner NE/NW/SE/SW,
|
* corner (startingSystem: policy "corner", corner NE/NW/SE/SW,
|
||||||
* screen orientation — SE = lower right); "center" / "random" still
|
* screen orientation — SE = lower right); "center" / "random" still
|
||||||
|
|
@ -55,7 +58,7 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
|
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
|
||||||
* and `record.zone` (the middle zone is where the zone borders cross
|
* and `record.zone` (the middle zone is where the zone borders cross
|
||||||
* — the planned contested space + transit trade hubs).
|
* — the planned contested space + transit trade hubs).
|
||||||
* - more layout knobs in data/galaxy.json `layout.square`.
|
* - more layout knobs in data/galaxy.json `layout.field`.
|
||||||
*/
|
*/
|
||||||
export class Galaxy {
|
export class Galaxy {
|
||||||
constructor(seed, params, typeDefs) {
|
constructor(seed, params, typeDefs) {
|
||||||
|
|
@ -109,11 +112,14 @@ export class Galaxy {
|
||||||
|
|
||||||
_generate(count) {
|
_generate(count) {
|
||||||
const L = this.params.layout ?? {};
|
const L = this.params.layout ?? {};
|
||||||
const SQ = L.square ?? {};
|
const F = L.field ?? {};
|
||||||
const S = Math.max(2, Math.floor(Number(SQ.side) || 32000));
|
const W = Math.max(4, Math.floor(Number(F.width) || 32000));
|
||||||
this.side = S; // the square domain's edge (world px, center at origin)
|
const H = Math.max(4, Math.floor(Number(F.height) || 16000));
|
||||||
const half = S / 2;
|
this.fieldW = W; // the field's extent (world px, center at origin)
|
||||||
const minSpacing = clamp(Number(SQ.minSpacing) || 0.8, 0.4, 1.2);
|
this.fieldH = H;
|
||||||
|
const halfW = W / 2;
|
||||||
|
const halfH = H / 2;
|
||||||
|
const minSpacing = clamp(Number(F.minSpacing) || 0.8, 0.4, 1.2);
|
||||||
|
|
||||||
// The HOME CORNER (data/galaxy.json → startingSystem.corner; screen
|
// The HOME CORNER (data/galaxy.json → startingSystem.corner; screen
|
||||||
// orientation — y down, so SE = lower right). Two things key off it:
|
// orientation — y down, so SE = lower right). Two things key off it:
|
||||||
|
|
@ -122,9 +128,9 @@ export class Galaxy {
|
||||||
// corner — the galaxy's progression axis, whatever policy picks the
|
// corner — the galaxy's progression axis, whatever policy picks the
|
||||||
// starting system).
|
// starting system).
|
||||||
const start = this.params.startingSystem ?? {};
|
const start = this.params.startingSystem ?? {};
|
||||||
const corner = this._cornerPoint(start.corner ?? 'SE', half);
|
const corner = this._cornerPoint(start.corner ?? 'SE', { halfW, halfH });
|
||||||
const opp = { x: -corner.x, y: -corner.y };
|
const opp = { x: -corner.x, y: -corner.y };
|
||||||
const diag2 = (corner.x - opp.x) ** 2 + (corner.y - opp.y) ** 2; // 2·S²
|
const diag2 = (corner.x - opp.x) ** 2 + (corner.y - opp.y) ** 2; // W² + H²
|
||||||
const dOf = (x, y) => clamp(
|
const dOf = (x, y) => clamp(
|
||||||
((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2,
|
((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2,
|
||||||
0, 1,
|
0, 1,
|
||||||
|
|
@ -153,17 +159,18 @@ export class Galaxy {
|
||||||
// EVEN PLACEMENT — a Bridson Poisson-disk (blue-noise) field: every
|
// EVEN PLACEMENT — a Bridson Poisson-disk (blue-noise) field: every
|
||||||
// pair of systems stays at least dmin apart (no clumps, no voids)
|
// 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
|
// while the field stays organic (not a grid). dmin is a fraction of
|
||||||
// the mean inter-star spacing √(side²/N) (layout.square.minSpacing).
|
// the mean inter-star spacing √(width·height/N)
|
||||||
|
// (layout.field.minSpacing).
|
||||||
// Even spacing is what keeps the jump network's hop counts
|
// Even spacing is what keeps the jump network's hop counts
|
||||||
// proportional to map distance — the property the trade economy
|
// proportional to map distance — the property the trade economy
|
||||||
// leans on ("a hop is a hop").
|
// leans on ("a hop is a hop").
|
||||||
const dmin = minSpacing * Math.sqrt((S * S) / Math.max(1, count));
|
const dmin = minSpacing * Math.sqrt((W * H) / Math.max(1, count));
|
||||||
const points = this._poissonDisk(count, half, dmin);
|
const points = this._poissonDisk(count, { halfW, halfH }, dmin);
|
||||||
|
|
||||||
const records = this.records;
|
const records = this.records;
|
||||||
for (let i = 1; i <= count; i++) {
|
for (let i = 1; i <= count; i++) {
|
||||||
const id = `S${String(i).padStart(6, '0')}`;
|
const id = `S${String(i).padStart(6, '0')}`;
|
||||||
const p = points[i - 1] ?? { x: g.range(-half, half), y: g.range(-half, half) };
|
const p = points[i - 1] ?? { x: g.range(-halfW, halfW), y: g.range(-halfH, halfH) };
|
||||||
const d = dOf(p.x, p.y);
|
const d = dOf(p.x, p.y);
|
||||||
const zone = zoneOf(d);
|
const zone = zoneOf(d);
|
||||||
const type = g.weighted(weightsFor(zone), typeIds[0]);
|
const type = g.weighted(weightsFor(zone), typeIds[0]);
|
||||||
|
|
@ -195,7 +202,7 @@ export class Galaxy {
|
||||||
|
|
||||||
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
|
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
|
||||||
// the eventual star map).
|
// the eventual star map).
|
||||||
const area = S * S;
|
const area = W * H;
|
||||||
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
|
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
|
||||||
this.grid = new Map();
|
this.grid = new Map();
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
|
|
@ -308,56 +315,58 @@ export class Galaxy {
|
||||||
return out.length ? out : [{ name: 'all', d: [0, 1] }];
|
return out.length ? out : [{ name: 'all', d: [0, 1] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A corner of the square domain (screen orientation — y DOWN). */
|
/** A corner of the field (screen orientation — y DOWN). */
|
||||||
_cornerPoint(name, half) {
|
_cornerPoint(name, { halfW, halfH }) {
|
||||||
const CORNERS = { NE: [1, -1], NW: [-1, -1], SE: [1, 1], SW: [-1, 1] };
|
const CORNERS = { NE: [1, -1], NW: [-1, -1], SE: [1, 1], SW: [-1, 1] };
|
||||||
const key = String(name ?? 'SE').toUpperCase();
|
const key = String(name ?? 'SE').toUpperCase();
|
||||||
const [sx, sy] = CORNERS[key] ?? CORNERS.SE;
|
const [sx, sy] = CORNERS[key] ?? CORNERS.SE;
|
||||||
return { x: sx * half, y: sy * half };
|
return { x: sx * halfW, y: sy * halfH };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Even star placement: exactly `n` points in the square [−half, half]²,
|
* Even star placement: exactly `n` points in the field
|
||||||
* every pair at least `d0` apart (Bridson / Poisson disk). If the disk
|
* [−halfW, halfW] × [−halfH, halfH], every pair at least `d0` apart
|
||||||
* can't hold `n` points at `d0` (dense config), retry a few times with
|
* (Bridson / Poisson disk). If the field can't hold `n` points at
|
||||||
* a relaxed spacing; as a last resort pad with random points — a working
|
* `d0` (dense config), retry a few times with a relaxed spacing; as a
|
||||||
* galaxy beats a perfect one. Deterministic: each attempt draws from its
|
* last resort pad with random points — a working galaxy beats a
|
||||||
* own seeded fork (seed, 'layout', 'poisson', attempt).
|
* perfect one. Deterministic: each attempt draws from its own seeded
|
||||||
|
* fork (seed, 'layout', 'poisson', attempt).
|
||||||
*/
|
*/
|
||||||
_poissonDisk(n, half, d0) {
|
_poissonDisk(n, { halfW, halfH }, d0) {
|
||||||
if (n <= 0) return [];
|
if (n <= 0) return [];
|
||||||
let d = Math.max(1, d0);
|
let d = Math.max(1, d0);
|
||||||
let pts = null;
|
let pts = null;
|
||||||
for (let attempt = 0; attempt < 8; attempt++) {
|
for (let attempt = 0; attempt < 8; attempt++) {
|
||||||
const rng = Rng.derive(this.seed, 'layout', 'poisson', attempt);
|
const rng = Rng.derive(this.seed, 'layout', 'poisson', attempt);
|
||||||
pts = this._bridson(n, half, d, rng);
|
pts = this._bridson(n, { halfW, halfH }, d, rng);
|
||||||
if (pts.length >= n) break;
|
if (pts.length >= n) break;
|
||||||
d *= 0.88; // not enough room — loosen the spacing and retry
|
d *= 0.88; // not enough room — loosen the spacing and retry
|
||||||
}
|
}
|
||||||
const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad');
|
const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad');
|
||||||
while (pts.length < n) {
|
while (pts.length < n) {
|
||||||
pts.push({ x: rng.range(-half, half), y: rng.range(-half, half) });
|
pts.push({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
|
||||||
}
|
}
|
||||||
return pts.slice(0, n);
|
return pts.slice(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridson's algorithm: grow a Poisson-disk of points in the square,
|
* Bridson's algorithm: grow a Poisson-disk of points in the field,
|
||||||
* stopping once `n` points are placed (or the frontier is exhausted).
|
* stopping once `n` points are placed (or the frontier is exhausted).
|
||||||
* Pure — deterministic for a given (n, half, d, rng stream).
|
* Pure — deterministic for a given (n, halfW, halfH, d, rng stream).
|
||||||
*/
|
*/
|
||||||
_bridson(n, half, d, rng) {
|
_bridson(n, { halfW, halfH }, d, rng) {
|
||||||
if (n <= 0) return [];
|
if (n <= 0) return [];
|
||||||
const cell = d / Math.SQRT2;
|
const cell = d / Math.SQRT2;
|
||||||
const grid = new Map(); // "cx,cy" → [point, …]
|
const grid = new Map(); // "cx,cy" → [point, …]
|
||||||
const pts = [];
|
const pts = [];
|
||||||
const active = []; // indices into pts (Bridson's active list)
|
const active = []; // indices into pts (Bridson's active list)
|
||||||
const inside = (p) => p.x >= -half && p.x <= half && p.y >= -half && p.y <= half;
|
const inside = (p) =>
|
||||||
|
p.x >= -halfW && p.x <= halfW && p.y >= -halfH && p.y <= halfH;
|
||||||
const keyOf = (p) =>
|
const keyOf = (p) =>
|
||||||
`${Math.floor((p.x + half) / cell)},${Math.floor((p.y + half) / cell)}`;
|
`${Math.floor((p.x + halfW) / cell)},${Math.floor((p.y + halfH) / cell)}`;
|
||||||
const free = (p) => {
|
const free = (p) => {
|
||||||
const cx = Math.floor((p.x + half) / cell);
|
const cx = Math.floor((p.x + halfW) / cell);
|
||||||
const cy = Math.floor((p.y + half) / cell);
|
const cy = Math.floor((p.y + halfH) / cell);
|
||||||
for (let ax = -2; ax <= 2; ax++) {
|
for (let ax = -2; ax <= 2; ax++) {
|
||||||
for (let ay = -2; ay <= 2; ay++) {
|
for (let ay = -2; ay <= 2; ay++) {
|
||||||
const bucket = grid.get(`${cx + ax},${cy + ay}`);
|
const bucket = grid.get(`${cx + ax},${cy + ay}`);
|
||||||
|
|
@ -380,7 +389,7 @@ export class Galaxy {
|
||||||
else grid.set(k, [p]);
|
else grid.set(k, [p]);
|
||||||
};
|
};
|
||||||
// Seed the frontier with one random interior point.
|
// Seed the frontier with one random interior point.
|
||||||
place({ x: rng.range(-half, half), y: rng.range(-half, half) });
|
place({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
|
||||||
while (active.length > 0 && pts.length < n) {
|
while (active.length > 0 && pts.length < n) {
|
||||||
const i = rng.int(0, active.length - 1);
|
const i = rng.int(0, active.length - 1);
|
||||||
const p = pts[active[i]];
|
const p = pts[active[i]];
|
||||||
|
|
@ -418,7 +427,7 @@ export class Galaxy {
|
||||||
best.sort((a, b) => a.d2 - b.d2);
|
best.sort((a, b) => a.d2 - b.d2);
|
||||||
if (best.length > k) best.length = k;
|
if (best.length > k) best.length = k;
|
||||||
};
|
};
|
||||||
const maxRing = Math.min(1024, Math.ceil((Math.SQRT2 * (this.side ?? 40000)) / 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 ring = 0; ring <= maxRing; ring++) {
|
||||||
for (let dx = -ring; dx <= ring; dx++) {
|
for (let dx = -ring; dx <= ring; dx++) {
|
||||||
for (let dy = -ring; dy <= ring; dy++) {
|
for (let dy = -ring; dy <= ring; dy++) {
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,14 @@
|
||||||
* Construction:
|
* Construction:
|
||||||
* 1. The undirected neighbor graph (u~v iff v ∈ nn(u) or u ∈ nn(v)).
|
* 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
|
* 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"
|
* BFS-outward from the home system with a "save the stranded first"
|
||||||
* child heuristic: attach the unvisited neighbors with the MOST
|
* child heuristic: attach the unvisited neighbor with the FEWEST
|
||||||
* unvisited neighbors first, so rim clusters are absorbed before
|
* unvisited non-barren neighbors (its other possible adopters)
|
||||||
* their degree budget is spent. A BARREN node never adopts children
|
* first — a star nobody else can adopt must not wait for a budget
|
||||||
* (it keeps its single gate — the maze's dead end).
|
* 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
|
* 3. Tree edges run BOTH ways. A bidirected tree is strongly connected
|
||||||
* by construction (the unique tree path between any two systems can
|
* by construction (the unique tree path between any two systems can
|
||||||
* be walked in either direction), and every node's gate count is its
|
* 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
|
* The repair pass (below) is defensive: it fires only if the neighbor
|
||||||
* graph is disconnected (effectively impossible at this scale), and it
|
* graph is disconnected (effectively impossible at this scale), and it
|
||||||
* still respects the degree budget (preferring non-barren attach targets
|
* NEVER breaks the invariants: it only ever attaches to NON-BARREN nodes
|
||||||
* so a dead end stays a leaf when it can).
|
* (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;
|
if (budget <= 0) continue;
|
||||||
const cands = nbr[u].filter((v) => !visited[v]);
|
const cands = nbr[u].filter((v) => !visited[v]);
|
||||||
if (cands.length === 0) continue;
|
if (cands.length === 0) continue;
|
||||||
// "Keep the frontier open": candidates with the most unvisited
|
// "Save the stranded first": attach the candidate with the FEWEST
|
||||||
// neighbors grow the tree for others; ties by index (determinism).
|
// 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) => {
|
const scored = cands.map((v) => {
|
||||||
|
let risk = 0; // unvisited NON-BARREN neighbors of v (its other adopters)
|
||||||
let unv = 0;
|
let unv = 0;
|
||||||
for (const w of nbr[v]) if (!visited[w]) unv++;
|
for (const w of nbr[v]) {
|
||||||
return { v, unv };
|
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)) {
|
for (const { v } of scored.slice(0, budget)) {
|
||||||
visited[v] = 1;
|
visited[v] = 1;
|
||||||
parent[v] = u;
|
parent[v] = u;
|
||||||
|
|
@ -153,30 +167,44 @@ export function buildJumpNetwork({
|
||||||
// --- Repair (defensive): attach anything the tree left behind ---------
|
// --- Repair (defensive): attach anything the tree left behind ---------
|
||||||
// A leftover node has no visited neighbor with spare degree (its whole
|
// A leftover node has no visited neighbor with spare degree (its whole
|
||||||
// neighborhood sat in a disconnected pocket). Repair, in order of
|
// neighborhood sat in a disconnected pocket). Repair, in order of
|
||||||
// preference (all deterministic — index order, strict comparisons):
|
// preference (all deterministic — index order, strict comparisons). A
|
||||||
// 1. Attach to a visited neighbor with spare degree (local).
|
// 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
|
// 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
|
// onto one of x's OWN visited NON-BARREN neighbors that has spare
|
||||||
// use the freed budget for w. The tree stays a tree; locality is
|
// 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).
|
// preserved (x stays inside its own neighborhood).
|
||||||
// 3. Last resort (should never fire on a kNN graph): attach w to the
|
// 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
|
// nearest visited NON-BARREN node and accept one over-budget degree
|
||||||
// network beats a broken one.
|
// — a working network beats a broken one.
|
||||||
const dist2 = (a, b) => {
|
const dist2 = (a, b) => {
|
||||||
const dx = records[a].x - records[b].x;
|
const dx = records[a].x - records[b].x;
|
||||||
const dy = records[a].y - records[b].y;
|
const dy = records[a].y - records[b].y;
|
||||||
return dx * dx + dy * dy;
|
return dx * dx + dy * dy;
|
||||||
};
|
};
|
||||||
let repaired = 0;
|
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++) {
|
for (let w = 0; w < n; w++) {
|
||||||
if (visited[w]) continue;
|
if (visited[w]) continue;
|
||||||
// (1) local attach — prefer a non-barren target (a dead end should
|
// (1) local attach — a barren neighbor is NEVER a target: a dead end
|
||||||
// keep its single gate when the graph lets us).
|
// keeps its single gate, even in repair.
|
||||||
let u = -1;
|
let u = -1;
|
||||||
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates && !isBarren(c)) { u = c; break; }
|
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) {
|
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;
|
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
|
||||||
for (let c = 0; c < n; c++) {
|
for (let c = 0; c < n; c++) {
|
||||||
if (!visited[c] || c === w || deg[c] < 2) continue; // needs a child to free
|
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++) {
|
for (let x = 0; x < n; x++) {
|
||||||
if (parent[x] !== c) continue;
|
if (parent[x] !== c) continue;
|
||||||
for (const nn of nbr[x]) {
|
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)))) {
|
if (d < bestD || (d === bestD && (c < bestU || (c === bestU && x < bestX)))) {
|
||||||
bestD = d; bestU = c; bestX = x; bestNew = nn;
|
bestD = d; bestU = c; bestX = x; bestNew = nn;
|
||||||
}
|
}
|
||||||
|
|
@ -201,18 +231,26 @@ export function buildJumpNetwork({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (u === -1) {
|
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;
|
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++) {
|
for (let c = 0; c < n; c++) {
|
||||||
if (!visited[c] || c === w) continue;
|
if (!visited[c] || c === w) continue;
|
||||||
if (Boolean(isBarren(c)) !== (pass === 1)) continue;
|
|
||||||
const d = dist2(w, c);
|
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)`);
|
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);
|
||||||
}
|
}
|
||||||
visited[w] = 1;
|
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 /
|
* The SYSTEM DISCOVERY share (data/map.json → stats): the player's found /
|
||||||
* total over the system's NAV objects — planets, free-space stations and
|
* total over the system's NAV objects — planets, free-space stations and
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
import { setInteractiveEnabled } from '../utils/Input.js';
|
import { setInteractiveEnabled } from '../utils/Input.js';
|
||||||
import { canvasTexture } from '../utils/Textures.js';
|
import { canvasTexture } from '../utils/Textures.js';
|
||||||
import { Rng } from '../utils/Rng.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';
|
import { paddedHullPolygon, starPulse, starTypeColor, clipLineToRect, clipPolygonToRect, starDotPx, starArtSpec } from '../galaxy/GalaxyChart.js';
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
const TAU = Math.PI * 2;
|
||||||
|
|
@ -334,14 +334,23 @@ export class GalaxyView {
|
||||||
const first = this._snap == null;
|
const first = this._snap == null;
|
||||||
this._snap = snap;
|
this._snap = snap;
|
||||||
if (first) {
|
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 glowW = Math.max(10, Number(this._cfg.stars.glow ?? 30));
|
||||||
const pad = Number(config.get('map.galaxy.padding', 90)) || 90;
|
const platePad = Number(config.get('map.galaxy.platePadding', 26)) || 26;
|
||||||
this._bounds = chartBounds(
|
const b0 = chartBounds(
|
||||||
snap.systems.map((s) => ({ x: s.x, y: s.y, radius: glowW / 2 })),
|
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 };
|
this._view = { z: 1, cx: this._bounds.cx, cy: this._bounds.cy };
|
||||||
// charted-region inflation, in WORLD units (a fraction of the plate
|
// charted-region inflation, in WORLD units (a fraction of the plate
|
||||||
// width at 1× zoom) — stable across zoom levels
|
// width at 1× zoom) — stable across zoom levels
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue