diff --git a/README.md b/README.md index 9cab7fe..bf17178 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ 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**: 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 home in the lower-right corner, and a home→far difficulty axis split into near/middle/far zones — typed into six themed archetypes @@ -188,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, square field, zones, corner home…) +│ ├── 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) diff --git a/assets/videos/ss-surface-02.mp4 b/assets/videos/ss-surface-02.mp4 new file mode 100644 index 0000000..3748b04 Binary files /dev/null and b/assets/videos/ss-surface-02.mp4 differ diff --git a/data/galaxy.json b/data/galaxy.json index e87f6a7..d823f24 100644 --- a/data/galaxy.json +++ b/data/galaxy.json @@ -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, "layout": { - "square": { - "side": 32000, + "field": { + "width": 32000, + "height": 16000, "minSpacing": 0.8 } }, "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": [ - { "name": "near", "d": [0, 0.4] }, - { "name": "middle", "d": [0.4, 0.6] }, - { "name": "far", "d": [0.6, 1] } + { "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 }, diff --git a/data/map.json b/data/map.json index 73f088a..54ed37a 100644 --- a/data/map.json +++ b/data/map.json @@ -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, diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index 126a79e..1e47b9f 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -9,7 +9,7 @@ * - same seed ⇒ identical roster (ids, names, types, positions); * - different seed ⇒ different galaxy; * - 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; * - every generated system obeys its type's attribute bounds; * - 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); - // SQUARE DOMAIN + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract). - const side = Math.max(2, Math.floor(Number(big.params.layout?.square?.side) || 32000)); - const half = side / 2; + // 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 square domain (±side/2)', - big.records.every((r) => Math.abs(r.x) <= half + 1e-9 && Math.abs(r.y) <= half + 1e-9), + '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((side * side) / big.records.length); + 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, @@ -187,7 +187,7 @@ let big; ); // 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: half, y: half }; + 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; @@ -289,10 +289,11 @@ let big; // 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 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 .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); } diff --git a/dev/system-chart.test.mjs b/dev/system-chart.test.mjs index fca094f..b5dcacb 100644 --- a/dev/system-chart.test.mjs +++ b/dev/system-chart.test.mjs @@ -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'); { diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index aae22e2..1d0d973 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 each planet record (`planet.frame`) and the home world's face on `content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`. -- **distributed** — a seeded SQUARE field of stars - (`galaxy.layout.square`: `side`, `minSpacing`) placed by Bridson - Poisson-disk sampling — even, organic spacing (no clumps, no voids, - not a grid). The player's home sits in the star NEAREST the configured +- **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 @@ -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 - [ ] Ship screen (the Ship slot) — inspect & upgrade the ship (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 → distribution.zones/zoneMix) — the region layer is live - [ ] Factions: Voronoi territories around seeded capitals → each diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 0d9f8cc..5d0cb69 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -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". * * The SHAPE (data/galaxy.json): - * - a seeded SQUARE field of stars (layout.square.side, center at the - * world origin) placed by Bridson Poisson-disk sampling — an even, - * organic field: every pair of systems stays at least - * minSpacing·√(side²/N) apart, no clumps, no voids, not a grid; + * - 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 @@ -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` * 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.square`. + * - more layout knobs in data/galaxy.json `layout.field`. */ export class Galaxy { constructor(seed, params, typeDefs) { @@ -109,11 +112,14 @@ export class Galaxy { _generate(count) { const L = this.params.layout ?? {}; - const SQ = L.square ?? {}; - const S = Math.max(2, Math.floor(Number(SQ.side) || 32000)); - this.side = S; // the square domain's edge (world px, center at origin) - const half = S / 2; - const minSpacing = clamp(Number(SQ.minSpacing) || 0.8, 0.4, 1.2); + 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); // The HOME CORNER (data/galaxy.json → startingSystem.corner; screen // 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 // starting system). 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 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( ((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2, 0, 1, @@ -153,17 +159,18 @@ export class Galaxy { // 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 √(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 // proportional to map distance — the property the trade economy // leans on ("a hop is a hop"). - const dmin = minSpacing * Math.sqrt((S * S) / Math.max(1, count)); - const points = this._poissonDisk(count, half, dmin); + const dmin = minSpacing * Math.sqrt((W * H) / Math.max(1, count)); + const points = this._poissonDisk(count, { halfW, halfH }, dmin); const records = this.records; for (let i = 1; i <= count; i++) { 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 zone = zoneOf(d); 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, // the eventual star map). - const area = S * S; + const area = W * H; this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4); this.grid = new Map(); for (const rec of records) { @@ -308,56 +315,58 @@ export class Galaxy { return out.length ? out : [{ name: 'all', d: [0, 1] }]; } - /** A corner of the square domain (screen orientation — y DOWN). */ - _cornerPoint(name, half) { + /** 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 * half, y: sy * half }; + return { x: sx * halfW, y: sy * halfH }; } /** - * Even star placement: exactly `n` points in the square [−half, half]², - * every pair at least `d0` apart (Bridson / Poisson disk). If the disk - * 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). + * 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, half, d0) { + _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, half, d, rng); + pts = this._bridson(n, { halfW, halfH }, d, rng); if (pts.length >= n) break; d *= 0.88; // not enough room — loosen the spacing and retry } const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad'); 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); } /** - * 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). - * 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 []; 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 >= -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) => - `${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 cx = Math.floor((p.x + half) / cell); - const cy = Math.floor((p.y + half) / cell); + 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}`); @@ -380,7 +389,7 @@ export class Galaxy { else grid.set(k, [p]); }; // 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) { const i = rng.int(0, active.length - 1); const p = pts[active[i]]; @@ -418,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((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 dx = -ring; dx <= ring; dx++) { for (let dy = -ring; dy <= ring; dy++) { diff --git a/js/galaxy/JumpNetwork.js b/js/galaxy/JumpNetwork.js index 68eac77..90e2c75 100644 --- a/js/galaxy/JumpNetwork.js +++ b/js/galaxy/JumpNetwork.js @@ -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; diff --git a/js/galaxy/SystemChart.js b/js/galaxy/SystemChart.js index 2d6f9d1..01d4107 100644 --- a/js/galaxy/SystemChart.js +++ b/js/galaxy/SystemChart.js @@ -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 diff --git a/js/ui/GalaxyView.js b/js/ui/GalaxyView.js index 3d34c60..dbef49e 100644 --- a/js/ui/GalaxyView.js +++ b/js/ui/GalaxyView.js @@ -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