Redesign galaxy as sparse 60-system spanning-tree maze without rendered
- Shrink the roster from 200 to 60 systems and drop spiral arms / core bulge for a uniform-in-area sparse disk (data/galaxy.json) - Turn the jump network into a pure spanning tree (shortcuts off): exactly one route between any two systems, barren systems become single-gate dead-end leaves - Remove the rendered Star entity; non-home system centers are now empty space with the star kept only as invisible dossier flavor - Gate anchors restricted to planets (stations no longer anchor) and every non-barren system guaranteed ≥1 planet so a buildable world always exists - Give barren dead ends 1–2 asteroid clusters drifting inside their gate's level-1 tether as the stop's only payload - Bump save format to 2 and reject legacy format-1 saves with a clear toast message - Update tests, docs, and README to reflect the maze layout, leaf dead ends, empty centers, and new barren cluster rules
This commit is contained in:
parent
59ef952d5d
commit
963b2b5bc2
32
README.md
32
README.md
|
|
@ -36,10 +36,11 @@ 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**: 200 star systems in a seeded disk + core +
|
||||
spiral arms (`data/galaxy.json`), typed into six themed archetypes
|
||||
(`data/systems.json`) with per-type distribution weights and radial
|
||||
bands — the first "how does the galaxy lay itself out" rules.
|
||||
- **Procedural galaxy**: 60 star systems in a sparse seeded disk — no
|
||||
spiral arms, no core bulge, uniform-in-area (`data/galaxy.json`), typed
|
||||
into six themed archetypes (`data/systems.json`) with per-type
|
||||
distribution weights and radial bands — the first "how does the galaxy
|
||||
lay itself out" rules.
|
||||
- **Two-level generation**: the whole galaxy roster is generated at New
|
||||
Game (~15 ms); each system's planets/moons/belts/settlements are
|
||||
generated lazily on arrival, deterministically (seed + system id), so
|
||||
|
|
@ -50,9 +51,15 @@ node dev/server.mjs 8080
|
|||
adrift in open space and beacons, whose odds are per-archetype
|
||||
(`data/systems.json`) and thin out from the settled core to the wilder
|
||||
rim (`data/galaxy.json`). **Barren systems** (~10%, the
|
||||
`objectCount` → 0 stops) hold nothing but their jump gate and report
|
||||
*charted · unclaimed*. Non-home systems hold 0/2/3/4/5 objects
|
||||
`objectCount` → 0 stops) are the DEAD-END LEAVES of the jump-gate maze:
|
||||
they hold nothing but their single jump gate (and 1–2 asteroid
|
||||
clusters drifting inside that gate's tether, their only payload) and
|
||||
report *charted · unclaimed*; you enter and exit through the same
|
||||
gate. Non-home systems hold 0/2/3/4/5 objects
|
||||
(planets + free-space stations, `data/systems.json → objectCount`);
|
||||
every non-barren system holds at least one planet, and the gate's
|
||||
tether anchors on one of them (so the player can always build and
|
||||
expand out from the gate);
|
||||
the starting system always holds the home world, a gas giant,
|
||||
and a rocky world. Each settlement has a name, population, and an
|
||||
`owner` seam reserved for the factions/pirates to come.
|
||||
|
|
@ -71,10 +78,11 @@ node dev/server.mjs 8080
|
|||
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
|
||||
in the current system's open space. Every system also holds 1–3 JUMP
|
||||
GATES — solid, discoverable exits that face their destination star —
|
||||
wired into a strongly-connected gate network (data/gates.json); every
|
||||
gate is `active: false` for now — DORMANT (dimmed, field still) — and
|
||||
activation is the seam for the tether mechanic (an activated gate
|
||||
anchors a level-1 tether);
|
||||
wired into a PURE SPANNING TREE gate network (data/gates.json, no
|
||||
shortcuts, no closed loops: exactly one route between any two systems —
|
||||
a maze of dead ends and long hauls); every gate is `active: false` for
|
||||
now — DORMANT (dimmed, field still) — and activation is the seam for
|
||||
the tether mechanic (an activated gate anchors a level-1 tether);
|
||||
the jump drive itself comes next.
|
||||
Discovered worlds get a screen-edge arrow + a name tag showing the
|
||||
world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the
|
||||
|
|
@ -153,10 +161,10 @@ orbit/
|
|||
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
|
||||
│ ├── planets.json # home world + system layout + solid-disc rules
|
||||
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
|
||||
│ ├── galaxy.json # galaxy scale & shape (count, radius, sparse disk…)
|
||||
│ ├── 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, strong connectivity) + 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)
|
||||
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
|
||||
│ │ # trees live one-per-file below (section name = file basename)
|
||||
│ ├── research/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects − planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.",
|
||||
"_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a NON-barren system gets: targetObjects − planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. barren = the dead-end (gate-only) systems: the cluster drifts INSIDE the arrival gate's level-1 tether (tether.json → level1Radius, minus the cluster's extent + tetherMargin) — the stop's only payload: mine it, then head back (clusters = how many, minRadius = nearest a cluster center may sit to the gate, px). placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, jump gates, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in (the starting system's tether-inside clusters and the barren gate clusters are exempt — they sit in their own tether annulus); tetherMargin = how far inside the tether rim a tether-bound cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.",
|
||||
"enabled": true,
|
||||
"texture": "assets/images/asteroids.png",
|
||||
"frameWidth": 128,
|
||||
|
|
@ -105,6 +105,10 @@
|
|||
"maxClusters": 6,
|
||||
"startingSystemMinClusters": 2
|
||||
},
|
||||
"barren": {
|
||||
"clusters": [1, 2],
|
||||
"minRadius": 1024
|
||||
},
|
||||
"placement": {
|
||||
"minObjectSpacing": 1024,
|
||||
"minRadius": 2048,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"systemCount": 200,
|
||||
"_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout = a sparse seeded DISK: no spiral arms (spiral.enabled = false, arms = 0 — the arm snap is skipped), no center bulge (coreFraction = 0), diskSkew = 0.5 so the radial spread is uniform-in-area (rNorm = √x — stars spread out sparsely across the disk instead of crowding the core), flatten = y squash (ellipse). settlements.gradient = free-space settlement density by galactic radius (rNorm 0 = center, 1 = rim): chance = 1 − rNorm×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).",
|
||||
"systemCount": 60,
|
||||
"radius": 20000,
|
||||
"layout": {
|
||||
"coreFraction": 0.25,
|
||||
"coreFraction": 0,
|
||||
"bulgeSigma": 0.09,
|
||||
"diskSkew": 1.7,
|
||||
"diskSkew": 0.5,
|
||||
"flatten": 0.62,
|
||||
"spiral": { "enabled": true, "arms": 2, "twist": 2.6, "strength": 0.5 }
|
||||
"spiral": { "enabled": false, "arms": 0, "twist": 2.6, "strength": 0.5 }
|
||||
},
|
||||
"distribution": {
|
||||
"rules": []
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets, no unreachable stars), every jump is to a nearby star, and no system ever holds more than maxGates gates. Single-gate systems (e.g. the barren ones) read as dead-end corridors — enter from a neighbor, exit through the gate — which is fine as long as they are connected to the main network, which strong connectivity guarantees. PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the gate sits ON the ray from the star toward its destination, `barrenDistance` px from the star (stepped outward within the minRadius..maxRadius band if two gates would otherwise violate the gap), facing its destination. ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
|
||||
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a PLANET, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
|
||||
"enabled": true,
|
||||
"minGates": 1,
|
||||
"maxGates": 3,
|
||||
"neighborPool": 8,
|
||||
"shortcuts": true,
|
||||
"shortcuts": false,
|
||||
"size": 96,
|
||||
"texture": "assets/images/jumpgate.png",
|
||||
"frameWidth": 256,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 200-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).",
|
||||
"_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 60-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).",
|
||||
"star": {
|
||||
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
|
||||
"minParts": 2,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + 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 — present ONLY at the origin of the system they start in (the starting system's central body); every other system's central body is its star (`star` below). 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). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
|
||||
"_comment": "Planet visuals + home-planet rules + the invisible star's flavor (class colors — non-home systems have no central body) + 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 — present ONLY at the origin of the system they start in (the starting system's central body); every OTHER system's center is empty — the star is invisible flavor (`star` below: name/class feed the dossier and gate names, never rendered). 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). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the home world — at the local origin of the starting system only; every other center is empty) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
|
||||
"texture": "assets/images/planets.png",
|
||||
"frameWidth": 1024,
|
||||
"frameHeight": 1024,
|
||||
|
|
@ -35,10 +35,8 @@
|
|||
"homeName": "Terra",
|
||||
"homeTypeLabel": "Home World",
|
||||
"star": {
|
||||
"_comment": "The central body of a NON-HOME system — the system's star (content.star from the generator; class = the spectral type G/K/M/…). The starting system's central body is the home world (homePlanet) and only there — a star is drawn in every other system. size = the SOLID disc's diameter px (1024 = a Terran world; the halo is visual only, outside the keep-out). classColor = the disc's hue per spectral class (a white core melts into it, js/entities/Star.js). glow.radiusFactor = halo radius as a multiple of the disc radius; glow.alpha = halo strength at the rim.",
|
||||
"size": 1536,
|
||||
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" },
|
||||
"glow": { "radiusFactor": 1.55, "alpha": 0.5 }
|
||||
"_comment": "The system's INVISIBLE star — flavor, not a body. Every non-home system's center is empty (the star is never rendered; js/entities/Star.js is gone); this section feeds the dossier's identity (content.star) and the class colors used by the diagnostics and the map plate tag. classColor = the class's hue (G/K/M/… → hex).",
|
||||
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" }
|
||||
},
|
||||
"starTypeLabels": {
|
||||
"O": "O-type Star",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a jump-gate-only system — star and gates, nothing else), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
|
||||
"objectCount": {
|
||||
"barren": 0.10,
|
||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
* - COUNT vs PLANETS: clusterCount ≈ targetObjects − planetCount (±
|
||||
* jitter, clamped to [minClusters, maxClusters]) — so systems with
|
||||
* more planets get fewer clusters and vice versa (also checked as an
|
||||
* aggregate correlation across the non-barren sample); barren systems
|
||||
* (jump-gate-only) hold NO clusters;
|
||||
* aggregate correlation across the non-barren sample); BARREN
|
||||
* systems (gate-only dead-end leaves) are the one exception — they
|
||||
* hold 1–2 clusters (their payload) INSIDE the arrival gate's
|
||||
* level-1 tether (data/asteroids.json → barren);
|
||||
* - the STARTING system always gets ≥ startingSystemMinClusters, and
|
||||
* those first ones sit INSIDE the initial tether (whole cluster,
|
||||
* minus placement.tetherMargin);
|
||||
|
|
@ -18,9 +20,10 @@
|
|||
* every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration),
|
||||
* bound = max(offset + size/2);
|
||||
* - SPACING: no cluster within 1024 px (center-to-center) of ANY other
|
||||
* object — home world (origin), planets, free-space stations, other
|
||||
* clusters — and every cluster sits in the [minRadius, maxRadius]
|
||||
* annulus around the origin;
|
||||
* solid object — the home world (origin, starting system only),
|
||||
* planets, free-space stations, other clusters — and every non-barren
|
||||
* cluster sits in the [minRadius, maxRadius] annulus around the origin
|
||||
* (barren clusters orbit their gate instead);
|
||||
* - MOTION: each rock's spin and the group drift are within the
|
||||
* configured slow-spin ranges;
|
||||
* - NAMES: synthesised, and never repeating a name in the same system;
|
||||
|
|
@ -122,12 +125,16 @@ for (const rec of sample) {
|
|||
// target — targetObjects − planetCount, ±jitter, clamped into [min, max]
|
||||
// (and [startingMin, max] for the home system). When the ideal band
|
||||
// clamps to empty, the clamped range itself is the contract.
|
||||
// BARREN systems (0 planets + 0 free-space stations — the jump-gate-only
|
||||
// stops) are the one exception: no clusters by design.
|
||||
// BARREN systems (0 planets + 0 free-space stations — the gate-only
|
||||
// dead-end leaves) are the one exception: 1–2 clusters (barren.clusters)
|
||||
// as their payload, placed around the arrival gate.
|
||||
const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space');
|
||||
if (barren) {
|
||||
if (clusters.length !== 0) {
|
||||
countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want 0)`; break;
|
||||
const bc = A.barren ?? {};
|
||||
const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
|
||||
const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
|
||||
if (clusters.length < lo || clusters.length > hi) {
|
||||
countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want ${lo}–${hi})`; break;
|
||||
}
|
||||
} else {
|
||||
const want = D.targetObjects - c.planets.length;
|
||||
|
|
@ -144,11 +151,19 @@ for (const rec of sample) {
|
|||
for (const p of c.planets) used.add(p.name);
|
||||
for (const s of c.settlements ?? []) if (s.name) used.add(s.name);
|
||||
|
||||
// Spacing obstacles.
|
||||
const objects = [{ x: 0, y: 0 }];
|
||||
// Spacing obstacles — solids only (the center holds the home world in
|
||||
// the starting system and is EMPTY in every other system).
|
||||
const objects = isHome ? [{ x: 0, y: 0 }] : [];
|
||||
for (const p of c.planets) if (typeof p.x === 'number') objects.push({ x: p.x, y: p.y });
|
||||
for (const s of c.settlements ?? []) if (s.anchor?.type === 'space' && typeof s.x === 'number') objects.push({ x: s.x, y: s.y });
|
||||
|
||||
// Barren payload: the clusters orbit the arrival gate, inside its
|
||||
// level-1 tether (and at least barren.minRadius out from it).
|
||||
const gate = (c.jumps ?? [])[0];
|
||||
const bMin = A.barren?.minRadius ?? 1024;
|
||||
const gTether = config.get('tether.level1Radius', 5120);
|
||||
const margin = P.tetherMargin ?? 96;
|
||||
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const cl = clusters[i];
|
||||
|
||||
|
|
@ -209,7 +224,7 @@ for (const rec of sample) {
|
|||
}
|
||||
used.add(cl.name);
|
||||
|
||||
// --- Spacing: ≥ 1024 px from EVERY other object ----------------------
|
||||
// --- Spacing: ≥ 1024 px from every other SOLID ----------------------
|
||||
for (const o of objects) {
|
||||
const d = Math.hypot(cl.x - o.x, cl.y - o.y);
|
||||
if (d < P.minObjectSpacing - 1e-6) {
|
||||
|
|
@ -218,26 +233,37 @@ for (const rec of sample) {
|
|||
}
|
||||
if (!spacingOk) break;
|
||||
|
||||
// --- Scatter annulus ---------------------------------------------------
|
||||
const dist0 = Math.hypot(cl.x, cl.y);
|
||||
if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) {
|
||||
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}–${P.maxRadius})`; break;
|
||||
// --- Barren payload: inside the gate's tether ------------------------
|
||||
if (barren) {
|
||||
if (!gate || typeof gate.x !== 'number') {
|
||||
annulusOk = false; annulusWhy = `${rec.id}#${i}: barren cluster but no gate to anchor on`; break;
|
||||
}
|
||||
const dg = Math.hypot(cl.x - gate.x, cl.y - gate.y);
|
||||
if (dg < bMin - 1e-6 || dg + cl.bound + margin > gTether + 1e-6) {
|
||||
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dg)} px from the gate (want ${bMin}–${gTether}, bound ${Math.round(cl.bound)} + margin ${margin})`; break;
|
||||
}
|
||||
} else {
|
||||
// --- Scatter annulus (non-barren: around the origin) ---------------
|
||||
const dist0 = Math.hypot(cl.x, cl.y);
|
||||
if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) {
|
||||
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}–${P.maxRadius})`; break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Starting-system tether guarantee ---------------------------------
|
||||
if (isHome && i < D.startingSystemMinClusters) {
|
||||
if (dist0 + cl.bound + (P.tetherMargin ?? 96) > TETHER + 1e-6) {
|
||||
homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(dist0 + cl.bound)} + margin > ${TETHER})`; break;
|
||||
if (Math.hypot(cl.x, cl.y) + cl.bound + margin > TETHER + 1e-6) {
|
||||
homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(Math.hypot(cl.x, cl.y) + cl.bound)} + margin > ${TETHER})`; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break;
|
||||
}
|
||||
|
||||
check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ 0)${countOk ? '' : ' — ' + countWhy}`, countOk);
|
||||
check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ ${A.barren?.clusters?.join('–') ?? '1–2'})${countOk ? '' : ' — ' + countWhy}`, countOk);
|
||||
check(`shape: groups of ${CL.groupSize.min}–${CL.groupSize.max}, sizes ${CL.sizes.min}–${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk);
|
||||
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
|
||||
check(`scatter: every cluster inside the ${P.minRadius}–${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
|
||||
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY solid (home world, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
|
||||
check(`scatter: non-barren clusters inside the ${P.minRadius}–${P.maxRadius} annulus; barren payload inside the gate's level-1 tether${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
|
||||
check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk);
|
||||
check('names: synthesised, never repeating a name in the same system', namesOk);
|
||||
const homeClusters = g.ensureContent(homeId).asteroids;
|
||||
|
|
@ -247,8 +273,9 @@ check(
|
|||
);
|
||||
|
||||
// The inverse rule, in aggregate: rockier systems (few planets) get more
|
||||
// clusters than planet-rich ones — over the NON-BARREN systems (barren are
|
||||
// jump-gate-only and hold 0 clusters by design, a separate rule).
|
||||
// clusters than planet-rich ones — over the NON-BARREN systems (barren
|
||||
// dead ends carry their fixed 1–2 payload clusters around the gate, a
|
||||
// separate rule).
|
||||
{
|
||||
const rows = sample.map((r) => {
|
||||
const c = g.ensureContent(r.id);
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@
|
|||
* - every generated system obeys its type's attribute bounds;
|
||||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||||
* non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space
|
||||
* stations), ≈ 10% barren — jump-gate-only stops;
|
||||
* stations), ≈ 10% barren — the gate-only dead-end leaves of the
|
||||
* jump network (the maze's dead ends);
|
||||
* - lazy (on-arrival) content === eager (generateAll) content;
|
||||
* - spatial-hash neighbor queries agree with brute force;
|
||||
* - starting system policy works.
|
||||
* Also reports generation timing for the 200-system galaxy.
|
||||
* Also reports generation timing for the 60-system galaxy.
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -170,11 +171,15 @@ let big;
|
|||
check('every non-home system holds 0/2/3/4/5 objects (the global objectCount rule)', boundsOk);
|
||||
const emptyShare = emptyCount / nSys;
|
||||
const barrenExpect = OC.barren ?? 0.1;
|
||||
const barrenSd = Math.sqrt(barrenExpect * (1 - barrenExpect) / nSys);
|
||||
check(
|
||||
`≈ ${Math.round(barrenExpect * 100)}% of systems are barren — jump-gate-only (observed ${(emptyShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(emptyShare - barrenExpect) < 0.08,
|
||||
`≈ ${Math.round(barrenExpect * 100)}% of systems are barren — the gate-only dead-end leaves (observed ${(emptyShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(emptyShare - barrenExpect) < 4 * barrenSd + 0.004,
|
||||
);
|
||||
check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys);
|
||||
// (The barren-share check below uses the same ±4σ band as the other
|
||||
// composition checks — at 60 systems the count is small, so an absolute
|
||||
// 0.08 band was tighter than the sampling noise.)
|
||||
|
||||
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
|
||||
const fresh = Galaxy.create(SEED);
|
||||
|
|
@ -315,18 +320,20 @@ let big;
|
|||
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
|
||||
|
||||
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
|
||||
// unsettled ones — jump-gate-only stops, deliberately (strong
|
||||
// connectivity keeps them reachable); every non-barren system is settled
|
||||
// (all its planets + its free-space stations).
|
||||
// unsettled ones — the gate-only dead-end leaves of the jump network
|
||||
// (the spanning tree keeps them reachable: the maze's dead ends, in and
|
||||
// out the same way); every non-barren system is settled (all its
|
||||
// planets + its free-space stations).
|
||||
let barren = 0;
|
||||
for (const r of sample) {
|
||||
const c = big2.ensureContent(r.id);
|
||||
if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++;
|
||||
}
|
||||
const barrenShare = barren / sample.length;
|
||||
const barrenP = config.get('systems.objectCount.barren', 0.1);
|
||||
check(
|
||||
`barren systems are jump-gate-only: ≈ ${Math.round((config.get('systems.objectCount.barren', 0.1) * 100))}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(barrenShare - (config.get('systems.objectCount.barren', 0.1))) < 0.08,
|
||||
`barren systems are the dead-end leaves: ≈ ${Math.round(barrenP * 100)}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(barrenShare - barrenP) < 4 * Math.sqrt(barrenP * (1 - barrenP) / sample.length) + 0.004,
|
||||
);
|
||||
check(
|
||||
'every non-barren system is settled (the home system is exempt)',
|
||||
|
|
|
|||
|
|
@ -11,25 +11,36 @@
|
|||
* - the network is STRONGLY CONNECTED: from the home system every
|
||||
* other system is reachable (forward BFS) AND every system can
|
||||
* reach home (reverse BFS) — no closed systems, no trapped sets;
|
||||
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config):
|
||||
* exactly N−1 undirected edges — no loops, one route between any two
|
||||
* systems (the maze) — and every BARREN system (objectCount → 0) is
|
||||
* a LEAF: exactly one gate, in and out the same way (the dead end);
|
||||
*
|
||||
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
|
||||
* - content.jumps matches the network (count + destinations);
|
||||
* - ANCHORED systems (a planet, a free-space station, or the home world):
|
||||
* every gate is within level-1 tether (tether.level1Radius) of an
|
||||
* anchor and FACES its destination star on the 2-D map (soft rule —
|
||||
* within 90° of the system→star bearing from the anchoring object);
|
||||
* - BARREN systems (objectCount → 0 — a jump-gate-only stop): the gate
|
||||
* sits ON the ray toward its destination, ≥ gates.barrenDistance from
|
||||
* the star, within the radius band, facing it;
|
||||
* - gates stay gates.minRadius..gates.maxRadius from the star;
|
||||
* - gates keep size+clearance from anchor discs and 2·size+gateGap
|
||||
* from each other;
|
||||
* - ANCHORED systems (a planet — or the home world in the starting
|
||||
* system): every gate is within level-1 tether (tether.level1Radius)
|
||||
* of a planet anchor and FACES its destination star on the 2-D map
|
||||
* (soft rule — within 90° of the system→star bearing from the
|
||||
* anchoring object); stations are NOT anchors (the player must be
|
||||
* able to build out from a world), but they DO get clearance;
|
||||
* - every non-barren system holds at least ONE planet (the gate's
|
||||
* anchor is guaranteed to exist);
|
||||
* - BARREN systems (objectCount → 0 — the network's dead-end leaves):
|
||||
* the gate sits ON the ray toward its destination, in the radius
|
||||
* band, facing it — and 1–2 asteroid clusters drift INSIDE the
|
||||
* gate's level-1 tether (the stop's only payload, data/asteroids.json
|
||||
* → barren);
|
||||
* - gates stay gates.minRadius..gates.maxRadius from the center;
|
||||
* - gates keep size+clearance from every solid disc (planets +
|
||||
* stations + the home world) and 2·size+gateGap from each other;
|
||||
* - gate ids/names are unique per system;
|
||||
* - every gate record carries active: false (data/gates.json → ACTIVITY);
|
||||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||||
* non-home system holds 0, 2, 3, 4, or 5 objects (planets +
|
||||
* free-space stations) in the configured proportions; a barren
|
||||
* system holds nothing else — no asteroid clusters either;
|
||||
* system holds no planets or stations — its payload is the gate's
|
||||
* asteroid cluster;
|
||||
*
|
||||
* and DETERMINISM: same seed ⇒ same network, same gates, same layout;
|
||||
* different seed ⇒ different network (spot check).
|
||||
|
|
@ -137,6 +148,32 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s);
|
||||
}
|
||||
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
|
||||
|
||||
// TREE (shortcuts OFF in data/gates.json): exactly N−1 undirected
|
||||
// edges — no loops, no redundant routes (the maze's single path).
|
||||
const pairs = new Set();
|
||||
let treeEdges = 0;
|
||||
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
|
||||
const key = [r.id, t.id].sort().join('\u0000');
|
||||
if (!pairs.has(key)) pairs.add(key), treeEdges++;
|
||||
}
|
||||
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
|
||||
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
|
||||
|
||||
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
|
||||
// exactly one gate, in and out the same way. (Home is exempt — it
|
||||
// always holds planets.)
|
||||
const leafBad = [];
|
||||
for (const r of g.records) {
|
||||
if (r.id === HOME) continue;
|
||||
const c = g.ensureContent(r.id);
|
||||
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||||
const isBarren = c.planets.length === 0 && spaceCount === 0;
|
||||
if (!isBarren) continue;
|
||||
const n = g.jumpGatesFor(r.id).length;
|
||||
if (n !== 1) leafBad.push(`${r.id}:${n}`);
|
||||
}
|
||||
check('every barren system is a leaf — exactly one gate (the dead end)', leafBad.length === 0, leafBad.slice(0, 5).join(', '));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -166,12 +203,19 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
active++;
|
||||
if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`;
|
||||
}
|
||||
// Anchors: planets, free-space stations, and (home) the home world.
|
||||
// ANCHORS: the PLANETS and (home only) the home world — the bodies a
|
||||
// gate's tether may hang from (stations are NOT anchors — the player
|
||||
// must be able to build out from a world).
|
||||
const anchors = [
|
||||
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
|
||||
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
|
||||
];
|
||||
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
|
||||
// CLEARANCE DISCS: every solid body the gate keeps clear of — the
|
||||
// anchors plus the free-space stations.
|
||||
const discs = [
|
||||
...anchors,
|
||||
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
|
||||
];
|
||||
|
||||
c.jumps.forEach((j, gi) => {
|
||||
const t = recOf.get(j.to);
|
||||
|
|
@ -213,14 +257,15 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
|
||||
}
|
||||
}
|
||||
// RADIUS band from the star.
|
||||
// RADIUS band from the center.
|
||||
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
|
||||
radius++;
|
||||
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the star (band ${MIN_R}..${MAX_R})`;
|
||||
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the center (band ${MIN_R}..${MAX_R})`;
|
||||
}
|
||||
// CLEARANCE from anchor discs (every disc in this game is under
|
||||
// 800 px across, so a size + 100 px floor is a fair check).
|
||||
for (const a of anchors) {
|
||||
// CLEARANCE from every solid disc (planets + stations + home world
|
||||
// — every disc in this game is under 800 px across, so a size +
|
||||
// 100 px floor is a fair check).
|
||||
for (const a of discs) {
|
||||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||||
if (d < SIZE + 100) {
|
||||
clearance++;
|
||||
|
|
@ -247,11 +292,11 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
}
|
||||
}
|
||||
check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
|
||||
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy);
|
||||
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet anchor`, tether === 0, tWhy);
|
||||
check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
|
||||
check(`barren gates sit on the target ray, ≥ ${BARN_D} px from the star, facing it`, barrenBad === 0, bWhy);
|
||||
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the star`, radius === 0, rWhy);
|
||||
check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy);
|
||||
check(`barren gates sit on the target ray, in the band ${MIN_R}..${MAX_R}, facing it`, barrenBad === 0, bWhy);
|
||||
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
|
||||
check(`clearance: every gate keeps ${SIZE} + 100 px from solid discs (planets + stations + home)`, clearance === 0, cWhy);
|
||||
check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
|
||||
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
|
||||
check('every gate record is active:false (inert until activation)', active === 0, bWhy);
|
||||
|
|
@ -259,12 +304,19 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
// The OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||||
// non-home system holds 0, 2, 3, 4, or 5 objects (planets +
|
||||
// free-space stations) in the configured proportions — 0 = a barren,
|
||||
// jump-gate-only stop (no asteroid clusters either).
|
||||
// jump-gate-only dead end (the network's leaves). Every NON-barren
|
||||
// system holds at least ONE PLANET (the gate's anchor — the player must
|
||||
// be able to build out from a world), and a barren system's payload is
|
||||
// 1–2 asteroid clusters INSIDE its single gate's level-1 tether.
|
||||
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
|
||||
const expected = { 0: OC.barren ?? 0.1 };
|
||||
for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w;
|
||||
const counts = {};
|
||||
let shapeBad = 0, whyShape = '', barrenClusters = 0;
|
||||
let shapeBad = 0, whyShape = '';
|
||||
let noPlanet = 0, whyPlanet = '', barrenCl = 0, whyCl = '';
|
||||
const AC = config.section('asteroids', {});
|
||||
const clLo = Math.max(0, Math.floor(AC.barren?.clusters?.[0] ?? 1));
|
||||
const clHi = Math.max(clLo, Math.floor(AC.barren?.clusters?.[1] ?? 2));
|
||||
for (const r of g.records) {
|
||||
if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets)
|
||||
const c = g.ensureContent(r.id);
|
||||
|
|
@ -274,7 +326,34 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
shapeBad++;
|
||||
if (!whyShape) whyShape = `${r.id}: ${n} objects`;
|
||||
}
|
||||
if (n === 0 && (c.asteroids ?? []).length > 0) barrenClusters++;
|
||||
// ≥ 1 PLANET in every non-barren system (the gate's anchor is real).
|
||||
if (n > 0 && c.planets.length === 0) {
|
||||
noPlanet++;
|
||||
if (!whyPlanet) whyPlanet = `${r.id}: ${n} objects, 0 planets`;
|
||||
}
|
||||
// BARREN: the dead end's payload — clLo..clHi clusters, each inside
|
||||
// its single gate's level-1 tether (center + extent ≤ tether rim).
|
||||
if (n === 0) {
|
||||
const cl = c.asteroids ?? [];
|
||||
if (cl.length < clLo || cl.length > clHi) {
|
||||
barrenCl++;
|
||||
if (!whyCl) whyCl = `${r.id}: ${cl.length} clusters (want ${clLo}..${clHi})`;
|
||||
continue;
|
||||
}
|
||||
const gate = (c.jumps ?? [])[0];
|
||||
if (!gate) {
|
||||
barrenCl++;
|
||||
if (!whyCl) whyCl = `${r.id}: barren but no gate to hang the cluster on`;
|
||||
continue;
|
||||
}
|
||||
for (const a of cl) {
|
||||
const d = Math.hypot(a.x - gate.x, a.y - gate.y);
|
||||
if (d - (a.bound ?? 0) > TETHER + 1e-6) {
|
||||
barrenCl++;
|
||||
if (!whyCl) whyCl = `${r.id}: cluster at ${Math.round(d)} px from the gate (+extent > ${TETHER})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const nN = g.records.length - 1;
|
||||
let distOk = true;
|
||||
|
|
@ -288,7 +367,8 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
}
|
||||
check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape);
|
||||
check('composition: the object counts match the configured proportions (±4σ)', distOk);
|
||||
check('barren systems are truly barren — no asteroid clusters', barrenClusters === 0, `${barrenClusters} with clusters`);
|
||||
check('every non-barren system holds ≥ 1 planet (the gate anchor exists)', noPlanet === 0, whyPlanet);
|
||||
check(`barren dead ends carry ${clLo}–${clHi} clusters INSIDE the gate's level-1 tether`, barrenCl === 0, whyCl);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
/**
|
||||
* dev/nav-reach-probe.mjs — soft-lock probe (path-aware): which systems'
|
||||
* NAV charts can NEVER be completed under the game's actual progression?
|
||||
*
|
||||
* node dev/nav-reach-probe.mjs [seed ...]
|
||||
*
|
||||
* Model (mirrors GameScene + SystemCategory):
|
||||
* - To jump Y→X the player must have RESEARCHED Y's gate tech, so every
|
||||
* system jumped OUT of is researched; arrival at X from Y ⇒ Y (and
|
||||
* everything before it on the path, back to home) is researched.
|
||||
* - On entry, X's ACTIVE gates = the gates from X to any researched
|
||||
* system (those return gates were flipped by the neighbor's tech).
|
||||
* - The player's room to move = disc(star, 5120) ∪ disc(activeGate, 5120)
|
||||
* for each active gate. A NAV point is discoverable iff that union
|
||||
* comes within (radius + 540) of the point.
|
||||
* - A system is clearable iff SOME researched neighbor lets it chart.
|
||||
* Clear systems stay clear (they become researched and only add more
|
||||
* active gates for their neighbors).
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(join(root, 'data', 'manifest.json'), 'utf8'));
|
||||
const files = {};
|
||||
for (const f of manifest.files) files[f.split('/').pop().replace('.json', '')] = JSON.parse(fs.readFileSync(join(root, 'data', f), 'utf8'));
|
||||
const { config } = await import(join(root, 'js', 'config', 'Config.js'));
|
||||
config.init(files);
|
||||
|
||||
const { Galaxy } = await import(join(root, 'js', 'galaxy', 'Galaxy.js'));
|
||||
const { navPoints } = await import(join(root, 'js', 'research', 'SystemCategory.js'));
|
||||
|
||||
const TETHER = config.get('tether.level1Radius', 5120);
|
||||
const DISC = config.get('game.discovery.distance', 540);
|
||||
const GATE_R = config.get('gates.size', 96);
|
||||
const STAR_R = config.get('planets.star.size', 1536) / 2;
|
||||
const homeTether = TETHER * Math.pow(config.get('tether.radiusGrowth', 2), Math.max(1, config.get('tether.homeLevel', 1)) - 1);
|
||||
|
||||
function systemOf(galaxy, id) {
|
||||
const content = galaxy.ensureContent(id);
|
||||
const rec = galaxy.byId.get(id);
|
||||
const pts = navPoints(content).map((p) => {
|
||||
let pos = { x: 0, y: 0 };
|
||||
let r = STAR_R;
|
||||
if (p.kind === 'planet') {
|
||||
const q = content.planets.find((x) => x.name === p.id);
|
||||
pos = q; r = (1024 * (config.get(`planets.classScale.${q.class}`, 1))) / 2;
|
||||
} else if (p.kind === 'gate') {
|
||||
pos = content.jumps.find((j) => j.id === p.id); r = GATE_R;
|
||||
} else if (p.kind === 'station') {
|
||||
pos = content.settlements.find((s) => s.id === p.id); r = 108;
|
||||
}
|
||||
return { id: p.id, kind: p.kind, x: pos.x, y: pos.y, r };
|
||||
});
|
||||
const gates = (content.jumps ?? []).filter((j) => typeof j.x === 'number');
|
||||
return {
|
||||
id,
|
||||
name: rec.name,
|
||||
isHome: id === galaxy.homeSystemId,
|
||||
planets: content.planets.length,
|
||||
stations: (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
|
||||
gates,
|
||||
asteroids: (content.asteroids ?? []).length,
|
||||
pts,
|
||||
neighbors: gates.map((g) => g.to),
|
||||
};
|
||||
}
|
||||
|
||||
function chartable(sys, researched) {
|
||||
const active = sys.gates.filter((g) => researched.has(g.to));
|
||||
const discs = [{ x: 0, y: 0, r: sys.isHome ? homeTether : TETHER }, ...active.map((g) => ({ x: g.x, y: g.y, r: TETHER }))];
|
||||
const missing = sys.pts.filter((p) => {
|
||||
let best = Infinity;
|
||||
for (const c of discs) {
|
||||
const d = Math.hypot(p.x - c.x, p.y - c.y) - c.r;
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best > p.r + DISC;
|
||||
});
|
||||
return { chartable: missing.length === 0, missing, activeCount: active.length };
|
||||
}
|
||||
|
||||
const seeds = process.argv.slice(2).length ? process.argv.slice(2) : ['probe1'];
|
||||
for (const seed of seeds) {
|
||||
const galaxy = Galaxy.create(seed, { systemCount: 200 });
|
||||
const all = [...galaxy.byId.keys()].map((id) => systemOf(galaxy, id));
|
||||
const byId = new Map(all.map((s) => [s.id, s]));
|
||||
|
||||
// Progression: BFS of clearable systems, researching each as it clears.
|
||||
const researched = new Set([galaxy.homeSystemId]);
|
||||
let frontier = [galaxy.homeSystemId];
|
||||
while (frontier.length) {
|
||||
const next = [];
|
||||
for (const id of frontier) {
|
||||
for (const s of all) {
|
||||
if (s.isHome || researched.has(s.id)) continue;
|
||||
if (!s.neighbors.includes(id)) continue; // entry neighbor
|
||||
const r2 = new Set(researched);
|
||||
if (!r2.has(id)) r2.add(id);
|
||||
if (chartable(s, r2).chartable) {
|
||||
researched.add(s.id);
|
||||
next.push(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
const stuck = all.filter((s) => !s.isHome && !researched.has(s.id));
|
||||
console.log(`\n=== seed ${seed}: ${researched.size - 1}/${all.length - 1} non-home systems clearable, ${stuck.length} stuck ===`);
|
||||
for (const s of stuck.slice(0, 8)) {
|
||||
// best explanation: via which neighbor, which points remain missing
|
||||
let best = null;
|
||||
for (const n of new Set(s.neighbors)) {
|
||||
if (!byId.has(n)) continue;
|
||||
const r2 = new Set(researched);
|
||||
if (!r2.has(n)) r2.add(n);
|
||||
const c = chartable(s, r2);
|
||||
if (!best || c.missing.length < best.missing.length) best = { via: n, ...c };
|
||||
}
|
||||
console.log(
|
||||
` ${s.id}: planets=${s.planets} stations=${s.stations} gates=${s.gates.length} asteroids=${s.asteroids}` +
|
||||
` → best entry via ${best?.via}: missing=[${best?.missing.map((m) => m.kind).join(',')}] activeGates=${best?.activeCount}`
|
||||
);
|
||||
}
|
||||
// Also: systems matching "1 planet, 3 gates" (the user's shape)
|
||||
const one = all.filter((s) => !s.isHome && s.planets === 1 && s.stations === 0 && s.gates.length === 3);
|
||||
console.log(` systems with 1 planet + 3 gates: ${one.map((s) => `${s.id}(stuck=${!researched.has(s.id)})`).join(' ')}`);
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ const fakeScene = () => ({
|
|||
|
||||
const makeRec = (over = {}) => ({
|
||||
app: 'orbit',
|
||||
format: 1,
|
||||
format: SAVE_FORMAT, // a record of the CURRENT format (older builds' saves are rejected)
|
||||
savedAt: '2026-07-15T00:00:00.000Z',
|
||||
seed: SEED,
|
||||
galaxyName: galaxy.name,
|
||||
|
|
@ -183,6 +183,13 @@ const makeStorage = (fail = false) => {
|
|||
check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null);
|
||||
check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null);
|
||||
check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null);
|
||||
// The galaxy-redesign gate: an old build's save (format 1) references a
|
||||
// world that no longer exists — rejected with a clear, toast-able message.
|
||||
const legacy = makeRec({ format: 1 });
|
||||
const legacyErr = SaveManager.validateRecord(legacy);
|
||||
check('validateRecord: a legacy (format 1) save is rejected', typeof legacyErr === 'string' && legacyErr.length > 0);
|
||||
check('validateRecord: the legacy message names the format gap (the UI toasts it)',
|
||||
typeof legacyErr === 'string' && legacyErr.includes('1') && legacyErr.includes(String(SAVE_FORMAT)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
/**
|
||||
* Star test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node dev/star.test.mjs
|
||||
*
|
||||
* Stubs just enough of Phaser to construct the REAL Star from
|
||||
* js/entities/Star.js against the real data/planets.json config, then
|
||||
* asserts the central-body rules (the star is the central body of every
|
||||
* NON-HOME system — see js/scenes/GameScene.js):
|
||||
* - the solid rim is half the configured star.size (1024-class worlds,
|
||||
* a bigger star: 1536 px across by default);
|
||||
* - the ship can come to shipClearance px (edge-to-edge) from the rim,
|
||||
* but never closer, and never moves through the star;
|
||||
* - a graze keeps its tangential velocity (slides along the rim);
|
||||
* - edgePoint() places a point exactly `gap` off the rim;
|
||||
* - one texture per spectral class (generated once, keyed star-<cls>).
|
||||
*/
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Phaser stub: Image + Graphics + Display ------------------------------
|
||||
class Image {
|
||||
constructor(scene, x, y, key, frame) {
|
||||
this.scene = scene; this.x = x; this.y = y;
|
||||
this.key = key; this.frame = frame;
|
||||
this.scaleX = 1; this.scaleY = 1;
|
||||
this.scale = 1;
|
||||
}
|
||||
setScale(s) { this.scaleX = s; this.scaleY = s; this.scale = s; return this; }
|
||||
}
|
||||
const Sprite = class extends Image {};
|
||||
class Graphics {
|
||||
fillStyle() { return this; }
|
||||
fillCircle() { return this; }
|
||||
generateTexture(key, w, h) { made.push({ key, w, h }); return this; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
function hexToRgbInt(v) {
|
||||
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
|
||||
return m ? parseInt(m[1], 16) : 0xffffff;
|
||||
}
|
||||
const made = []; // generateTexture calls
|
||||
globalThis.window = {
|
||||
Phaser: {
|
||||
GameObjects: { Image, Sprite },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
|
||||
},
|
||||
};
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton --------
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
console.log('config loaded from data/:', Object.keys(configData).join(', '));
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const { Star } = await import(pathToFileURL(join(__dirname, '../js/entities/Star.js')).href);
|
||||
|
||||
// A fake scene: textures.exists + make.graphics + add.existing.
|
||||
const scene = {
|
||||
add: { existing: (o) => o },
|
||||
textures: { exists: (k) => made.some((t) => t.key === k) },
|
||||
make: { graphics: ({ add } = {}) => new Graphics() },
|
||||
};
|
||||
|
||||
// --- 1. Real config: solid rim + keep-out distances -----------------------
|
||||
// Off-origin on purpose: proves nothing is baked to (0, 0).
|
||||
const star = new Star(scene, 100, -50, { name: 'Kestrel', class: 'k' });
|
||||
const wantSize = config.get('planets.star.size', 1536);
|
||||
const wantRim = wantSize / 2;
|
||||
|
||||
check(`star rim is half star.size (got ${star.radius}, want ${wantRim})`, star.radius === wantRim);
|
||||
check(`default star is 1536 px across (got ${star.size})`, star.size === 1536);
|
||||
check(`texture key is per-class (got "${star.key}", want "star-k")`, star.key === 'star-k');
|
||||
check(`class is normalized to uppercase (got "${star.starClass}")`, star.starClass === 'K');
|
||||
|
||||
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
|
||||
const clearance = star.clearance;
|
||||
const minDist = star.minCenterDistance(shipRadius);
|
||||
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
|
||||
const dist = (p) => Math.hypot(p.x - star.x, p.y - star.y);
|
||||
|
||||
check(`keep-out = rim + clearance + ship radius (got ${minDist}, want ${wantRim + clearance + shipRadius})`,
|
||||
minDist === wantRim + clearance + shipRadius);
|
||||
|
||||
// --- 2. Outside the keep-out circle: untouched ----------------------------
|
||||
{
|
||||
const s = ship(star.x + minDist + 10, star.y);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('outside the keep-out circle: ship untouched',
|
||||
s.x === star.x + minDist + 10 && s.y === star.y);
|
||||
}
|
||||
|
||||
// Exactly on the boundary: allowed.
|
||||
{
|
||||
const s = ship(star.x + minDist, star.y, -100, 0); // on the line, moving in
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('on the boundary with inward velocity: position held, inward velocity removed',
|
||||
dist(s) === minDist && s.body.velocity.x >= 0);
|
||||
}
|
||||
|
||||
// Deep inside: pushed out to the rim line.
|
||||
{
|
||||
const s = ship(star.x + 10, star.y, -200, 0);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('inside the keep-out circle: pushed out to the rim line',
|
||||
Math.abs(dist(s) - minDist) < 1e-9);
|
||||
}
|
||||
|
||||
// A graze on the rim keeps its tangential velocity (slides along the rim),
|
||||
// and the inward part of any velocity is stripped.
|
||||
{
|
||||
const s = ship(star.x + minDist, star.y, -50, 300); // on the line, partly along the rim
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('graze: tangential velocity kept, inward part stripped (got vx=' + s.body.velocity.x + ', vy=' + s.body.velocity.y + ')',
|
||||
s.body.velocity.x === 0 && s.body.velocity.y === 300 && dist(s) === minDist);
|
||||
}
|
||||
|
||||
// Dead center: pushed out along +x (deterministic).
|
||||
{
|
||||
const s = ship(star.x, star.y, 0, 0);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('dead center: pushed out along +x', s.y === star.y && s.x > star.x);
|
||||
}
|
||||
|
||||
// --- 3. edgePoint / aimPoint ----------------------------------------------
|
||||
{
|
||||
const p = star.edgePoint(0.7, 150, shipRadius);
|
||||
check(`edgePoint sits rim + gap + ship off the rim (got ${dist(p).toFixed(3)})`,
|
||||
Math.abs(dist(p) - (wantRim + 150 + shipRadius)) < 1e-9);
|
||||
}
|
||||
{
|
||||
const inside = { x: star.x + 10, y: star.y };
|
||||
const out = star.aimPoint(inside.x, inside.y, shipRadius);
|
||||
check(`aimPoint projects inside points onto the rim (got ${dist(out).toFixed(3)})`,
|
||||
Math.abs(dist(out) - minDist) < 1e-9);
|
||||
const pas = star.aimPoint(star.x + minDist + 5, star.y, shipRadius);
|
||||
check('aimPoint passes outside points through unchanged',
|
||||
pas.x === star.x + minDist + 5 && pas.y === star.y);
|
||||
}
|
||||
|
||||
// --- 4. Textures: one per spectral class, generated once ------------------
|
||||
{
|
||||
const before = made.length;
|
||||
new Star(scene, 0, 0, { name: 'Sol', class: 'G' }); // star-g: fresh
|
||||
check('new class generates its texture (star-g present)', made.some((t) => t.key === 'star-g'));
|
||||
new Star(scene, 0, 0, { name: 'Betelgeuse', class: 'M' }); // star-m: fresh
|
||||
new Star(scene, 0, 0, { name: 'Kestrel', class: 'k' }); // star-k: already made
|
||||
const k = made.filter((t) => t.key === 'star-k').length;
|
||||
check('existing class reuses its texture (star-k generated exactly once, got ' + k + ')', k === 1);
|
||||
check('texture size = rim × 2 × glowFactor',
|
||||
made.filter((t) => t.key === 'star-m')[0].w === Math.ceil(wantRim * config.get('planets.star.glow.radiusFactor', 1.55)) * 2);
|
||||
check(`texture generation calls total ${before + 2} (star-g + star-m)`, made.length === before + 2);
|
||||
}
|
||||
|
||||
// Unknown spectral class: no crash, its own texture with the G fallback hue.
|
||||
{
|
||||
const s = new Star(scene, 0, 0, { name: 'X', class: 'zzz' });
|
||||
check('unknown class gets its own texture (star-zzz, G fallback hue)',
|
||||
s.key === 'star-zzz' && made.some((t) => t.key === 'star-zzz'));
|
||||
const s2 = new Star(scene, 0, 0, { name: 'Y', class: undefined });
|
||||
check('missing class defaults to G (got "' + s2.key + '")', s2.key === 'star-g');
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -144,6 +144,8 @@ check('run: restore keeps the project in flight (25 s left)',
|
|||
restored.getActive()?.id === gatesId && restored.progress(900_000).fraction < 1 && restored.progress(925_000).fraction >= 1);
|
||||
|
||||
// -- the chart gate: NAV points + completion ------------------------------
|
||||
// A NON-home system (no homeName — its center is empty, the star is
|
||||
// invisible flavor): NAV points = planets + space stations + gates.
|
||||
const content = {
|
||||
name: NAME,
|
||||
type: 'anchored',
|
||||
|
|
@ -155,41 +157,46 @@ const content = {
|
|||
jumps: [{ id: `${SYS}-j1`, to: 'S000043' }, { id: `${SYS}-j2`, to: 'S000044' }],
|
||||
asteroids: [{ id: `${SYS}-a1` }], // clusters are objects, not NAV points
|
||||
};
|
||||
check('nav: the NAV points = central body + planets + space stations + gates',
|
||||
JSON.stringify(navPointIds(content)) === JSON.stringify(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]));
|
||||
const POINT_IDS = ['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`];
|
||||
check('nav: non-home — the NAV points = planets + space stations + gates (no central body)',
|
||||
JSON.stringify(navPointIds(content)) === JSON.stringify(POINT_IDS));
|
||||
check('nav: the home system (homeName present) carries the central NAV point first',
|
||||
JSON.stringify(navPointIds({ homeName: 'Terra', ...content })) ===
|
||||
JSON.stringify(['home', ...POINT_IDS]));
|
||||
|
||||
const makeDiscovery = (found) => {
|
||||
const set = new Set(found);
|
||||
return { isDiscovered: (_sys, id) => set.has(id) };
|
||||
};
|
||||
check('nav: incomplete while any NAV point is undiscovered',
|
||||
isNavComplete(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false);
|
||||
isNavComplete(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false);
|
||||
check('nav: complete once EVERY NAV point is discovered',
|
||||
isNavComplete(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j2`, `${SYS}-j1`]), SYS, content) === true);
|
||||
isNavComplete(makeDiscovery(POINT_IDS), SYS, content) === true);
|
||||
check('nav: a missing discovery state never counts as complete', isNavComplete(null, SYS, content) === false);
|
||||
|
||||
// navPoints / navChart — the diagnostic's pure core (orbitNav builds on this)
|
||||
check('navPoints: each NAV point carries its kind (home/planet/station/gate)',
|
||||
check('navPoints: each NAV point carries its kind (planet/station/gate)',
|
||||
JSON.stringify(navPoints(content)) === JSON.stringify([
|
||||
{ id: 'home', kind: 'home' },
|
||||
{ id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' },
|
||||
{ id: `${SYS}-s1`, kind: 'station' },
|
||||
{ id: `${SYS}-j1`, kind: 'gate' }, { id: `${SYS}-j2`, kind: 'gate' },
|
||||
]));
|
||||
check('navPoints: the home system leads with the central NAV point',
|
||||
JSON.stringify(navPoints({ homeName: 'Terra', ...content })[0]) === JSON.stringify({ id: 'home', kind: 'home' }));
|
||||
|
||||
const chartMissing = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
|
||||
check('navChart: reports the discovered/missing split (5 of 6, j2 still out)',
|
||||
chartMissing.discovered === 5 && chartMissing.total === 6 && chartMissing.complete === false
|
||||
const chartMissing = navChart(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
|
||||
check('navChart: reports the discovered/missing split (4 of 5, j2 still out)',
|
||||
chartMissing.discovered === 4 && chartMissing.total === 5 && chartMissing.complete === false
|
||||
&& JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`])
|
||||
&& chartMissing.points.find((p) => p.id === `${SYS}-j2`)?.discovered === false);
|
||||
|
||||
const chartDone = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]), SYS, content);
|
||||
check('navChart: complete once every NAV point is discovered (6 of 6)',
|
||||
chartDone.discovered === 6 && chartDone.total === 6 && chartDone.complete === true && chartDone.missing.length === 0);
|
||||
const chartDone = navChart(makeDiscovery(POINT_IDS), SYS, content);
|
||||
check('navChart: complete once every NAV point is discovered (5 of 5)',
|
||||
chartDone.discovered === 5 && chartDone.total === 5 && chartDone.complete === true && chartDone.missing.length === 0);
|
||||
|
||||
const chartNoState = navChart(null, SYS, content);
|
||||
check('navChart: a missing discovery state shows nothing discovered (0 of 6)',
|
||||
chartNoState.discovered === 0 && chartNoState.total === 6 && chartNoState.complete === false);
|
||||
check('navChart: a missing discovery state shows nothing discovered (0 of 5)',
|
||||
chartNoState.discovered === 0 && chartNoState.total === 5 && chartNoState.complete === false);
|
||||
|
||||
// node id <-> system id — the "researched but the gates stay dark" regression.
|
||||
// A node OBJECT has no `id` field (its id is the key in the tree's `nodes`
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ The galaxy is **seeded and two-level**. The seed is chosen on the main
|
|||
menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
||||
|
||||
1. **Roster** — `Galaxy.create(seed)` builds every system's *identity*
|
||||
(id, name, type, x, y) up front. 200 systems is ~15 ms, so the whole
|
||||
(id, name, type, x, y) up front. 60 systems is ~10 ms, so the whole
|
||||
galaxy is always known: the player can never "discover" a layout that
|
||||
wasn't already implied by the seed.
|
||||
2. **Contents** — planets/moons/belts/**settlements**/hazards are
|
||||
|
|
@ -108,12 +108,17 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
|||
habitability, hazard, free-space settlement odds). New attribute key =
|
||||
JSON + a few lines in `SystemGenerator.js`;
|
||||
- **object count is global, not per-type** — `data/systems.json →
|
||||
objectCount`: `barren` (10%) of all systems are jump-gate-only (no
|
||||
planets, no stations — `barren` ⇒ `0`), the rest hold a weighted whole
|
||||
number of OBJECTS (planets + free-space stations) from the `objects`
|
||||
table (2/3/4/5). Stations roll first (0–2, per-type odds × the core→rim
|
||||
density), then planets fill the remaining budget (N − stations). The
|
||||
**starting system** is the exception: it always holds exactly two
|
||||
objectCount`: `barren` (10%) of all systems are gate-only DEAD-END
|
||||
LEAVES of the jump network (no planets, no stations — `barren` ⇒ `0`;
|
||||
exactly one gate — JumpNetwork keeps them at the tree's leaves), the
|
||||
rest hold a weighted whole number of OBJECTS (planets + free-space
|
||||
stations) from the `objects` table (2/3/4/5). Stations roll first
|
||||
(0–2, per-type odds × the core→rim density), then planets fill the
|
||||
remaining budget (N − stations); if the station roll would consume the
|
||||
whole budget it is demoted to a planet, so every non-barren system
|
||||
holds ≥ 1 planet — the gate's tether anchor and the place the player
|
||||
can build out from. The **starting system** is the exception: it
|
||||
always holds exactly two
|
||||
generated planets — a gas giant and a rocky world — which with the home
|
||||
world (the origin, the player's homestead, not a generated planet) makes
|
||||
its three planets, always.
|
||||
|
|
@ -207,11 +212,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
|
||||
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
|
||||
places each system's planets and free-space stations on a ring (a
|
||||
regular polygon) around the central body (the home world at the origin
|
||||
in the starting system, the star elsewhere), enforcing the SOLAR
|
||||
regular polygon) around the system center (the home world at the
|
||||
origin in the starting system; every other system's center is EMPTY —
|
||||
its star is invisible flavor, never rendered), enforcing the SOLAR
|
||||
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
|
||||
objects — planets, stations, the central body — sits center-to-center
|
||||
in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's
|
||||
objects — planets, stations (and the home world, starting system
|
||||
only) — sits center-to-center in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's
|
||||
band tightens to `[minSpacing, homeMaxSpacing]` = 6400–10240 px). The
|
||||
ring radius is chosen inside the band (non-home: `maxSpacing` over the
|
||||
chord of the N-gon; home: the (N+1)-gon side over the band's tight
|
||||
|
|
@ -229,12 +235,13 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
|
||||
`game.discovery.distance` (data/game.json, default 540 px) of an
|
||||
object's *edge* (center distance ≤ radius + distance) discovers it,
|
||||
once, per system. The central body is discovered at spawn in EVERY
|
||||
system: the home world (the ship starts beside it) in the starting
|
||||
system — "Home World" exists in exactly one system — and the system's
|
||||
STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one;
|
||||
both read the chart's central NAV point (id 'home'). Feedback: rim
|
||||
ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery).
|
||||
once, per system. The home world is discovered at spawn in the starting
|
||||
system (the ship starts beside it) — "Home World" exists in exactly one
|
||||
system and is that system's central NAV point (id 'home'); every other
|
||||
system's center is EMPTY (the star is invisible dossier flavor —
|
||||
name/class, never rendered) and carries no central NAV point. Feedback:
|
||||
rim ping + "DISCOVERED — NAME · TYPE" toast
|
||||
(GameScene.celebrateDiscovery).
|
||||
- **Compass** — `js/ui/DiscoveryCompass.js` (screen-space Container,
|
||||
scrollFactor 0). Every frame it refreshes the set of **discovered**
|
||||
objects that are **off-screen**, drawing a themed chevron arrow on the
|
||||
|
|
@ -243,8 +250,8 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
exported pure for tests. Arrow texture is generated procedurally on
|
||||
first use (`__compass_arrow`).
|
||||
- **World solids** — the ship collides with *every* solid in the system
|
||||
(`GameScene.solids`): the central body (the home world in the starting
|
||||
system, the star elsewhere — both the 'home' NAV point), all system
|
||||
(`GameScene.solids`): the home world (starting system only — it is the
|
||||
only central body in the galaxy, and its 'home' NAV point), all system
|
||||
planets, asteroid clusters, stations and jump gates: you can approach
|
||||
a rim, never pass through.
|
||||
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
|
||||
|
|
@ -260,16 +267,25 @@ exit toward a NEARBY star on the 2-D map. Two layers:
|
|||
`galaxy.jumpNetwork` / `galaxy.jumpGatesFor(id)`). It is a
|
||||
degree-limited spanning tree of the nearest-star graph (each system's
|
||||
`neighborPool` = 8 closest stars, `galaxy.neighborsOf`), with tree
|
||||
edges run BOTH ways plus optional one-way `shortcuts` bought with the
|
||||
spare gate budget. Result: the directed graph is **strongly
|
||||
connected** (from any star you can reach any other — no closed
|
||||
systems, no trapped sets), every jump is local, and no system holds
|
||||
more than `maxGates` gates. A 3-tier repair (local attach → swap →
|
||||
last-resort attach, logged) covers pathological pools, preferring a
|
||||
working network over the cap.
|
||||
edges run BOTH ways; `shortcuts` (one-way extra edges bought with the
|
||||
spare gate budget) is a dormant code path, OFF in the current config
|
||||
(`data/gates.json → shortcuts: false`) — so the network is a PURE
|
||||
SPANNING TREE: exactly one route between any two systems, no closed
|
||||
loops. The galaxy reads as a **MAZE** of dead ends and long hauls;
|
||||
strong connectivity still holds by construction (a bidirected tree —
|
||||
from any system you can reach any other — no closed systems, no
|
||||
trapped sets), every jump is local, no system holds more than
|
||||
`maxGates` gates, and EVERY jump has a return gate (the destination's
|
||||
gate pointing back). A BARREN system is a dead-end LEAF of the tree —
|
||||
exactly one gate, in and out the same way (the function takes the
|
||||
barren set and never lets a barren node adopt children; the repair
|
||||
pass prefers non-barren attach targets). A 3-tier repair (local
|
||||
attach → swap → last-resort attach, logged) covers pathological pools,
|
||||
preferring a working network over the cap.
|
||||
- **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure)
|
||||
places each gate on the tether circle of one of the system's ANCHORS
|
||||
(a planet, a free-space station, or the home world in the starting
|
||||
(a PLANET — free-space stations are deliberately not anchors, since the
|
||||
build console lives on a world — or the home world in the starting
|
||||
system), exactly `anchorTetherLevel` (level 1 = 5120 px, from
|
||||
`data/tether.json`) from it — so the tether rule is hard by
|
||||
construction. Candidates are tried in facing quality: the ray-circle
|
||||
|
|
@ -277,12 +293,16 @@ exit toward a NEARBY star on the 2-D map. Two layers:
|
|||
aimed at the star, then a ±75° scan — so the direction rule is soft
|
||||
(same side of the anchor as the target, deviation < 90°; in practice
|
||||
~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480
|
||||
px) from the star, keep `size` + `clearance` from anchor discs and
|
||||
px) from the system center, keep `size` + `clearance` from solid discs
|
||||
(planets, free-space stations, the home world) and
|
||||
2·`size` + `gateGap` from each other, and get unique names ("Avidy
|
||||
Gate", "Avidy Gate II" — star names are syllable-generated and
|
||||
collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
|
||||
get their single gate on the star→destination ray at `barrenDistance`
|
||||
(8192 px) instead. Every gate record carries `active: false` — gates
|
||||
get their single gate on the center→destination ray at `barrenDistance`
|
||||
(8192 px) instead; their payload is 1–2 asteroid clusters drifting
|
||||
inside that gate's level-1 tether (`data/asteroids.json → barren` — the
|
||||
dead end's only thing to mine). Every gate record carries
|
||||
`active: false` — gates
|
||||
are DORMANT until activated (the entity renders dim, field still);
|
||||
activation is the SYSTEM research category's jumpgate tech completing
|
||||
(the Research section below): the system's gates go live plus the
|
||||
|
|
@ -317,10 +337,10 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
|
|||
just past the destination's RETURN gate's keepout (the gate pointing
|
||||
back — activated with its twin per ACTIVITY, its tether anchoring the
|
||||
landing), offset back along its facing with nose along the travel
|
||||
direction; one-way SHORTCUT jumps have no return gate (JumpNetwork:
|
||||
tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's
|
||||
gates) and land on the destination's star, inside its home-tether
|
||||
zone. THE CLIP: the jump plays a full-screen one-shot between the two
|
||||
direction; with shortcuts OFF (the current config) the network is a
|
||||
pure spanning tree, so every jump has a return gate — JumpTravel's
|
||||
null path (land on the destination's origin, inside its home-tether
|
||||
zone) is defensive only. THE CLIP: the jump plays a full-screen one-shot between the two
|
||||
systems (`jump.video`, cover-scaled over an opaque theme backdrop — the
|
||||
source system must not show through; `jump.videoVolume` 0..1, 0 =
|
||||
silent). The clip + backdrop are SCREEN-pinned (`scrollFactor(0)`) —
|
||||
|
|
@ -435,9 +455,11 @@ one shared category without collisions:
|
|||
Copy: *Added the Solar System of {system} to the onboard
|
||||
NAV System. Discover all NAV points to unlock the system Jumpgates.*
|
||||
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
|
||||
**every NAV point of the system is discovered** (the central body, all
|
||||
planets, all space stations, all jump gates — the scene's discoverable
|
||||
set minus the asteroid clusters); 45 s
|
||||
**every NAV point of the system is discovered** (the home world in the
|
||||
starting system, and otherwise all planets, all space stations, all
|
||||
jump gates — the scene's discoverable
|
||||
set minus the asteroid clusters; the center of a non-home system is
|
||||
empty, so it carries no central NAV point); 45 s
|
||||
(`data/gates.json → activation.researchDuration`). Completing it
|
||||
activates the system's gates **and the return gates in the systems
|
||||
they connect to** (pure, from the jump network alone —
|
||||
|
|
@ -539,11 +561,12 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
|
|||
idempotently). Node-tested by `dev/system-category.test.mjs`.
|
||||
- `js/galaxy/JumpTravel.js` — PURE (no Phaser): the jump's arrival
|
||||
geometry. `returnGateFor(content, fromId)` (the destination's gate
|
||||
pointing back at the system left — one-way shortcuts have none),
|
||||
pointing back at the system left — with shortcuts OFF it always
|
||||
exists; the null path is defensive),
|
||||
`arrivalPoint(gate, cfg)` (spawn just past the keepout, on the far
|
||||
side of the gate's facing, nose along the travel direction),
|
||||
`jumpArrival(content, fromId, cfg)` (both; null ⇒ the scene lands on
|
||||
the destination's star). Driven by `GameScene.jumpThroughGate`
|
||||
the destination's origin — defensive only). Driven by `GameScene.jumpThroughGate`
|
||||
(the transport — save pipeline + scene restart); Node-tested by
|
||||
`dev/jump-travel.test.mjs` (contract, geometry, a real galaxy,
|
||||
determinism).
|
||||
|
|
@ -899,7 +922,7 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
||||
- [x] Galaxy seed on the main menu (displayed, editable, rerollable;
|
||||
same seed → same galaxy, shown before you commit)
|
||||
- [x] Two-level worldgen: seeded galaxy roster (200 systems) + lazy,
|
||||
- [x] Two-level worldgen: seeded galaxy roster (60 systems) + lazy,
|
||||
order-independent system contents; system archetypes in JSON
|
||||
(theme + attributes + distribution weight/radius band)
|
||||
- [x] The lived-in layer: settlements (colonies, mining stations, cloud
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor } from '../utils/Color.js';
|
||||
import { Planet } from './Planet.js';
|
||||
|
||||
/**
|
||||
* A STAR — the central body of every NON-HOME system (content.star from
|
||||
* the generator: name + spectral class). The starting system's central
|
||||
* body is the player's home world (js/entities/Planet.js) and only there
|
||||
* — "Home World" exists in exactly one system in the galaxy.
|
||||
*
|
||||
* Solid like a planet: the keep-out circle is the disc rim (half the
|
||||
* configured size — the halo is visual only), the ship keeps
|
||||
* `planets.shipClearance` px (edge-to-edge) off it and can never move
|
||||
* through it (the same plain-circle rule, GameScene.solids). It is
|
||||
* discoverable (the 'home' NAV point of the chart — every system has a
|
||||
* central body) but NOT a comms target: there is no surface to land on.
|
||||
*
|
||||
* The look is procedural (data/planets.json → star): an opaque body —
|
||||
* a white core melting into the spectral class's hue — plus a soft halo,
|
||||
* band-filled with Graphics (same pattern as js/visuals/Starfield.js, so
|
||||
* no canvas API and no art asset). One texture per spectral class,
|
||||
* generated on first use.
|
||||
*/
|
||||
export class Star extends Phaser.GameObjects.Image {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x — world x of the star's center
|
||||
* @param {number} y — world y
|
||||
* @param {object} [star={}] — content.star ({ name, class })
|
||||
*/
|
||||
constructor(scene, x, y, star = {}) {
|
||||
const cls = String(star?.class ?? 'G').toUpperCase();
|
||||
const key = `star-${cls.toLowerCase()}`;
|
||||
if (!scene.textures.exists(key)) Star.makeTexture(scene, key, cls);
|
||||
super(scene, x, y, key);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.starClass = cls;
|
||||
this.name = star?.name ?? ''; // display name (the scene stamps discoveryName)
|
||||
|
||||
// The solid disc: diameter from data/planets.json → star.size (the
|
||||
// halo extends past the rim but is not part of the keep-out).
|
||||
this.size = Math.max(64, Number(config.get('planets.star.size', 1536)) || 1536);
|
||||
this.setScale(1);
|
||||
this.radius = this.size / 2;
|
||||
// Edge-to-edge gap the ship may close in on the rim (never less).
|
||||
this.clearance = config.get('planets.shipClearance', 50);
|
||||
}
|
||||
|
||||
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
|
||||
minCenterDistance(shipRadius = 0) {
|
||||
return this.radius + this.clearance + shipRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* A world point `gap` px (edge-to-edge) off this star's rim at `angle`
|
||||
* radians — e.g. a spawn or approach point.
|
||||
*/
|
||||
edgePoint(angle, gap, shipRadius = 0) {
|
||||
const d = this.radius + gap + shipRadius;
|
||||
return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d };
|
||||
}
|
||||
|
||||
/**
|
||||
* A world point the ship may be sent to: a point inside the keep-out
|
||||
* circle (e.g. a click on the star itself, or through it) is projected
|
||||
* out onto the rim, along the ray from the center — so the ship always
|
||||
* has a reachable destination and is never told to go inside.
|
||||
*/
|
||||
aimPoint(wx, wy, shipRadius = 0) {
|
||||
const minDist = this.minCenterDistance(shipRadius);
|
||||
const dx = wx - this.x;
|
||||
const dy = wy - this.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist >= minDist) return { x: wx, y: wy };
|
||||
if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x
|
||||
return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a ship out of the star: same plain-circle rule as Planet —
|
||||
* push the center out to `minCenterDistance`, strip the inward part of
|
||||
* velocity and acceleration (the tangential part slides along the rim).
|
||||
* Returns true when it actually touched the ship (contact — see
|
||||
* Planet.constrainShip).
|
||||
*/
|
||||
constrainShip(ship, shipRadius = 0) {
|
||||
const body = ship.body;
|
||||
const r = Planet.resolve(
|
||||
this.x, this.y, this.minCenterDistance(shipRadius),
|
||||
ship.x, ship.y,
|
||||
body.velocity.x, body.velocity.y,
|
||||
body.acceleration ? body.acceleration.x : 0,
|
||||
body.acceleration ? body.acceleration.y : 0,
|
||||
);
|
||||
const touched =
|
||||
r.x !== ship.x || r.y !== ship.y ||
|
||||
r.vx !== body.velocity.x || r.vy !== body.velocity.y ||
|
||||
(body.acceleration &&
|
||||
(r.ax !== body.acceleration.x || r.ay !== body.acceleration.y));
|
||||
ship.x = r.x;
|
||||
ship.y = r.y;
|
||||
body.velocity.x = r.vx;
|
||||
body.velocity.y = r.vy;
|
||||
if (body.acceleration) {
|
||||
body.acceleration.x = r.ax;
|
||||
body.acceleration.y = r.ay;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* The star's texture for one spectral class: an opaque body (a white
|
||||
* core melting into the class hue, data/planets.json →
|
||||
* star.classColor) + a soft halo (star.glow). Band-filled circles,
|
||||
* outer → inner, via Graphics — no canvas API, one texture per class.
|
||||
*/
|
||||
static makeTexture(scene, key, cls) {
|
||||
const coreR = Math.max(32, Math.floor(config.get('planets.star.size', 1536) / 2));
|
||||
const glowFactor = Math.max(1, Number(config.get('planets.star.glow.radiusFactor', 1.55)) || 1.55);
|
||||
const glowAlpha = Math.min(1, Math.max(0, Number(config.get('planets.star.glow.alpha', 0.5)) || 0));
|
||||
const color = toColor(config.get(`planets.star.classColor.${cls}`, '#ffe9b0'));
|
||||
|
||||
const R = Math.ceil(coreR * glowFactor);
|
||||
const T = Math.ceil(R * 2);
|
||||
const c = T / 2;
|
||||
|
||||
// Blend the class hue toward white (t = 1 → white).
|
||||
const mix = (t) => {
|
||||
const ch = (sh) => {
|
||||
const v = (color >> sh) & 255;
|
||||
return Math.round(v + (255 - v) * t);
|
||||
};
|
||||
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
||||
};
|
||||
|
||||
const g = scene.make.graphics({ add: false });
|
||||
// Halo: rim → edge, alpha growing to the rim (0 at the edge).
|
||||
const H = 14;
|
||||
for (let i = 0; i < H; i++) {
|
||||
const t = i / (H - 1); // 0 = edge, 1 = rim
|
||||
g.fillStyle(color, glowAlpha * t * t);
|
||||
g.fillCircle(c, c, R - (R - coreR) * t);
|
||||
}
|
||||
// Body: rim → core, opaque; the hue melts into a white core.
|
||||
const B = 24;
|
||||
for (let i = 0; i < B; i++) {
|
||||
const t = i / (B - 1); // 0 = rim, 1 = core
|
||||
g.fillStyle(mix(Math.pow(t, 1.4)), 1);
|
||||
g.fillCircle(c, c, coreR * (1 - t * 0.999));
|
||||
}
|
||||
g.generateTexture(key, T, T);
|
||||
g.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { Rng } from '../utils/Rng.js';
|
|||
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||
import { buildJumpNetwork } from './JumpNetwork.js';
|
||||
import { assignPlanetFrames } from './PlanetFrames.js';
|
||||
import { generateSystemContent } from './SystemGenerator.js';
|
||||
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
|
|
@ -20,7 +20,7 @@ const wrapPI = (a) => {
|
|||
*
|
||||
* 1. GALAXY ROSTER — generated once, up front, when New Game is pressed:
|
||||
* `systemCount` lightweight records ({ id, name, type, x, y }).
|
||||
* This is cheap: 200 systems is a few ms and a few hundred KB.
|
||||
* This is cheap: the 60-system default is a few ms and a few hundred KB.
|
||||
* It fixes the shape of the galaxy, where every system sits, and
|
||||
* what KIND each one is — for the entire galaxy, from the seed alone.
|
||||
*
|
||||
|
|
@ -177,13 +177,32 @@ export class Galaxy {
|
|||
cell.push(rec);
|
||||
}
|
||||
|
||||
// The BARREN set — the gate-only dead ends (objectCount → 0). The
|
||||
// SAME roll the content generator uses (dedicated per-system forks —
|
||||
// rollSystemComposition), so the network and the content always agree:
|
||||
// JumpNetwork keeps every barren system a LEAF of the tree (one gate —
|
||||
// in and out the same way), the maze's dead ends.
|
||||
const typeDefs = this.typeDefs ?? {};
|
||||
const barren = new Set();
|
||||
for (const record of records) {
|
||||
if (record.id === this.currentSystemId) continue; // home is exempt
|
||||
const type = typeDefs[record.type] ?? {};
|
||||
const density = settlementDensity({ params: this.params }, record);
|
||||
const composition = rollSystemComposition(
|
||||
this.seed, record, false, type.attributes?.settlements ?? {}, density, type.attributes ?? {},
|
||||
);
|
||||
if (composition.classes.length === 0) barren.add(record.id);
|
||||
}
|
||||
|
||||
// The JUMP NETWORK (data/gates.json): which star each system's jump
|
||||
// gates reach. A bidirected, degree-limited spanning tree of the
|
||||
// nearest-star graph (plus local shortcuts) — STRONGLY CONNECTED, so
|
||||
// there are no closed systems and no trapped sets: from any star the
|
||||
// player can reach any other, and every system holds
|
||||
// minGates–maxGates gates (1–3 in data/gates.json). Deterministic:
|
||||
// same roster ⇒ same network.
|
||||
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no
|
||||
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
|
||||
// a MAZE: exactly one route between any two systems, no closed loops.
|
||||
// Tree edges run BOTH ways, so from any system the player can reach
|
||||
// any other and every jump has a RETURN gate (no trapped sets). Each
|
||||
// system holds 1–maxGates gates (its tree degree); a BARREN system is
|
||||
// a dead-end LEAF — exactly one gate (in and out the same way).
|
||||
// Deterministic: same roster ⇒ same network.
|
||||
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
|
||||
this.jumpNetwork = buildJumpNetwork({
|
||||
records,
|
||||
|
|
@ -191,6 +210,7 @@ export class Galaxy {
|
|||
minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
|
||||
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
|
||||
shortcuts: config.get('gates.shortcuts', true) === true,
|
||||
barren,
|
||||
rootId: this.currentSystemId,
|
||||
});
|
||||
// Defensive repairs that had to fire (0 on a healthy kNN graph).
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@
|
|||
* any other (dev/jumps.test.mjs verifies forward AND
|
||||
* backward reachability from the home system).
|
||||
* BOUNDED — minGates ≤ gates(system) ≤ maxGates (1..3 in practice).
|
||||
* MAZE — with shortcuts OFF (the current config, data/gates.json →
|
||||
* shortcuts:false) the graph is a PURE SPANNING TREE: exactly
|
||||
* one route between any two systems, no closed loops. The
|
||||
* galaxy reads as a maze of dead ends and long hauls — and
|
||||
* every barren system (the `barren` set, gate-only dead ends)
|
||||
* is a LEAF: exactly one gate, in and out the same way.
|
||||
* PURE — no Math.random: every tie is broken by index. Same seed
|
||||
* ⇒ same network (deterministic across machines and runs).
|
||||
*
|
||||
|
|
@ -24,19 +30,24 @@
|
|||
* 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.
|
||||
* their degree budget is spent. 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
|
||||
* tree degree — at least 1 (no isolated node) and at most maxGates.
|
||||
* 4. Optional SHORTCUTS: any spare degree budget (nodes under
|
||||
* maxGates) buys extra one-way local edges — the web, not just the
|
||||
* roads. Adding edges never removes reachability, so the strong
|
||||
* connectivity survives.
|
||||
* Every jump therefore has a RETURN gate (the destination's gate
|
||||
* pointing back), so the player can always jump back the way they
|
||||
* came.
|
||||
* 4. Optional SHORTCUTS (OFF in the current config): any spare degree
|
||||
* budget (nodes under maxGates) buys extra one-way local edges — the
|
||||
* web, not just the roads. Adding edges never removes reachability,
|
||||
* so the strong connectivity survives.
|
||||
*
|
||||
* 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.
|
||||
* still respects the degree budget (preferring non-barren attach targets
|
||||
* so a dead end stays a leaf when it can).
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -49,11 +60,16 @@
|
|||
* @param {number} [o.minGates=1] — validation bound.
|
||||
* @param {number} [o.maxGates=3] — hard degree budget per node.
|
||||
* @param {boolean} [o.shortcuts=true] — spend spare budget on extra local edges.
|
||||
* @param {Set<string>|null} [o.barren] — the gate-only dead-end systems
|
||||
* (objectCount → 0, from the composition roll): a barren node never
|
||||
* adopts children, so it ends up a tree LEAF — exactly one gate, in and
|
||||
* out the same way (the maze's dead ends). The repair pass prefers to
|
||||
* attach leftovers to non-barren nodes.
|
||||
* @param {string|null} [o.rootId] — grow the tree from this system (the home
|
||||
* system) — it then keeps the lowest possible degree.
|
||||
* @returns {{ gates: Map<string, string[]>, repaired: number }}
|
||||
* gates: system id → ordered list of gate destinations (parent edge
|
||||
* first — "the road home" — then shortcuts).
|
||||
* first — "the road home" — then children/shortcuts).
|
||||
* repaired: systems that needed the defensive attach (0 on real data).
|
||||
*/
|
||||
export function buildJumpNetwork({
|
||||
|
|
@ -62,9 +78,11 @@ export function buildJumpNetwork({
|
|||
minGates = 1,
|
||||
maxGates = 3,
|
||||
shortcuts = true,
|
||||
barren = null,
|
||||
rootId = null,
|
||||
}) {
|
||||
const n = records.length;
|
||||
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
|
||||
const idx = new Map();
|
||||
records.forEach((r, i) => idx.set(r.id, i));
|
||||
const iOf = (id) => {
|
||||
|
|
@ -110,6 +128,7 @@ export function buildJumpNetwork({
|
|||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const u = queue[head++];
|
||||
if (isBarren(u)) continue; // a dead end never adopts children (it keeps its single gate)
|
||||
const budget = maxGates - deg[u];
|
||||
if (budget <= 0) continue;
|
||||
const cands = nbr[u].filter((v) => !visited[v]);
|
||||
|
|
@ -151,9 +170,11 @@ export function buildJumpNetwork({
|
|||
let repaired = 0;
|
||||
for (let w = 0; w < n; w++) {
|
||||
if (visited[w]) continue;
|
||||
// (1) local attach
|
||||
// (1) local attach — prefer a non-barren target (a dead end should
|
||||
// keep its single gate when the graph lets us).
|
||||
let u = -1;
|
||||
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { 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) {
|
||||
// (2) swap: best (u, x) pair by dist(w, u), then indices
|
||||
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
|
||||
|
|
@ -180,12 +201,16 @@ export function buildJumpNetwork({
|
|||
}
|
||||
}
|
||||
if (u === -1) {
|
||||
// (3) nearest visited node, budget be damned
|
||||
// (3) nearest visited node, budget be damned (prefer non-barren)
|
||||
let bestD = Infinity;
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!visited[c] || c === w) continue;
|
||||
const d = dist2(w, c);
|
||||
if (d < bestD) { bestD = d; u = c; }
|
||||
for (const pass of [1, 0]) {
|
||||
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 (u !== -1) break;
|
||||
}
|
||||
if (u === -1) continue; // nothing to attach to (n === 1 handled above)
|
||||
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@
|
|||
* jumpArrival(content, fromId, cfg)
|
||||
* Both, together: { x, y, heading, gateId } — or null when the
|
||||
* destination holds no return gate (a one-way SHORTCUT jump —
|
||||
* JumpNetwork: tree edges run both ways, shortcuts don't). The
|
||||
* scene falls back to the destination's origin (its home-tether
|
||||
* zone) when this is null.
|
||||
* JumpNetwork: tree edges run both ways, shortcuts don't). With
|
||||
* shortcuts OFF (the current config) the network is a pure spanning
|
||||
* tree, so a return gate always exists and this never returns null;
|
||||
* the null path is defensive (re-enabled shortcuts, or a malformed
|
||||
* record). The scene falls back to the destination's origin (its
|
||||
* home-tether zone) when this is null.
|
||||
*
|
||||
* GameScene.jumpThroughGate supplies the live numbers (the gate's
|
||||
* radius/clearance, the ship's radius, data/gates.json → jump) and
|
||||
|
|
@ -67,7 +70,9 @@ export function arrivalPoint(gate, cfg = {}) {
|
|||
|
||||
/**
|
||||
* The jump's arrival for a destination system: at its return gate (if
|
||||
* it holds one — tree-edge jumps do; one-way shortcut jumps don't).
|
||||
* it holds one — tree-edge jumps do; one-way shortcut jumps don't;
|
||||
* shortcuts are OFF in the current config, so the return gate always
|
||||
* exists and the null path below is defensive only).
|
||||
*
|
||||
* @returns {{x:number, y:number, heading:number, gateId:string}|null}
|
||||
* null when there is no return gate — the caller falls back.
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ const DEG = Math.PI / 180;
|
|||
* EVERY planet hosts a settlement (for now — data/settlements.json →
|
||||
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
|
||||
* mining stations over the rest, cloud bases riding gas giants. The
|
||||
* BARREN systems (objectCount → 0) are the deliberate exception: star and
|
||||
* jump gates, nothing else — dead-end stops on the network (strong
|
||||
* connectivity keeps them reachable and escapable), where the player's
|
||||
* room to move is the activated gate's own level-1 tether (data/gates.json
|
||||
* → ACTIVITY: every gate carries `active`, default false; the activation
|
||||
* BARREN systems (objectCount → 0) are the deliberate exception: jump
|
||||
* gates — and, in most, an asteroid cluster drifting inside the gate's
|
||||
* tether to mine at the stop — and nothing else. They are the DEAD-END
|
||||
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's
|
||||
* dead ends: one gate, in and out the same way), where the player's room
|
||||
* to move is the activated gate's own level-1 tether (data/gates.json →
|
||||
* ACTIVITY: every gate carries `active`, default false; the activation
|
||||
* mechanic is future work). Nothing here is hostile yet: `owner` on every
|
||||
* settlement is a reserved seam for the factions and pirates we'll
|
||||
* introduce later.
|
||||
|
|
@ -56,26 +58,32 @@ const DEG = Math.PI / 180;
|
|||
* stays a ≤ 4-object configuration (see layoutSystem).
|
||||
*
|
||||
* LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every
|
||||
* PAIR of layout objects (planets, free-space stations, and the central
|
||||
* body — the star, or the home world in the starting system — at the
|
||||
* local origin) sits 6400..15360 px apart, center to center; in the home
|
||||
* system the band tightens to 6400..10240 px. Normal systems lay out as a
|
||||
* regular N-gon ring around the star; the home system as a regular
|
||||
* (N+1)-polygon with the home world as one vertex. The rotation is chosen
|
||||
* to serve the jump gates — objects bias toward the directions the system
|
||||
* jumps (see layoutSystem + layoutGates).
|
||||
* PAIR of layout objects (planets and free-space stations — the central
|
||||
* body, the home world, sits at the local origin of the STARTING system
|
||||
* only; every other system has an EMPTY center — the star is invisible
|
||||
* flavor, generated as `star` but never rendered) sits 6400..15360 px
|
||||
* apart, center to center; in the home system the band tightens to
|
||||
* 6400..10240 px. Normal systems lay out as a regular N-gon ring around
|
||||
* the (empty) center; the home system as a regular (N+1)-polygon with the
|
||||
* home world as one vertex. The rotation is chosen to serve the jump
|
||||
* gates — objects bias toward the directions the system jumps (see
|
||||
* layoutSystem + layoutGates).
|
||||
*
|
||||
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
|
||||
* 1–3 gates per system — each placed on the side of the system facing its
|
||||
* destination star on the 2-D map (an upper-right gate jumps to a star in
|
||||
* the upper right). An ANCHORED system (a planet, a free-space station,
|
||||
* or the home world) hosts its gates within level-1 tether (5120 px) of
|
||||
* an anchor; a BARREN system (no anchors) hosts its gate(s) on the ray
|
||||
* toward the destination, `barrenDistance` from the star. Every gate
|
||||
* record carries `active` (default false — the activation mechanic is
|
||||
* future work; an activated gate anchors a level-1 tether). The
|
||||
* galaxy-wide network is strongly connected: no closed systems, no
|
||||
* trapped sets, the whole galaxy is reachable.
|
||||
* the upper right). An ANCHORED system (a planet, or the home world in
|
||||
* the starting system) hosts its gates within level-1 tether (5120 px)
|
||||
* of an anchor — planets ONLY (free-space stations are deliberately not
|
||||
* anchors: the build console lives on a world, so every gate must be
|
||||
* tether-reachable from a planet the player can build out from). A BARREN
|
||||
* system (no planets — a dead-end leaf of the tree) hosts its single gate
|
||||
* on the ray toward the destination, `barrenDistance` from the center.
|
||||
* Every gate record carries `active` (default false — the activation
|
||||
* mechanic is future work; an activated gate anchors a level-1 tether).
|
||||
* The galaxy-wide network is a pure spanning tree (no loops — the maze):
|
||||
* from any system the player can reach any other, and every jump has a
|
||||
* return gate.
|
||||
*
|
||||
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
|
||||
* frame is assigned by a galaxy-wide pass — the system's (class, frame)
|
||||
|
|
@ -103,7 +111,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
const attr = type.attributes ?? {};
|
||||
const rng = Rng.derive(galaxy.seed, 'system', record.id);
|
||||
|
||||
// --- Star -------------------------------------------------------------
|
||||
// --- Star (invisible flavor) ------------------------------------------
|
||||
// The system's star — generated (name/class/mass/binary) as the
|
||||
// dossier's identity ("Kepler Reach · star G") and the gate names, but
|
||||
// never rendered: the central body exists only in the starting system
|
||||
// (the home world). See data/galaxy.json.
|
||||
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
|
||||
const massTable = attr.star?.mass ?? {};
|
||||
const starClass = rng.weighted(starClasses, 'M');
|
||||
|
|
@ -437,23 +449,32 @@ function bestRotation(place, targetAngles) {
|
|||
* - sits on the system's side of its DESTINATION star — the bearing is
|
||||
* computed in the 2-D map plane, so "an upper-right gate" means "a
|
||||
* star to the upper right on the map";
|
||||
* - ANCHORED systems (a planet, a free-space station, or the home world):
|
||||
* within level-`anchorTetherLevel` tether (5120 px for level 1) of an
|
||||
* anchor — on the anchor's tether circle, chosen in order of facing
|
||||
* quality: (1) the far ray-circle intersection (the gate exactly on
|
||||
* the target ray — system, gate, and star collinear), (2) the point of
|
||||
* the circle aimed exactly at the target star, (3) a forward-hemisphere
|
||||
* scan of the circle (±75°). Every candidate is exactly `range` from
|
||||
* its anchor, so tether-reachability holds by construction and the
|
||||
* facing deviation never exceeds 90° (in practice a few degrees);
|
||||
* - BARREN systems (no anchors — objectCount → 0): on the ray toward the
|
||||
* destination, `barrenDistance` from the star (stepped outward within
|
||||
* the radius band only if the gate gap forces it). The activation
|
||||
* - ANCHORED systems (a planet — or the home world in the starting
|
||||
* system): within level-`anchorTetherLevel` tether (5120 px for level
|
||||
* 1) of an anchor — on the anchor's tether circle, chosen in order of
|
||||
* facing quality: (1) the far ray-circle intersection (the gate
|
||||
* exactly on the target ray — system center, gate, and destination
|
||||
* star collinear), (2) the point of the circle aimed exactly at the
|
||||
* target star, (3) a forward-hemisphere scan of the circle (±75°).
|
||||
* Every candidate is exactly `range` from its anchor, so
|
||||
* tether-reachability holds by construction and the facing deviation
|
||||
* never exceeds 90° (in practice a few degrees). Anchors are PLANETS
|
||||
* only (plus the home world at home) — free-space stations are
|
||||
* deliberately excluded: the build console lives on a world, so every
|
||||
* gate must be tether-reachable from a planet the player can build
|
||||
* out from (reach the anchor → build the next tether level → expand).
|
||||
* (Every non-barren system holds at least one planet — the
|
||||
* composition roll demotes a station if the budget ran out — so an
|
||||
* anchor always exists.)
|
||||
* - BARREN systems (no planets — objectCount → 0, the network's
|
||||
* dead-end leaves): on the ray toward the destination,
|
||||
* `barrenDistance` from the center (stepped outward within the
|
||||
* radius band only if the gate gap forces it). The activation
|
||||
* mechanic (future work) then turns the gate itself into the system's
|
||||
* level-1 tether anchor — see data/gates.json → ACTIVITY;
|
||||
* - stays `minRadius..maxRadius` from the star, `size` + `clearance`
|
||||
* clear of every anchor disc, and 2·`size` + `gateGap` from every
|
||||
* other gate.
|
||||
* - stays `minRadius..maxRadius` from the center, `size` + `clearance`
|
||||
* clear of every anchor disc (planets + free-space stations), and
|
||||
* 2·`size` + `gateGap` from every other gate.
|
||||
*
|
||||
* The target list arrives ordered (the "road home" parent edge first) and
|
||||
* anchors iterate in content order, so the placement is exact for the
|
||||
|
|
@ -478,14 +499,21 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
const maxR = Math.max(minR, g.maxRadius ?? 20480);
|
||||
const range = anchorTetherRange(g.anchorTetherLevel ?? 1);
|
||||
|
||||
// Anchors: the planets, the free-space stations, and (home only) the
|
||||
// home world at the origin — the bodies a gate's tether may hang from.
|
||||
// A BARREN system holds none — its gates use the on-ray rule below.
|
||||
const anchors = [
|
||||
...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })),
|
||||
// Anchors: the PLANETS and (home only) the home world at the origin —
|
||||
// the bodies a gate's tether may hang from. Free-space stations are
|
||||
// deliberately NOT anchors (the build console lives on a world — the
|
||||
// player must always be able to reach a planet and build out from the
|
||||
// anchor); the composition roll guarantees a non-barren system holds
|
||||
// at least one. A BARREN system holds none — its gate uses the on-ray
|
||||
// rule below.
|
||||
const anchors = planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) }));
|
||||
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
|
||||
// Clearance discs: every solid body the gate must keep clear of — the
|
||||
// anchors plus the free-space stations.
|
||||
const discs = [
|
||||
...anchors,
|
||||
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
|
||||
];
|
||||
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
|
||||
|
||||
const jumps = [];
|
||||
const gateNames = new Map(); // star name → how many gates named after it
|
||||
|
|
@ -499,10 +527,12 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
if (anchors.length === 0) {
|
||||
// BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate
|
||||
// sits ON the ray toward its destination, `barrenDistance` from the
|
||||
// star (data/gates.json), stepped outward in 1024 px steps within the
|
||||
// center (data/gates.json), stepped outward in 1024 px steps within the
|
||||
// radius band if the gate gap forces it (two close targets). When the
|
||||
// player activates it (future mechanic), the gate itself becomes the
|
||||
// system's level-1 tether anchor (data/gates.json → ACTIVITY).
|
||||
// system's level-1 tether anchor (data/gates.json → ACTIVITY); its 1–2
|
||||
// asteroid clusters drift inside that tether (data/asteroids.json →
|
||||
// barren).
|
||||
const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192)));
|
||||
const okB = (px, py) => {
|
||||
const d2c = px * px + py * py;
|
||||
|
|
@ -568,7 +598,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
const ok = (c) => {
|
||||
const d2c = c.px * c.px + c.py * c.py;
|
||||
if (d2c < minR * minR || d2c > maxR * maxR) return false;
|
||||
for (const b of anchors) {
|
||||
for (const b of discs) {
|
||||
const need = b.r + size + clearance;
|
||||
const dx = c.px - b.x;
|
||||
const dy = c.py - b.y;
|
||||
|
|
@ -709,30 +739,45 @@ function homeWorldRadius() {
|
|||
function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) {
|
||||
const cfg = config.section('asteroids', {});
|
||||
if (cfg.enabled === false) return [];
|
||||
// A BARREN system (objectCount → 0) is a jump-gate-only stop — star and
|
||||
// gates, nothing else — so no clusters.
|
||||
if (planets.length === 0 && freeSpace.length === 0) return [];
|
||||
// Without the solar-system layout there are no placed objects to space
|
||||
// against — no clusters either (the scene renders nothing else anyway).
|
||||
if (config.get('planets.solarSystem.enabled', true) === false) return [];
|
||||
const isBarren = planets.length === 0 && freeSpace.length === 0;
|
||||
if (isBarren && !jumps.some((j) => typeof j.x === 'number' && typeof j.y === 'number')) {
|
||||
return []; // no gate to hang the cluster on (a one-system galaxy)
|
||||
}
|
||||
|
||||
const clusterCfg = cfg.cluster ?? {};
|
||||
const dist = cfg.distribution ?? {};
|
||||
const placement = cfg.placement ?? {};
|
||||
const isHome = record.id === galaxy?.homeSystemId;
|
||||
|
||||
// --- How many clusters: the inverse of the planet count ---------------
|
||||
const target = dist.targetObjects ?? 9;
|
||||
const jitter = dist.jitter ?? 1;
|
||||
// --- How many clusters -------------------------------------------------
|
||||
// BARREN (dead-end) systems: the stop's payload — a little rock to mine
|
||||
// inside the gate's tether (data/asteroids.json → barren.clusters,
|
||||
// default 1–2). Everyone else: the inverse of the planet count — the
|
||||
// more planets a system has, the fewer asteroid clusters, and vice
|
||||
// versa. The STARTING system always gets at least
|
||||
// startingSystemMinClusters, and those first ones are placed inside the
|
||||
// player's initial tether.
|
||||
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
|
||||
const minC = Math.max(1, Math.floor(dist.minClusters ?? 1));
|
||||
const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
|
||||
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
|
||||
|
||||
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
|
||||
const count = clamp(
|
||||
Math.round(target - planets.length + rng.range(-jitter, jitter)),
|
||||
homeMin, maxC,
|
||||
);
|
||||
let count;
|
||||
if (isBarren) {
|
||||
const bc = cfg.barren ?? {};
|
||||
const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
|
||||
const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
|
||||
count = rng.int(lo, hi);
|
||||
} else {
|
||||
const target = dist.targetObjects ?? 9;
|
||||
const jitter = dist.jitter ?? 1;
|
||||
count = clamp(
|
||||
Math.round(target - planets.length + rng.range(-jitter, jitter)),
|
||||
homeMin, maxC,
|
||||
);
|
||||
}
|
||||
if (count === 0) return [];
|
||||
|
||||
// --- Parameter plumbing (every value steerable from data/asteroids.json)
|
||||
|
|
@ -760,15 +805,25 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
|
|||
const tetherRadius = homeTetherRadius();
|
||||
const homeSlots = isHome ? Math.min(count, homeMin) : 0;
|
||||
|
||||
// The dead end's anchor — its gate (barren systems are leaves of the
|
||||
// tree, so exactly one; the cluster drifts inside its level-1 tether
|
||||
// radius — an activated gate anchors a level-1 tether).
|
||||
const gateAnchor = isBarren
|
||||
? (jumps.find((j) => typeof j.x === 'number' && typeof j.y === 'number') ?? null)
|
||||
: null;
|
||||
const gateTetherRadius = anchorTetherRange(1);
|
||||
|
||||
// Names already taken in this system (planets + stations).
|
||||
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
|
||||
const usedNames = new Set();
|
||||
for (const p of planets) if (p.name) usedNames.add(p.name);
|
||||
for (const s of freeSpace) if (s.name) usedNames.add(s.name);
|
||||
|
||||
// Spacing obstacles: the home world (origin) + every placed object
|
||||
// (planets, free-space stations, and the jump gates).
|
||||
const placed = [{ x: 0, y: 0 }];
|
||||
// Spacing obstacles: the home world (origin — a real body in the
|
||||
// starting system; every other system's center is empty) + every placed
|
||||
// object (planets, free-space stations, and the jump gates).
|
||||
const placed = [];
|
||||
if (isHome) placed.push({ x: 0, y: 0 });
|
||||
for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y });
|
||||
for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y });
|
||||
for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y });
|
||||
|
|
@ -838,9 +893,21 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
|
|||
// The starting system's first clusters live INSIDE the initial tether
|
||||
// (whole group: center + bound + margin ≤ tether radius); the rest —
|
||||
// and every cluster elsewhere — go anywhere in the scatter annulus.
|
||||
const zoneMax =
|
||||
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
|
||||
const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
|
||||
// A BARREN (dead-end) system's clusters drift INSIDE the gate's
|
||||
// level-1 tether (the stop's only payload — mine it, then head back):
|
||||
// an annulus around the gate, from barren.minRadius out to the tether
|
||||
// rim minus the cluster's extent and the margin.
|
||||
let pos;
|
||||
if (isBarren && gateAnchor) {
|
||||
const bc = cfg.barren ?? {};
|
||||
const bMin = Math.max(0, Math.floor(bc.minRadius ?? 1024));
|
||||
const bMax = Math.max(bMin, gateTetherRadius - bound - tetherMargin);
|
||||
pos = placeInAnnulus(rng, bMin, bMax, placed, minSep, maxAttempts, gateAnchor);
|
||||
} else {
|
||||
const zoneMax =
|
||||
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
|
||||
pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
|
||||
}
|
||||
placed.push(pos);
|
||||
|
||||
clusters.push({
|
||||
|
|
@ -871,18 +938,18 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
|
|||
}
|
||||
|
||||
/**
|
||||
* A random point in the annulus [rMin, rMax] around the origin (uniform in
|
||||
* A random point in the annulus [rMin, rMax] around `center` (uniform in
|
||||
* AREA) that is ≥ minSep from every placed object — seeded rejection
|
||||
* sampling. If the annulus is hopelessly crowded (it isn't, at these
|
||||
* numbers) it returns the best candidate rather than failing.
|
||||
*/
|
||||
function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) {
|
||||
function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts, center = { x: 0, y: 0 }) {
|
||||
let best = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const a = rng.range(0, TAU);
|
||||
const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax));
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
const x = center.x + Math.cos(a) * r;
|
||||
const y = center.y + Math.sin(a) * r;
|
||||
let ok = true;
|
||||
let worst = Infinity;
|
||||
for (const p of placed) {
|
||||
|
|
@ -1003,12 +1070,23 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr)
|
|||
const rngS = Rng.derive(seed, 'system', record.id, 'settlements');
|
||||
const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
|
||||
const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density);
|
||||
const stations = Number(deepSpace) + Number(waypoint);
|
||||
|
||||
// 3) The planets fill the rest of the budget (N − stations ≥ 0).
|
||||
// Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors
|
||||
// its tether to a world (layoutGates), and the player must be able to
|
||||
// reach a planet and build out from it — a station-only budget
|
||||
// (N = 2 stations) demotes one station to a planet.
|
||||
let stations = Number(deepSpace) + Number(waypoint);
|
||||
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
||||
let wp = waypoint;
|
||||
let ds = deepSpace;
|
||||
while (stations >= objects && stations > 0) {
|
||||
if (wp) wp = false;
|
||||
else ds = false;
|
||||
stations = Number(ds) + Number(wp);
|
||||
}
|
||||
const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky'));
|
||||
return { objects, deepSpace, waypoint, classes };
|
||||
return { objects, deepSpace: ds, waypoint: wp, classes };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -142,9 +142,10 @@ export function buildSystemTree(o = {}) {
|
|||
/**
|
||||
* A system's NAV points — the discovery ids the scene's
|
||||
* discoverableObjects() uses for its discoverable set, minus the rocks
|
||||
* (asteroid clusters are objects, not NAV points): the central body
|
||||
* (every system — the scene's 'home' id, the player's home world in the
|
||||
* starting system and the system's central world elsewhere), every
|
||||
* (asteroid clusters are objects, not NAV points): the central body —
|
||||
* the player's home world, STARTING SYSTEM ONLY (content carries
|
||||
* homeName there; every other system has no central body — the star is
|
||||
* invisible flavor, content.star, and the origin is empty space) — every
|
||||
* planet (its name), every free-space station (its settlement id),
|
||||
* every jump gate (its gate id).
|
||||
*
|
||||
|
|
@ -152,16 +153,20 @@ export function buildSystemTree(o = {}) {
|
|||
* @returns {string[]} the discoverable ids to check (order: stable)
|
||||
*/
|
||||
/**
|
||||
* The system's NAV points (with their KIND), in a stable order: the central
|
||||
* body ('home'), then the planets (by name), the free-space stations
|
||||
* (settlement id), and the jump gates (gate id). Asteroid clusters are NOT
|
||||
* NAV points — they're objects you can mine, not chart waypoints.
|
||||
* The system's NAV points (with their KIND), in a stable order: the
|
||||
* central body ('home') — the starting system only, where it is the home
|
||||
* world — then the planets (by name), the free-space stations (settlement
|
||||
* id), and the jump gates (gate id). Asteroid clusters are NOT NAV points
|
||||
* — they're objects you can mine, not chart waypoints. A system without a
|
||||
* central body (every non-home system — the star is invisible flavor)
|
||||
* simply charts its planets, stations, and gates.
|
||||
*
|
||||
* @param {object} content — a generated system content (ensureContent)
|
||||
* @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}>
|
||||
*/
|
||||
export function navPoints(content) {
|
||||
const out = [{ id: 'home', kind: 'home' }]; // the central body — discoverable in every system
|
||||
const out = [];
|
||||
if (content?.homeName) out.push({ id: 'home', kind: 'home' }); // the home world — starting system only
|
||||
for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') out.push({ id: p.name, kind: 'planet' });
|
||||
for (const s of content?.settlements ?? []) {
|
||||
if (s && s.anchor?.type === 'space' && typeof s.id === 'string') out.push({ id: s.id, kind: 'station' });
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* One blob per browser (data/save.json → storageKey): the whole bank is
|
||||
* a single JSON document
|
||||
*
|
||||
* { "format": 1, "slots": { "3": { …save record… }, "7": { … } } }
|
||||
* { "format": 2, "slots": { "3": { …save record… }, "7": { … } } }
|
||||
*
|
||||
* keyed 1..`save.slots` (10 by default) — exactly what the pop-up shows.
|
||||
* Everything about the RECORD's shape is owned by js/save/SaveData.js
|
||||
|
|
@ -28,7 +28,12 @@
|
|||
import { config } from '../config/Config.js';
|
||||
|
||||
const DEFAULT_KEY = 'orbit.saves.v1';
|
||||
export const SAVE_FORMAT = 1;
|
||||
// FORMAT — bumped 1 → 2 with the galaxy redesign (60-system spanning-tree
|
||||
// galaxy, no rendered stars, planet-only gate anchors): format-1 saves
|
||||
// reference a galaxy that no longer exists and are REJECTED on load
|
||||
// (validateRecord) — they stay listed in the panel but fail with a clear
|
||||
// toast. New games write format 2.
|
||||
export const SAVE_FORMAT = 2;
|
||||
|
||||
export class SaveManager {
|
||||
/**
|
||||
|
|
@ -163,7 +168,7 @@ export class SaveManager {
|
|||
/**
|
||||
* Every saved game, one pretty-printed JSON document:
|
||||
*
|
||||
* { "app":"orbit", "format":1, "exportedAt":"…", "slots":{ "1":{…} } }
|
||||
* { "app":"orbit", "format":2, "exportedAt":"…", "slots":{ "1":{…} } }
|
||||
*
|
||||
* @returns {string} JSON (empty slots are omitted)
|
||||
*/
|
||||
|
|
@ -186,12 +191,20 @@ export class SaveManager {
|
|||
|
||||
/**
|
||||
* Sanity-check a save record before it's trusted (write or load):
|
||||
* it must be an object with a seed and a ship position.
|
||||
* it must be an object of the CURRENT format with a seed and a ship
|
||||
* position.
|
||||
*
|
||||
* @returns {string|null} an error message, or null when the record passes
|
||||
*/
|
||||
static validateRecord(rec) {
|
||||
if (!rec || typeof rec !== 'object') return 'corrupt save data';
|
||||
// Format gate — old builds' saves (format 1) predate the galaxy
|
||||
// redesign (the star was rendered, the gate network had loops and
|
||||
// shortcuts); their world no longer exists, so the save is a memory,
|
||||
// not a state: reject with a message the UI can toast.
|
||||
if (Number(rec.format) !== SAVE_FORMAT) {
|
||||
return `save is from an older orbit build (format ${rec.format ?? '?'}, this build needs ${SAVE_FORMAT}) — start a new game`;
|
||||
}
|
||||
if (!String(rec.seed ?? '').trim()) return 'save is missing its galaxy seed';
|
||||
if (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') {
|
||||
return 'save is missing its ship position';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
|
|||
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
|
||||
import { Ship } from '../entities/Ship.js';
|
||||
import { Planet } from '../entities/Planet.js';
|
||||
import { Star } from '../entities/Star.js';
|
||||
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
||||
import { Station } from '../entities/Station.js';
|
||||
import { JumpGate } from '../entities/JumpGate.js';
|
||||
|
|
@ -307,16 +306,15 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const j of this.systemContent.jumps ?? []) {
|
||||
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
|
||||
}
|
||||
// The CENTRAL BODY — one per system, always at the origin, always the
|
||||
// system's 'home' NAV point (js/research/SystemCategory.js → navPoints):
|
||||
// in the STARTING system it is the player's home world (the generator
|
||||
// deals its name — content.homeName — so it reads as a real place
|
||||
// rather than a generic "Terra"; the fallback keeps the old configured
|
||||
// name if the bank is ever unavailable); in every OTHER system it is
|
||||
// the system's STAR (content.star — name + spectral class). The home
|
||||
// world is the ONLY central body that is a world: only here is it a
|
||||
// comms target (settled, landable) and only here does it read "Home
|
||||
// World" — a star is not a comms object, and never "home".
|
||||
// The CENTRAL BODY — the home world, present ONLY in the STARTING
|
||||
// system, at the world origin (the generator deals its name —
|
||||
// content.homeName — so it reads as a real place rather than a generic
|
||||
// "Terra"; the fallback keeps the old configured name if the bank is
|
||||
// ever unavailable). It is the only central body in the galaxy: in
|
||||
// every other system the center is EMPTY (the star is invisible flavor
|
||||
// — content.star name/class feeds the dossier and gate names, never
|
||||
// rendered) and is the ONLY system where the origin is a comms target
|
||||
// (settled, landable) and reads "Home World".
|
||||
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
|
||||
// across jumps. Comparing against galaxy.currentSystemId would be
|
||||
// wrong: that pointer tracks where the player IS NOW, so every
|
||||
|
|
@ -328,7 +326,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.createSystemHud();
|
||||
|
||||
if (this.isHomeSystem) {
|
||||
// The home planet — the player's Terran world, present ONLY in the
|
||||
// The home world — the player's Terran world, present ONLY in the
|
||||
// system the player starts in. It sits at the world origin. Which
|
||||
// Terran face it shows comes from the galaxy-wide frame pass
|
||||
// (content.homeFrame — the same (class, face) spreading as the
|
||||
|
|
@ -342,14 +340,14 @@ export class GameScene extends Phaser.Scene {
|
|||
: Planet.frameFor(homeName, homeRng);
|
||||
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
|
||||
this.planet.discoveryName = this.homeWorldName;
|
||||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||||
} else {
|
||||
// The system's star — the central body of every other system
|
||||
// (content.star from the generator: name + spectral class).
|
||||
const star = this.systemContent.star ?? {};
|
||||
this.planet = new Star(this, 0, 0, star);
|
||||
this.planet.discoveryName = star.name ?? 'Star';
|
||||
// Every other system has NO central body: the star is INVISIBLE
|
||||
// flavor (content.star — name/class/binary — feeds the dossier and
|
||||
// gate names, but is never rendered). The origin is empty space;
|
||||
// the gate tethers are the player's anchors here.
|
||||
this.planet = null;
|
||||
}
|
||||
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
|
||||
// generator (data/planets.json → solarSystem) on orbits around the
|
||||
|
|
@ -427,17 +425,27 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Every solid in the system — worlds first (their keep-out circles are
|
||||
// disjoint), then the clusters, then the stations, then the gates.
|
||||
// disjoint), then the clusters, then the stations, then the gates. The
|
||||
// central body (the home world — the starting system only; other
|
||||
// systems have no star) leads when it exists.
|
||||
// Ship constraint, click-to-fly clamping and autopilot all run
|
||||
// against this list.
|
||||
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
|
||||
// The central body's discovery id is 'home' in EVERY system — it is
|
||||
// the chart's central NAV point (SystemCategory.navPoints); its NAME
|
||||
// differs: the home world in the starting system, the star elsewhere.
|
||||
this.planet.discoveryId = 'home';
|
||||
this.solids = [
|
||||
...(this.planet ? [this.planet] : []),
|
||||
...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates,
|
||||
];
|
||||
// The central body's discovery id is 'home' — the chart's central NAV
|
||||
// point (SystemCategory.navPoints) — in the STARTING SYSTEM ONLY (the
|
||||
// home world). Every other system's center is empty: no 'home' NAV
|
||||
// point there (its star is invisible flavor, not a chart waypoint).
|
||||
if (this.planet) this.planet.discoveryId = 'home';
|
||||
|
||||
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
|
||||
// rim, in a seed-derived direction: same galaxy ⇒ same start.
|
||||
// The ship — in the starting system, a short hop (~150 px,
|
||||
// edge-to-edge) from the home world's rim, in a seed-derived
|
||||
// direction: same galaxy ⇒ same start. Everywhere else the ship's
|
||||
// position comes from the jump/save restore (the arrival gate), so
|
||||
// the origin (an empty center — no star) is only a defensive
|
||||
// fallback.
|
||||
if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) {
|
||||
console.warn(
|
||||
`[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`,
|
||||
|
|
@ -445,11 +453,13 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
this.ship = new Ship(this, 0, 0);
|
||||
this.ship.setDepth(10);
|
||||
const spawn = this.planet.edgePoint(
|
||||
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
|
||||
config.get('planets.spawnDistanceFromEdge', 150),
|
||||
this.ship.radius,
|
||||
);
|
||||
const spawn = this.planet
|
||||
? this.planet.edgePoint(
|
||||
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
|
||||
config.get('planets.spawnDistanceFromEdge', 150),
|
||||
this.ship.radius,
|
||||
)
|
||||
: { x: 0, y: 0 };
|
||||
this.ship.setPosition(spawn.x, spawn.y);
|
||||
|
||||
// Center the camera on the ship from the very first frame.
|
||||
|
|
@ -462,22 +472,28 @@ export class GameScene extends Phaser.Scene {
|
|||
this.starfield = new Starfield(this);
|
||||
this.starfield.create();
|
||||
|
||||
// The TETHER — the player's range. Starts as one level-1 tether anchored
|
||||
// on the home world (data/tether.json): the ship may fly anywhere within
|
||||
// its rim (level 1 = 5120 px from the planet's center); the rim is a
|
||||
// hard barrier drawn as a thick glitchy dotted line. The field owns the
|
||||
// tether list (add/remove/setLevel are the seam the build system will
|
||||
// use later); where multiple tethers' zones overlap there is no line
|
||||
// and no wall — the union is the player's space.
|
||||
// The TETHER — the player's range. In the STARTING system it starts
|
||||
// as one level-1 tether anchored on the home world (data/tether.json):
|
||||
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
|
||||
// planet's center); the rim is a hard barrier drawn as a thick
|
||||
// glitchy dotted line. In every OTHER system there is no native
|
||||
// tether — the center is empty — the player's range is the activated
|
||||
// GATE tethers (the arrival gate anchors a level-1 tether on landing,
|
||||
// data/gates.json → ACTIVITY). The field owns the tether list
|
||||
// (add/remove/setLevel are the seam the build system will use later);
|
||||
// where multiple tethers' zones overlap there is no line and no wall
|
||||
// — the union is the player's space.
|
||||
this.tetherField = new TetherField(this, {
|
||||
depth: 6, // above planets (5), below the ship (10)
|
||||
});
|
||||
this.tetherField.add(
|
||||
config.get('tether.homeId', 'home'),
|
||||
0, 0, // the central body's center (home world or star — the system origin)
|
||||
config.get('tether.homeLevel', 1),
|
||||
config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName),
|
||||
);
|
||||
if (this.isHomeSystem) {
|
||||
this.tetherField.add(
|
||||
config.get('tether.homeId', 'home'),
|
||||
0, 0, // the home world's center (the system origin)
|
||||
config.get('tether.homeLevel', 1),
|
||||
config.get('tether.homeLabel', '') || this.homeWorldName,
|
||||
);
|
||||
}
|
||||
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
|
||||
// an active gate is a TETHER ANCHOR in its own right — the room to
|
||||
// move in a barren system). Idempotent: add() replaces by id, and a
|
||||
|
|
@ -1444,23 +1460,23 @@ export class GameScene extends Phaser.Scene {
|
|||
/** The discoverable objects of the system, with compass metadata. */
|
||||
discoverableObjects() {
|
||||
const out = [];
|
||||
// The central body — the player's home world in the starting system,
|
||||
// the system's star in every other one (discovery id 'home' either
|
||||
// way: the chart's central NAV point).
|
||||
out.push({
|
||||
id: 'home',
|
||||
x: this.planet.x,
|
||||
y: this.planet.y,
|
||||
radius: this.planet.radius,
|
||||
typeLabel: this.isHomeSystem
|
||||
? config.get('planets.homeTypeLabel', 'Home World')
|
||||
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'),
|
||||
// The compass accent: the home world is a PLANET (green —
|
||||
// data/planets.json → compassColor, like the system's worlds); the
|
||||
// star isn't a planet and keeps the compass's default neon cyan.
|
||||
color: this.isHomeSystem ? config.get('planets.compassColor', '#3dff88') : undefined,
|
||||
name: this.planet.discoveryName,
|
||||
});
|
||||
// The central body — the home world in the STARTING SYSTEM ONLY
|
||||
// (discovery id 'home': the chart's central NAV point). Every other
|
||||
// system's center is empty — its star is invisible flavor (content.star
|
||||
// feeds the dossier, not the chart) — so no central NAV point there.
|
||||
if (this.planet) {
|
||||
out.push({
|
||||
id: 'home',
|
||||
x: this.planet.x,
|
||||
y: this.planet.y,
|
||||
radius: this.planet.radius,
|
||||
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||||
// The compass accent: the home world is a PLANET (green —
|
||||
// data/planets.json → compassColor, like the system's worlds).
|
||||
color: config.get('planets.compassColor', '#3dff88'),
|
||||
name: this.planet.discoveryName,
|
||||
});
|
||||
}
|
||||
for (const p of this.systemPlanets) {
|
||||
out.push({
|
||||
id: p.discoveryId,
|
||||
|
|
@ -1666,9 +1682,10 @@ export class GameScene extends Phaser.Scene {
|
|||
* run moves to the connected system, arriving near that system's
|
||||
* RETURN gate (the one pointing back — activated with the gate per
|
||||
* the ACTIVITY rule, its tether anchoring the ship's room to move;
|
||||
* the pure geometry is js/galaxy/JumpTravel.js). One-way shortcut
|
||||
* jumps have no return gate — they land on the destination's star,
|
||||
* inside its home-tether zone.
|
||||
* the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF
|
||||
* the network is a pure spanning tree, so every jump has a return
|
||||
* gate; the origin fallback below is defensive only (the destination
|
||||
* center is empty — its star is invisible flavor).
|
||||
*
|
||||
* HOW: the save pipeline, in miniature. captureState() snapshots the
|
||||
* WHOLE run (discovery, reputation, research, builds, minerals,
|
||||
|
|
@ -1703,8 +1720,9 @@ export class GameScene extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
// Where we arrive: just past the return gate's keepout (JumpTravel).
|
||||
// Fallback — no return gate (a one-way shortcut): the destination's
|
||||
// star, inside its home tether (added unconditionally in create()).
|
||||
// Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
|
||||
// the current config): the destination's origin (its center is empty —
|
||||
// the star is invisible flavor, so this is just open space).
|
||||
const destContent = this.galaxy.ensureContent(destId);
|
||||
const arrival =
|
||||
jumpArrival(destContent, from.id, {
|
||||
|
|
@ -2335,18 +2353,23 @@ export class GameScene extends Phaser.Scene {
|
|||
label: t.label ?? '',
|
||||
}));
|
||||
|
||||
// central body: the home world in the starting system, else the star
|
||||
// central body: the home world in the starting system; every other
|
||||
// system's center is EMPTY — the star is invisible dossier flavor
|
||||
// (name/class feed the plate tag, never a rendered disc: `visible`
|
||||
// is false for the map's central-body draw)
|
||||
const starClass = String(content.star?.class ?? '').toUpperCase();
|
||||
const central = this.isHomeSystem
|
||||
? {
|
||||
name: this.homeWorldName ?? 'Terra',
|
||||
isHome: true,
|
||||
visible: true,
|
||||
radius: this.planet?.radius ?? 200,
|
||||
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||||
}
|
||||
: {
|
||||
name: this.planet?.discoveryName ?? 'Star',
|
||||
isHome: false,
|
||||
visible: false,
|
||||
radius: 240,
|
||||
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
|
||||
color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { config } from '../config/Config.js';
|
|||
*
|
||||
* STARS & THE GALAXY — synthesised from syllable pools (data/naming.json →
|
||||
* star / galaxy). Short, consistent, and effectively unlimited: there are
|
||||
* 200 systems and no reason to run out of star names.
|
||||
* 60 systems and no reason to run out of star names.
|
||||
*
|
||||
* PLANETS & STATIONS — drawn from finite, curated NAME BANKS (data/
|
||||
* naming.json → banks). A deep pool of hand-picked names — colonial
|
||||
|
|
|
|||
Loading…
Reference in New Issue