1391 lines
86 KiB
Markdown
1391 lines
86 KiB
Markdown
# Orbit — Project Notes
|
||
|
||
Working agreements and conventions for building this game. Read this before
|
||
adding new systems — these are the rules that keep the project scalable.
|
||
|
||
## Commits (important)
|
||
|
||
- **Brian makes the commits, manually.** After finishing a change, leave the
|
||
work staged/unstaged in the working tree with a suggested commit message
|
||
(in the chat reply, or a note here), and do NOT run `git commit` yourself.
|
||
This gives Brian the chance to review the code before it enters history.
|
||
- Amends/reverts are fine if asked explicitly.
|
||
|
||
## What we're building
|
||
|
||
A procedurally generated space RPG in the spirit of **Privateer**, but in
|
||
**top-down 2D**: stars/sectors you can jump between, ships to fly, crew,
|
||
trading/economy, stations, quests — to be scoped as we go.
|
||
|
||
**Hard technical constraints:**
|
||
|
||
- Plain **ES6 modules**, no transpilation, no build step.
|
||
- **No package managers required to run** — the game must work from any
|
||
static HTTP server (the browser fetches everything).
|
||
- Third-party libraries are **vendored** into `lib/` (right now:
|
||
`lib/phaser.min.js`, Phaser 4.2.1 UMD build, see `lib/PHASER_LICENSE.md`).
|
||
- Must be served over http(s), not `file://` (ES modules + `fetch` of JSON).
|
||
|
||
## Config lives in JSON (important)
|
||
|
||
- Tunable data lives in `data/*.json`: dimensions, colors, text, physics,
|
||
balance, spawn tables, and anything a non-programmer might want to tweak.
|
||
- `data/manifest.json` lists which files to load. **Add a config file =
|
||
drop it in `data/` + one line in the manifest.** It is then available as
|
||
a section named after the file (`ship.json` → `config.get('ship.thrust')`).
|
||
- Code reads config through the `config` singleton (`js/config/Config.js`):
|
||
```js
|
||
import { config } from '../config/Config.js';
|
||
const thrust = config.get('ship.thrust', 900);
|
||
const menu = config.section('menu', {});
|
||
```
|
||
Always supply a sensible fallback so code never depends on a missing key.
|
||
- **Rule of thumb:** if a value might ever change (balance, layout, copy,
|
||
colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns
|
||
*parameters*.
|
||
- Split config by concern as the game grows: world generation already has
|
||
`data/galaxy.json` (shape/scale), `data/systems.json` (archetypes), and
|
||
`data/naming.json` (syllable pools + name banks); next up might be `data/economy.json`,
|
||
`data/ships.json`, `data/crew.json`, etc. One file per system beats one
|
||
giant file.
|
||
|
||
## Code is modular & class-based (important)
|
||
|
||
- One class per file, ES module exports, no globals (except the deliberate
|
||
singletons: `config`).
|
||
- Layering:
|
||
- `js/scenes/` — Phaser scenes: state + orchestration only (thin classes)
|
||
- `js/entities/` — in-world objects that own their behavior (Ship, NPC, …)
|
||
- `js/ui/` — reusable UI components (MenuButton, panels, HUD)
|
||
- `js/visuals/` — decorative, non-interactive visuals (Starfield, …)
|
||
- `js/galaxy/` — the world model: seeded Galaxy, system archetypes,
|
||
lazy content generation (Galaxy, SystemGenerator)
|
||
- `js/utils/` — small pure helpers (Color, Rng, NameGenerator)
|
||
- `js/config/` — config loading + Phaser game config
|
||
- `js/vendor/` — shims to pinned third-party libraries
|
||
- Scenes stay thin: they compose entities/UI and wire input. They don't hold
|
||
balance numbers or game rules.
|
||
- Every class should be standalone-constructible: it takes what it needs
|
||
(`scene`, config values) in its constructor rather than reaching for
|
||
globals — that keeps things testable and reusable.
|
||
- Entities drive themselves from their own `update(time, delta)` method,
|
||
called by the owning scene (e.g. `GameScene.update`). If an entity ever
|
||
needs the engine's auto-update instead, define `preUpdate` (the Phaser v4
|
||
hook — v4 does not auto-call `update`).
|
||
- Import Phaser only through `js/vendor/phaser.js` — the single place that
|
||
changes if we swap framework versions.
|
||
|
||
## World model — the galaxy (important)
|
||
|
||
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, d, zone) up front. 90 systems is ~10 ms, so the
|
||
whole galaxy is always known: the player can never "discover" a layout
|
||
that wasn't already implied by the seed.
|
||
2. **Contents** — planets/moons/belts/**settlements**/hazards are
|
||
generated **lazily** on first arrival (`galaxy.ensureContent(id)`),
|
||
then cached. Each system's draw stream is `Rng.derive(seed, 'system', id)`
|
||
— independent of generation order — so lazy and eager
|
||
(`galaxy.generateAll()`) results are identical. Pick whichever is
|
||
cheaper at runtime; correctness never depends on it.
|
||
|
||
**Determinism rules (keep them!):**
|
||
- All generation draws go through `Rng` (`js/utils/Rng.js`). Never
|
||
`Math.random()` in a generation path.
|
||
- Anything that must be order-independent (names, contents) draws from a
|
||
`Rng.derive(seed, <purpose>, id)` stream — never from the galaxy-level
|
||
sequence. The roster loop is allowed to use its own sequence because
|
||
the loop order itself is part of the seed contract.
|
||
- Seeds are trimmed; seed → hash → stream is one-way.
|
||
- Dev tools that assert determinism: `dev/galaxy.test.mjs`.
|
||
|
||
**System archetypes** live in `data/systems.json`. Each type is:
|
||
- **themable** — `theme.color` etc. for UI;
|
||
- **attribute-driven** — `attributes` steer the SystemGenerator (star
|
||
classes, binary chance, planet class weights, moon/belt chances,
|
||
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 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.
|
||
- **planet frames are spread galaxy-wide** — `data/planets.json → frames`
|
||
maps each class to its spritesheet face pool. A random per-system pick
|
||
would let neighboring stars wear the same face, so `Galaxy._generate()`
|
||
runs one galaxy-wide pass (`js/galaxy/PlanetFrames.js →
|
||
assignPlanetFrames`): systems in a FIXED spatial order (x, y — a pure
|
||
function of the seeded roster, so it never depends on visit order)
|
||
each pick the least-used (class, frame) among their already-assigned
|
||
nearest stars, tie-broken by a derived seeded Rng. The stamps land on
|
||
each planet record (`planet.frame`) and the home world's face on
|
||
`content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`.
|
||
- **distributed** — a seeded WIDE (2:1) field of stars
|
||
(`galaxy.layout.field`: `width`, `height`, `minSpacing`) placed by
|
||
Bridson Poisson-disk sampling — even, organic spacing (no clumps, no
|
||
voids, not a grid). The 2:1 shape matches the map plate (830×414 at
|
||
the 1280×720 design size), and the plate fit is a true rectangle fit
|
||
with a plate-px margin on all sides (`map.galaxy.platePadding`) — the
|
||
fully-zoomed-out galaxy fills the plate and no star sits on its edge. The player's home sits in the star NEAREST the configured
|
||
corner (`startingSystem: policy "corner"`, `corner: SE` = lower right;
|
||
`center`/`random` still work), and the HOME→FAR diagonal is the
|
||
progression axis: every record carries `d` (0 = home corner, 1 = far
|
||
corner) and its `zone` (`galaxy.distribution.zones`, equal-area thirds:
|
||
near/middle/far). Types mix PER ZONE — `galaxy.distribution.zoneMix`
|
||
multiplies each type's global `distribution.weight` per zone (the old
|
||
per-type `radiusBand` is gone: `void`/`nebula` favor the deep corner,
|
||
`habitable` the home corner). Richer galaxy-level rules (faction
|
||
territories, trade hubs, combat difficulty by zone) will read
|
||
`record.zone` / `record.d` — the seams are in place now.
|
||
|
||
The player's **current system** starts at `galaxy.currentSystem()`
|
||
(`startingSystem.policy`: `corner` (default — the home corner, SE),
|
||
`center`, or `random`). Jumping between systems
|
||
now has its NETWORK (see "Jump gates" below — `data/gates.json` +
|
||
`js/galaxy/JumpNetwork.js`, built on `galaxy.neighborsOf(id)` and the
|
||
spatial hash); the in-flight jump drive is the next mechanic on top.
|
||
|
||
**The galaxy is already lived in.** It was settled long before the
|
||
player arrives. **Every planet is settled (for now)**: each world hosts
|
||
the one settlement kind that fits its class (`data/settlements.json →
|
||
allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable
|
||
rocky world, `miningStation` over every other rocky/ice/lava world,
|
||
`cloudBase` riding every gas giant. Plus the free-space kinds:
|
||
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon
|
||
— the faint trace of a crossed galaxy), still rolled per archetype and
|
||
thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are
|
||
the one exception to "lived in": they hold no planets and no stations —
|
||
just the jump gate — and are reachable only by jumping in (strong
|
||
connectivity keeps them on the network). Their "charted · unclaimed" report
|
||
branch is now the normal case, not a defensive fallback. Model & seams:
|
||
- **Kinds vocabulary** — `data/settlements.json` (label, description,
|
||
theme color, population range, anchor type). Add a kind = JSON + naming
|
||
pool; the generator picks it up by name.
|
||
- **The settled rule** — `data/settlements.json → allPlanetsSettled` +
|
||
`settledKindByClass` (class → kind, with a `habitable` override for
|
||
rocky worlds). Flip `allPlanetsSettled` off to return to the old
|
||
probabilistic layer (per-type `chance` + `needs` — the code path is
|
||
kept behind the flag).
|
||
- **Per-type free-space rates** — `types.<id>.attributes.settlements` in
|
||
`data/systems.json`: `chance` per free-space kind (deep-space station,
|
||
waypoint). Same attribute-driven pattern as everything else.
|
||
- **Home→far gradient** — `galaxy.settlements.gradient` in
|
||
`data/galaxy.json`: the settled heart (the home corner, `d = 0`) is
|
||
denser (factor 1.0), the deep corner (`d = 1`) is thinner (clamped to
|
||
`floor`). Each record carries `d` (0 = home corner, 1 = far corner) and
|
||
its `zone` so density — and later faction strength, hazard, and trade
|
||
value — is per-system, not global.
|
||
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's
|
||
where **factions and pirates** will plug in later (claim, flag,
|
||
relations). Deliberately absent for now — no factions yet.
|
||
- **Pure report formatter** — `js/galaxy/SystemReport.js`
|
||
(`formatSystemReport(content)` → title/subtitle/faction/population/
|
||
status). Compact by design: the HUD shows the system name, identity
|
||
("Main Sequence system · star G"), and standing ("Faction: Neutral ·
|
||
Pop ~1.2M" — faction is Neutral for now; unclaimed systems read
|
||
"Unclaimed"). Per-settlement / gate detail stays in the content +
|
||
star chart. The GameScene HUD renders it; a future star map / terminal
|
||
UI reuses it.
|
||
- Landing/exploration (a future feature) will treat settlements as points
|
||
of interest: the data already says what's there and where (anchor =
|
||
planet ordinal or open space).
|
||
|
||
**Naming** — planets and stations get their names from **curated banks** in
|
||
`data/naming.json → banks` (distinct from the star/galaxy *syllable pools*):
|
||
- **`banks.planet`** — a deep list mixing **colonial** names ("New Denver",
|
||
"Port Tucson") and **alien** names ("Klaxoria", "Vexithunhal").
|
||
- **`banks.station`** — a deep list mixing **official/procedural**
|
||
designations ("Deep Space SC-145", "Nav Relay V-77") and **smuggler** /
|
||
outlaw hangouts ("Hell's Hideout", "The Rusty Anchor").
|
||
- **Assignment** — `NameGenerator.planetDeck(rng)` / `stationDeck(rng)`
|
||
build one seeded shuffle of the whole bank; `SystemGenerator` deals names
|
||
out of it in order. So **within a system there are no repeats** (the first
|
||
N draws are N distinct names), and a system only reuses a name if it has
|
||
more bodies than the bank holds (impossible at these sizes). The decks are
|
||
dealt from dedicated derived streams (`Rng.derive(seed,'system',id,'names',
|
||
'planets'|'stations')`) so they stay order-independent (lazy === eager).
|
||
The **home world** (the player's planet, in the starting system only) takes
|
||
the first name off that system's planet deck — so it gets a real bank name
|
||
too, never clashing with one of the planets (`content.homeName`; the old
|
||
`planets.homeName` is now just a fallback). Stars and the galaxy are still
|
||
synthesised from syllable pools (unbounded).
|
||
|
||
## The current system is a place, not just a dossier (discovery + compass)
|
||
|
||
The player starts in a solar system, and the other worlds **exist in the
|
||
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 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 (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
|
||
ratio), and the rotation (ring phase) is a deterministic scan that
|
||
serves the system's gate target bearings. The starting system is capped
|
||
at 3 objects (2 planets + ≤ 1 station) — five points cannot sit
|
||
6400–10240 px apart (the tightest 5-point spacing needs ratio ≥ φ >
|
||
1.6). Planet-bound settlements (colonies, mining stations, cloud bases)
|
||
are features of their planet, not layout objects.
|
||
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
|
||
'layout')` — layout results are seed-deterministic *and* don't perturb
|
||
the content stream (lazy === eager is preserved). Class sizes/tints in
|
||
`classScale`/`classTint`.
|
||
- **Discovery** — `js/galaxy/Discovery.js` (pure, no Phaser — Node-
|
||
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 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
|
||
screen edge with a type/name chip, separated by angle when rays crowd.
|
||
Edge-anchor geometry (`edgeAnchor`, `circleInView`, `lerpAngle`) is
|
||
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 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
|
||
state, JSON round-trip, layout band + determinism, compass geometry).
|
||
|
||
## Jump gates — the galaxy's highway layer
|
||
|
||
Every system has **1–3 jump gates** (`data/gates.json`), each one the
|
||
exit toward a NEARBY star on the 2-D map. Two layers:
|
||
|
||
- **The network** — `js/galaxy/JumpNetwork.js` (pure, Node-tested;
|
||
built once per galaxy in `Galaxy._generate()`, exposed as
|
||
`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; `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 — 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
|
||
intersection (gate exactly on the system→star ray), the circle point
|
||
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 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 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
|
||
return gates in the systems they connect to, and each activated gate
|
||
anchors a level-1 tether so the player can leave. A gate's `rotation`
|
||
is the bearing from the gate to its
|
||
destination star — the art (twin-pylon portal, `js/entities/JumpGate.js`
|
||
— procedural, Station.js-style) faces where it jumps; the entity's
|
||
`activate()` lifts the dormant dimming (the anchor tether comes from
|
||
the scene).
|
||
- **In the scene** — GameScene builds the gates as solid world objects
|
||
(the ship keeps `gates.shipClearance` from them, autopilot flies to
|
||
their rim), discoverable (compass arrows + toast, in the gate cyan
|
||
`#5fd4ff`), and the HUD dossier lists them ("… Gate · jump to
|
||
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them;
|
||
`gateAt()` is their own hit test) — a click on ANY gate opens the
|
||
GATE COMM WINDOW (the shared comms panel, js/ui/CommsPanel.js, in its
|
||
gate variant — the LINK line reads ACTIVE / DORMANT, REQUEST JUMP
|
||
initiates the jump and is a grayed ghost while dormant, CANCEL
|
||
closes it) — never the ordinary fly-here.
|
||
- **The JUMP** (`data/gates.json` → `jump`; the pure geometry is
|
||
`js/galaxy/JumpTravel.js`, Node-tested by `dev/jump-travel.test.mjs`):
|
||
`GameScene.jumpThroughGate` re-stages the run for the connected
|
||
system through the save pipeline in miniature — `captureState()`
|
||
snapshots the whole run (discovery, reputation, research, builds,
|
||
minerals, playtime, `activatedGates`), the destination system +
|
||
arrival position are swapped in, `prepareLoad()` rebuilds the galaxy
|
||
from the seed (deterministic — same names/placements) with the new
|
||
current system, and `scene.restart()` rebuilds this scene onto it:
|
||
the destination's native tethers re-form in `create()`, and
|
||
`_onEnterSystem()` grants its map tech. ARRIVAL: the ship materialises
|
||
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; 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)`) —
|
||
this scene's camera follows the ship and scrolls, so a world-space clip
|
||
(the default `scrollFactor 1`) renders off-screen and you'd hear it
|
||
without seeing it (the same reason every screen UI here pins itself).
|
||
The destination is ALREADY staged behind it (`prepareLoad` ran first),
|
||
so the scene restarts the moment the clip ends — or errors, or stalls (a STALL guard advances after a grace period with no playback
|
||
progress; a CAP does so at duration + margin), whichever fires first
|
||
(`_playJumpClip`/`_finishJump`, both idempotent). A DOUBLE-CLICK (two
|
||
quick presses, the 350 ms window shared with the landing/takeoff skip)
|
||
skips the rest of the clip; a single press is swallowed. If `video` is
|
||
empty/missing the jump falls back to the short `jumpDelayMs` cut.
|
||
A mid-jump guard (`_jumping`) swallows input for the whole clip;
|
||
mining blocks the jump (console nudge).
|
||
- **Determinism** — same seed ⇒ same network, same gates, same
|
||
placements, same `active` flags, same jump arrivals. Verified:
|
||
`dev/jumps.test.mjs` (network invariants, placement invariants, barren
|
||
gates on-ray, `active: false` everywhere, composition buckets,
|
||
determinism), `dev/jumpgate.test.mjs` (the entity's solid contract +
|
||
dormant/active rendering), `dev/jump-travel.test.mjs` (the jump's
|
||
config + pure arrival geometry + a real galaxy's return-gate
|
||
coverage + determinism), and the layout band in
|
||
`dev/discovery.test.mjs`.
|
||
|
||
## Galaxy map — the MAP console's GALAXY tab
|
||
|
||
The deck's MAP button opens the cartography console
|
||
(`js/ui/MapWindow.js`, `data/map.json`, depth 80). It has three tabs on
|
||
the plate:
|
||
|
||
- **CURRENT SYSTEM** — the system chart (canvas-painted: discovered
|
||
objects, the tether union, the fog of war), hover + ENGAGE AUTOPILOT,
|
||
wheel zoom / drag pan. Unchanged by the galaxy work.
|
||
- **SYSTEM** — the same painter applied to a CHARTED star's system
|
||
(no ship/tether): pick a charted star on the GALAXY tab to arm it, then
|
||
click an object on its chart to **SET DESTINATION** (see the Route
|
||
section below). Locked ("NO SYSTEM TARGET") until a charted star is
|
||
picked. The shared `ConfirmOverlay` is label-sized to fit the dialog
|
||
(`baseWidth` + measured width, per-show `width`/`height` overrides).
|
||
- **GALAXY** (live — `tabs.galaxy.standby` is the killswitch; `true`
|
||
returns it to the old "OFFLINE" toast) — the whole-galaxy chart,
|
||
`js/ui/GalaxyView.js`, fed by `GameScene.galaxySnapshot()`
|
||
(`js/galaxy/GalaxyChart.js`, pure + Node-tested):
|
||
- **A glowing star per system**, colored by its archetype
|
||
(`data/systems.json → types`, 6 kinds) and **pulsing on that
|
||
archetype's heartbeat** — `galaxy.pulse.<type>` in `data/map.json`
|
||
(`speed` Hz + `amp`, the per-star-type animation; binary beats fast
|
||
with a seeded twin core, nebulae shimmer, voids breathe slow).
|
||
- **The jump-lane web** — the JumpNetwork's spanning tree as thin
|
||
lanes, three reads: TRAVELED (the run jumped it — bright glow pass +
|
||
a flow packet drifting the route), FRONTIER (exactly one end charted
|
||
— the "next step"), unexplored (faint threads). Lane state comes from
|
||
the run's footprint (below) + the SYSTEM category's activated gates
|
||
(`activatedGates`, the LIVE lanes a jump can be confirmed on).
|
||
- **The charted region** — the convex hull of the visited systems,
|
||
inflated (`galaxy.hull`): a soft fill + subtle double outline (the
|
||
discovered-area shading).
|
||
- **HOME + SHIP markers**, ambient dust blobs + the core glow, and a
|
||
reveal ripple OUT OF HOME on tab switch / window open
|
||
(stars bloom by distance — `galaxy.reveal`).
|
||
- **Interaction** (the plate idiom): wheel zoom about the cursor,
|
||
drag pan, double-tap 1×; hover = ring + tooltip (name, type, gate
|
||
count, charted state, link state to the current system); click a
|
||
star: current → "YOU ARE HERE"; LIVE lane → **CONFIRM JUMP** (the
|
||
same transport as the world's gate click — `GameScene.jumpFromMap` →
|
||
`jumpThroughGate`); dormant lane / no lane → the readouts. The
|
||
window is a pure view: the galaxy tab polls `getGalaxy()` on the
|
||
same 500 ms cycle the system chart is.
|
||
|
||
**The run's footprint (save-ready):** `GameScene` keeps two
|
||
registry-backed sets (the `activatedGates` pattern) — `visitedSystems`
|
||
(the systems the run has ENTERED; the current one is marked at
|
||
`create()`) and `usedGates` (the undirected lanes the run has TRAVELED —
|
||
`GalaxyChart.edgeKey(a,b)` = `min<max`, so a lane is the same key from
|
||
either end). `jumpThroughGate()` records the lane + destination right
|
||
before `captureState()`, so the save carries the footprint; `SaveData`
|
||
captures/restores/resets both, and a save that predates the tab
|
||
defaults to "current system charted, nothing traveled" (old saves load).
|
||
|
||
**FACTIONS (planned — not yet implemented):** the snapshot already
|
||
reserves the seam — every system carries `faction: null`, and
|
||
`GalaxyView`'s header documents the two reserved rendering passes for
|
||
when they ship: a **faction color layer** over the stars (a ring/tint on
|
||
top of the archetype color) and a **territory region fill** (the exact
|
||
hull/fill recipe of the charted region, per faction color + relation
|
||
alpha). The settlements' `owner: null` seam (above) +
|
||
`Reputation.factionStanding()` are the data-side counterparts.
|
||
|
||
- **Tuning** — all of it in `data/map.json → galaxy` (stars, pulse per
|
||
archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
|
||
- **Tests** — `dev/galaxy-map.test.mjs` (edgeKey symmetry, the snapshot's
|
||
systems/lanes/flags + the faction seam, hull/padding geometry incl.
|
||
the parallel-polygon corners, the pulse's bounds + determinism, the
|
||
archetype colors, and the save round-trip incl. the legacy default).
|
||
|
||
## Route — SET DESTINATION (the MAP console's SYSTEM tab)
|
||
|
||
The SYSTEM tab charts a CHARTED star's system (the same painter as the
|
||
CURRENT SYSTEM tab, minus the ship/tether). Clicking an object on that
|
||
chart asks **SET DESTINATION** (`MapWindow._askObject` → the shared
|
||
`ConfirmOverlay`, widened to fit the label). Confirming plots the route
|
||
and closes the map window.
|
||
|
||
**The route is between SYSTEMS, and it is DERIVED** (`js/galaxy/Route.js`,
|
||
pure + Node-tested). The run persists ONLY the destination —
|
||
`GameScene.destination = { systemId, objectId }` (registry-backed, like
|
||
`visitedSystems`). The path from wherever the ship is NOW to that system
|
||
is computed on demand by `planRoute()` (Dijkstra by travel distance over
|
||
the gate graph — the jump network is a spanning tree,
|
||
`data/gates.json → shortcuts:false`, so this is the unique route;
|
||
strong connectivity guarantees one always exists). Deriving it (instead
|
||
of storing a progress counter) makes the follow/detour rules automatic:
|
||
|
||
- **Set** (`setDestination`) — store the destination, toast the hop count
|
||
(`data/gates.json → route.setToast`), close the map. The compass then
|
||
points at the route's NEXT STOP.
|
||
- **Next stop** — the route's NEXT STOP is marked on the compass in
|
||
**ORANGE** (`route.compassColor`), shown even while still UNDISCOVERED
|
||
(it is the thing to find), and its ordinary arrow is suppressed so the
|
||
two don't stack; on screen there is no arrow (the player can see it).
|
||
It is the CURRENT SYSTEM's jump gate toward the destination
|
||
(`routeNextGate`, `route:` id, `route.typeLabel`) in an INTERMEDIATE
|
||
system, or the DESTINATION OBJECT itself (`routeDestinationObject`,
|
||
`dest:` id, `route.destTypeLabel`) once the player is in the destination
|
||
system — the last hop is across the system to the world. Both are
|
||
`alwaysFull` (never fold to the text-less far chip) and resolvable by
|
||
`autopilotTo` (which strips the `route:`/`dest:` prefix).
|
||
- **Jump** (`jumpThroughGate` → `_routeForJump`, BEFORE `captureState` so
|
||
the cleared state is what saves): entered the DESTINATION with a specific
|
||
OBJECT → the trip is NOT over (the last leg is across the system), so the
|
||
destination stays active and the compass now points at that object; entered
|
||
the DESTINATION with no object → route done, clear `destination` (the
|
||
compass orange drops) + a REACHED notice; entered the route's NEXT → on
|
||
course, silent (the derived route just shortens); entered anything else →
|
||
a DETOUR, the route re-plots from the detour + a RE-PLOTTED notice. Because
|
||
the route is derived, the re-plot is automatic — this only announces it.
|
||
- **Reached** (`_checkDestinationReached`, each frame) — when the player is
|
||
in the destination system and the ship is within the destination object's
|
||
keep-out rim, the trip is done: clear `destination` (the compass orange
|
||
drops, the SYSTEM tab unlocks) + a REACHED toast (fired directly — the
|
||
player is already in this scene, no jump cut to ride out).
|
||
- **Notices** — a jump fires its own toast + full-screen clip, which would
|
||
swallow an immediate REACHED/RE-PLOTTED toast, so those are QUEUED in
|
||
the registry (`routeNotice`) and surfaced by the DESTINATION scene's
|
||
`create()` a beat after spawn. `resetRunState` clears it (a New Game
|
||
inherits no pending notice).
|
||
- **Save** — `destination` is captured/restored/reset in
|
||
`js/save/SaveData.js` (a save predating it has no field → no route);
|
||
the route itself is re-derived at load from the saved current system.
|
||
|
||
**Tuning** — `data/gates.json → route` (the orange `compassColor`, the
|
||
`typeLabel` / `destTypeLabel`, the three toasts with their
|
||
`{dest}`/`{hops}` placeholders).
|
||
|
||
**Visualization** — the active route is drawn on the MAP console:
|
||
- **GALAXY tab** — the route's lanes are drawn in **ORANGE**
|
||
(`data/map.json → galaxy.edges.route`, color = `route.compassColor`)
|
||
above the used/frontier/unexplored reads, and the DESTINATION STAR is
|
||
circled in orange (`data/map.json → galaxy.destination`) — the
|
||
"where am I going" path + target across the whole map. The route's
|
||
edge keys are computed by `GameScene.routeEdgeKeys()` (the consecutive
|
||
system pairs of `planRoute`'s path) and passed to
|
||
`buildGalaxySnapshot` (which marks `route: true` on those edges and
|
||
`isDestination: true` on the destination star). `GalaxyView._redrawEdges`
|
||
draws the route lanes (three-pass glow, like traveled) and
|
||
`GalaxyView._drawMarkers` draws the destination ring (a breathing
|
||
orange circle, shown only while a destination is set).
|
||
- **CURRENT SYSTEM tab** — two cases:
|
||
- **Destination is IN this system** — the destination object is circled
|
||
in orange (the route's final stop). `GameScene.mapChartSnapshot`
|
||
sets `destinationId` on the snapshot; `MapWindow.paintChart` draws the
|
||
ring (step 8b) over the object (shown only when the object is
|
||
discovered — a ring over a ghost would be noise).
|
||
- **Destination is OUTSIDE this system** — the ROUTE EXIT GATE (the jump
|
||
gate the player should path out of the system from, `routeNextGate`) is
|
||
circled in orange + a dashed orange line is drawn from the ship to it
|
||
("path out of the system"). `GameScene.mapChartSnapshot` sets
|
||
`routeExitGate` on the snapshot (the gate's id/x/y/name); `MapWindow.
|
||
paintChart` draws the ring + line (step 8c). The gate may be
|
||
undiscovered (the player hasn't seen it yet) — the ring + line still
|
||
show WHERE to go, which is the point of the route.
|
||
- **SYSTEM tab** — the destination object is circled in orange when
|
||
viewing the destination system's chart. `GameScene.systemChartSnapshotFor`
|
||
passes `destinationId` to `systemChartSnapshot` (which carries it on
|
||
the snapshot); `MapWindow.paintChart` draws the ring (same step 8b).
|
||
|
||
**Tests** — `dev/route.test.mjs` (already-there short-circuit, path
|
||
validity over real gate edges, `next` is a gate neighbour, determinism +
|
||
tree symmetry (a→b reversed === b→a), strong connectivity across sampled
|
||
ordered pairs, guard rails). The compass orange, the map close, and the
|
||
notice queue are Phaser glue — verified in a headless browser
|
||
(`dev/shot-firefox.mjs` / geckodriver `execute/sync`):
|
||
set → orange `route:<gate>` compass entry (`#ff8c1a`); arrive → route
|
||
clears + REACHED notice queued; detour → destination kept + RE-PLOTTED
|
||
notice; clear → the orange entry drops on the next compass reconcile.
|
||
|
||
## The tether — the player's range (important)
|
||
|
||
The ship starts with **one level-1 tether** anchored on the home world
|
||
(system origin): a circular zone, **5120 px from the anchor's center**
|
||
(`data/tether.json → level1Radius`). That rim is the player's boundary:
|
||
|
||
- **The union is the space.** The ship may be anywhere inside ANY of its
|
||
tethers' zones. Where two zones overlap there is **no wall and no line**
|
||
— the boundary of the union is where the barrier lives, and the line is
|
||
drawn only on each tether's *visible* arcs (`Tether.visibleArcs` — the
|
||
rim arcs not covered by another zone).
|
||
- **Outside the union is a hard barrier.** The ship is clamped to the
|
||
closest boundary point with the outward normal; outward velocity AND
|
||
acceleration are stripped (same static-resolve pattern as
|
||
`Planet.constrainShip`, run after the planets in `GameScene.update`).
|
||
Click-to-fly and autopilot targets are clamped too, so an out-of-range
|
||
destination becomes "fly to the rim and rest on the line".
|
||
- **Feedback on contact:** the line shudders locally at the hit point
|
||
(TetherField pulse + radial zap), a small camera kick, and a throttled
|
||
magenta console toast (`tether.contact` in the config).
|
||
|
||
**Layering (same rules as everything else):**
|
||
- `js/tether/Tether.js` — PURE (no Phaser): the record (id/anchor/level/
|
||
radius), `radiusForLevel`, union membership, `clampPoint` (nearest union
|
||
boundary point + normal), and `visibleArcs` (circle-arc interval math on
|
||
the unit circle). Node-tested by `dev/tether.test.mjs` — including the
|
||
two-direction boundary property (nothing visible is inside another zone;
|
||
nothing exposed is missing from the visible arcs).
|
||
- `js/tether/TetherField.js` — the scene-facing field: owns the tether
|
||
list, exposes the seam the build system will use (`add(id, x, y, level)`,
|
||
`remove(id)`, `setLevel(id, level)`, `onChange` → HUD refresh), applies
|
||
the constraint (`constrainShip`), and renders the barrier: dash dots of
|
||
constant world size along the visible arcs, on-screen culling only,
|
||
marching offset, additive glow + RGB ghost fringe under the main pass,
|
||
ambient glitch bursts (dash flicker, radial displacement, data drops,
|
||
sparks), contact pulses. All tuning in `data/tether.json → line/contact`.
|
||
- `data/tether.json` — level radii (`level1Radius`, `radiusGrowth`,
|
||
`maxLevel`), the starting tether (`homeId`/`homeLevel`), and every
|
||
visual/feel parameter.
|
||
|
||
**Upgrade economics (planned):** radius(level) = 5120 × 2^(level−1) —
|
||
level 2 = 10240 px, which reaches every object of the current 6-object
|
||
maximum (all within 10240 px of the home world); the defensive two-orbit
|
||
fallback (11 objects, up to ~13380 px out) would fit inside level 3
|
||
(20480 px). Extra tethers anchored on planets/stations will be the
|
||
"expand your reach" verb; the seams are in place.
|
||
|
||
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
|
||
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,
|
||
no camera math beyond culling. The scene drives `tick()`/`draw()` from
|
||
`update()` alongside the TimeClock/tween stepping.
|
||
|
||
## Research — the progression gate (time-based, one at a time)
|
||
|
||
The RESEARCH deck slot opens the **Research console** (`js/ui/ResearchWindow.js`):
|
||
left pane loops the muted `assets/videos/research-computer.mp4` archive feed
|
||
(scanlines, sweep band, REC pulse, ambient glitch bursts, decode-in reveals),
|
||
right pane holds the category tabs + the **branching tech tree** for the
|
||
selected category (starts at the top, unlocks downward), and the selected
|
||
tech's dossier (icon, description, duration) with a RESEARCH button that is
|
||
only present when the tech is researchable and nothing is in progress.
|
||
|
||
**Data (one file per category):**
|
||
- `data/research.json` — global rules: `enabled`, `timeUnit: "seconds"`,
|
||
`maxConcurrent: 1`, `defaultCategory`, the `categories` registry (id,
|
||
label, icon, accent), the `video` (file + aspect), and `fx` timing.
|
||
- `data/research/<category>.json` — the tree: a flat `nodes` map where each
|
||
node has `label`, `description`, `duration` (in `timeUnit`), `requires`
|
||
(parent ids — a DAG), `unlocks` (what the tech OPENS, below), and
|
||
optional `effects`. `starting` lists the pre-unlocked roots. Section name
|
||
= file basename
|
||
(`research/exploration.json` → `config.section('exploration')`).
|
||
**Add a category = one file + one line in `research.json → categories`
|
||
+ one line in `data/manifest.json`.**
|
||
|
||
**The SYSTEM category (dynamic — per solar system):** one category is
|
||
flagged `dynamic: true` in `research.json → categories` — it has **no**
|
||
data file; its tree is built at runtime for the system the player is in
|
||
(`js/research/SystemCategory.js` → `buildSystemTree`), so its two techs
|
||
are NAMED after that system. Node ids embed the system id
|
||
(`S000012_map`, `S000012_gates`) so a run can chart many systems in the
|
||
one shared category without collisions:
|
||
- **`{System} Map`** — `duration: 0`, in `starting`: granted the moment
|
||
the player is in the system (`GameScene._onEnterSystem()` unlocks it —
|
||
create() runs it on every entry, which a jump is: the jump restarts
|
||
the scene onto the destination, see the Jump gates section).
|
||
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 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 —
|
||
`activationKeys`), and per the gate ACTIVITY rule each activated gate
|
||
anchors a level-1 tether at its own position.
|
||
|
||
**Diagnosing "I charted everything but it stays locked":** the console
|
||
command `orbitNav()` (DevTools → Console, space view — `js/dev/NavDiag.js`,
|
||
installed by `main.js`) prints this system's live NAV chart: every NAV
|
||
point with a ✓/✗, the discovered/total count, and exactly which objects
|
||
are still undiscovered. `orbitNavBrief()` gives the one-line summary.
|
||
It builds on the pure `navChart()` core above.
|
||
|
||
**Activation wiring (a real bug lived here):** completing the tech runs
|
||
`GameScene._applyResearchEffects`, whose `activateGates` branch recovers
|
||
the system id from the **node id** it's passed — via the pure
|
||
`systemIdOfGatesNode(nodeId)` (`S000137_gates` → `S000137`). A node
|
||
**object** carries no `id` field (its id is the key in the tree's
|
||
`nodes` map), so reading `node.id` yields `undefined` and the activation
|
||
silently never ran ("researched the jumpgates but they stay dark" — it
|
||
was *not* a name/case mismatch; the id lookup is by system id, never by
|
||
name). `GameScene._onEnterSystem()` now **reconciles** on every entry:
|
||
if the system's gates tech is unlocked it re-runs
|
||
`_activateSystemJumpgates` (idempotent + silent when already online),
|
||
so a missed activation self-heals on the next entry/jump/reload without
|
||
a re-research.
|
||
|
||
The gates node's chart gate is an **availability HOOK**, not a `requires`
|
||
edge (its requires are all met at grant — the gate is live-world state):
|
||
the tree object carries optional `available(state, id)` / `lockNote(state, id)`
|
||
functions, consulted by `ResearchModel.isAvailable` and the console's
|
||
LOCKED readout. Static trees have no hooks. The label/description copy
|
||
and the duration live in `data/gates.json → activation` (templated —
|
||
`{system}` is replaced with the system name); the tests exempt
|
||
`dynamic: true` categories from the one-file-per-category rule.
|
||
|
||
**The UNLOCKS space (research → everything else):** a tech unlocks more
|
||
than follow-on tech. Each node's `unlocks` is the declaration side:
|
||
- `unlocks.research` — the readable mirror of the children's `requires`
|
||
edges (the DAG itself stays authoritative — `requires` only).
|
||
- `unlocks.builds` — ids in `data/builds.json` the tech makes available.
|
||
The **authoritative gate is the build's own `requires`** (a list of
|
||
`"<category>/<node id>"` research ids — e.g.
|
||
`tether-l2.requires: ["exploration/tether_l2"]`): a build is available
|
||
once every id it names is researched. `dev/research-builds.test.mjs`
|
||
keeps both sides in lock-step (the test fails if one names the other and
|
||
the other doesn't name back, or an id dangles).
|
||
- Builds also carry `category` (planet / cargo — the console's tabs),
|
||
optional `targets` (the surfaces it can be placed on — defaults to
|
||
`[category]`), `cost` (minerals today; credits when they land),
|
||
`repeatable`, `starting` (pre-installed on fresh runs — e.g. the home
|
||
world's level-1 tether), optional `planetRequires` (world-state gates,
|
||
e.g. `tetherLevel`), `effects`, `theme`.
|
||
- `tether-l2` (the level-2 tether ring, anchored on a world that holds a
|
||
level-1 tether) is the first build — 200 minerals, 20 s, effect
|
||
`tether: {level: 2}` on the target world. The build console is live
|
||
(the deck's BUILD slot, `js/ui/BuildWindow.js` — see the section below);
|
||
tune cost/duration freely in `data/builds.json`.
|
||
- The console's dossier shows the line (`UNLOCKS: TETHER LEVEL 3 · TETHER
|
||
ANCHORING · BUILD · TETHER - LEVEL 2`), read through
|
||
`ResearchModel.unlocksOf(tree, id)` + `buildDefs()` — the one read
|
||
point the build UI uses too.
|
||
- The second static category is **Mining** (`data/research/mining.json`):
|
||
`improved_arm` (Tier 1, root) → `advanced_arm` + `improved_storage`
|
||
(Tier 2, both require the Tier-1). All three are BLUEPRINTS — 60 s
|
||
each, `effects: {}` — and each declares its matching SHIP-UPGRADE build
|
||
in `data/builds.json` (`mining-arm-improved` / `mining-arm-advanced` /
|
||
`mining-storage-improved`, the console's Mining tab) — the build's
|
||
`requires: ["mining/<node id>"]` is the authoritative gate, the node's
|
||
`unlocks.builds` the declaration (the test keeps both in lock-step). New
|
||
glyph set for the tab: `mining`, `mining2`, `storage`
|
||
(js/research/ResearchIcons.js) — the build rows reuse the same glyphs.
|
||
|
||
**Code layering (same rules as the tether):**
|
||
- `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues`
|
||
(DAG validation), `levels` (longest-path level), `layoutTree`
|
||
(deterministic column/row layout: DFS leaf-slot assignment, parent =
|
||
mean of children), `isAvailable`, `missingRequires`, and the unlocks
|
||
contract: `unlocksOf` (normalize `{builds, research}`), `buildDefs`
|
||
(builds.json → map, `_`-keys excluded), `unlockIssues(tree)` (mirror +
|
||
build wiring), `buildIssues()` (every build `requires` id resolves).
|
||
`isAvailable` also consults a tree's optional `available(state, id)`
|
||
hook (the dynamic SYSTEM category's chart gate). Node-tested.
|
||
- `js/research/SystemCategory.js` — PURE (no Phaser): the dynamic
|
||
SYSTEM category. `buildSystemTree({systemId, systemName, accent,
|
||
isComplete})` (the per-system tree + the `available`/`lockNote`
|
||
hooks), `systemIdOfGatesNode(nodeId)` (recover the system id a gates
|
||
node id encodes — the node object has no `id` field, so the scene passes
|
||
the id in; the pure core of the activation wiring),
|
||
`navPoints(content)` (the chart's NAV points + their kind),
|
||
`navPointIds(content)` (the chart's NAV points, ids only),
|
||
`navChart(discovery, systemId, content)` (the live discovered/missing
|
||
split that gates the jumpgate tech — the pure core of the `orbitNav()`
|
||
console diagnostic in `js/dev/NavDiag.js`),
|
||
`isNavComplete(discovery, systemId, content)` (every NAV point
|
||
discovered), `activationKeys(galaxy, systemId)` (the system's gates +
|
||
the linked systems' return gates, from the jump network alone),
|
||
`applyActivation(content, systemId, keys)` (flip the records
|
||
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 — 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 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).
|
||
- `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`,
|
||
`start`, `progress(time)`, `tick(time)` (→ array of completions),
|
||
`restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested.
|
||
- `js/research/ResearchIcons.js` — procedural 128 px icon textures
|
||
(tether rings / anchor / signal waves / diamond fallback), tinted.
|
||
- `js/ui/ResearchWindow.js` — the scene-facing window (depth 80, above the
|
||
save panel). A **passive view**: it asks `GameScene` to start a run via
|
||
`onResearch(catId, id)`; the scene owns the rules, the effects, the
|
||
toasts, and the save data. The window re-renders from
|
||
`ResearchState` + `ResearchModel` only.
|
||
- `GameScene` — `beginResearch`, `_completeResearch`,
|
||
`_applyResearchEffects`, `_deckResearchBar`. The **effects seam** reads
|
||
`node.effects`: `{ tether: { level: N } }` → `TetherField.setLevel(homeId, N)`
|
||
+ toast; `{ capability: "flag" }` → `scene.researchCapabilities.add(flag)`;
|
||
`{ activateGates: true }` (SYSTEM category) → the system's gates + the
|
||
linked systems' return gates go ACTIVE (`_activateSystemJumpgates` — the
|
||
activation keys join the run's `activatedGates` set, the current
|
||
system's gate entities wake, and each activated gate anchors its
|
||
level-1 tether); unknown shapes log and no-op. New effect kinds plug in
|
||
there without touching tree data.
|
||
- The SYSTEM category's world state is the run's **`activatedGates`** set
|
||
(activation keys `"<sysId>><destId>"`) — registry-backed like discovery
|
||
(`GameScene.create` reads it; `resetRunState` clears it on New Game,
|
||
`prepareLoad` restores it), and the anchored gate tethers save with the
|
||
run's tether list. On entry the scene flips the current system's gate
|
||
records BEFORE the gate entities build (the dormant look is baked at
|
||
construction), and anchors each activated gate's tether after the
|
||
field exists. `GameScene._onEnterSystem()` grants the map tech on
|
||
arrival (new run, load, and — when it lands — every jump landing).
|
||
The ResearchWindow takes the live tree via `systemTree` and re-paints
|
||
nodes live when a state flips under the open console (the chart
|
||
completes, a run starts).
|
||
|
||
**Save:** `record.research = { unlocked: ["cat::id", …], active:
|
||
{category, id, durationMs, remainingMs} | null }`. `remainingMs` is captured
|
||
at save time; `restoreActive(spec, now)` rebuilds `startedAt` from the fresh
|
||
`now`. A save from before research exists loads as fresh (no unlocks, no
|
||
active run) — old saves keep working. The SYSTEM category's activation keys
|
||
ride `record.activatedGates` (an array of `"<sysId>><destId>"`; saves from
|
||
before the category load as an empty set — gates stay dormant). No
|
||
auto-save: research state persists on the next explicit player save
|
||
(SavePanel), consistent with the rest of the game.
|
||
|
||
**SFX:** begin → `construct`, complete → `discovery` (both existing
|
||
`data/sfx.json` keys — there are no `research_begin`/`research_complete`
|
||
keys). Open/close → `ui_window`/`ui_close`.
|
||
|
||
**Verified:** `dev/research-builds.test.mjs` (manifest registration,
|
||
research.json globals — incl. the dynamic SYSTEM category's registration,
|
||
the exploration tree's raw-JSON contract — DAG, node
|
||
fields, effects — the real code path: ResearchModel layout/levels/determinism
|
||
+ the unlocks contract (unlocksOf/unlockIssues/buildIssues) +
|
||
ResearchState start/tick/complete/restore round-trip, builds.json (the
|
||
tether-l2 build, both sides of the gate, the template), actionbar.json).
|
||
`dev/system-category.test.mjs` (the gates.json activation contract, the
|
||
per-system tree through the real code path — hooks, one-at-a-time,
|
||
save/restore round-trip — the NAV-point rule, the activation keys +
|
||
record flips). `dev/research-shot.html` + `dev/cdp-shot.mjs` open the window and start a
|
||
run through CDP for a screenshot.
|
||
|
||
## Builds — the surface install (cost-based, one at a time)
|
||
|
||
The BUILD deck slot (on a planet surface — the deck re-deals itself there:
|
||
Shop, Build, Ship, ·, Take Off, Menu) opens the **Build console**
|
||
(`js/ui/BuildWindow.js`, same visual language as the Research console —
|
||
left pane loops the muted 2:3 `assets/videos/build.mp4` feed, right pane
|
||
holds the category tabs + a **list** of the category's buildable items —
|
||
not a tree: builds are one-off installs on the planet — + the selected
|
||
build's dossier). Locked items are grayed with their missing gates
|
||
(research / tether level); installed items read **BUILT ✓**; the BUILD
|
||
button appears only when the build is available AND affordable.
|
||
|
||
**Division of labor (the Research split, exactly):**
|
||
- `js/build/BuildModel.js` — PURE (no Phaser): `categories`, `loadBuilds`
|
||
(category → its builds), `defById`, `startingPairs` (pre-installed on
|
||
fresh runs — `tether-l1.starting: ["home"]`), `missingRequirements`
|
||
(research + `planetRequires.tetherLevel`), `isAvailable`, `rowState`
|
||
(built / active / available / locked), `costLines`, `canAfford`,
|
||
`isShipScoped` (`targets: ["ship"]` — the ship upgrades). Node-tested by
|
||
`dev/builds.test.mjs`.
|
||
- `js/build/BuildState.js` — PURE: the run's build records (`built`:
|
||
planet → set of buildIds) + the single in-progress build (`active`,
|
||
`maxConcurrent: 1`); `start/progress/tick/restoreActive`,
|
||
`toJSON(now)/fromJSON`, and `isBuiltAnywhere(build)` (ship-scoped
|
||
builds count as built if installed on ANY planet). Node-tested.
|
||
- `js/ui/BuildWindow.js` — the scene-facing window (depth 80, above the
|
||
save panel). A **passive view**: it asks via `onBuild(buildId)`;
|
||
`SurfaceScene.beginSurfaceBuild` → `GameScene.beginBuild` enforces the
|
||
rules (one at a time, not installed, research gate, planet gate,
|
||
mineral cost — the full amount paid up front), then `BuildState.start`
|
||
runs the clock. The window re-renders from `BuildState` + `BuildModel`
|
||
only.
|
||
- `GameScene` — `beginBuild` (the ship-scoped gate:
|
||
`isBuiltAnywhere` for `targets: ["ship"]` builds), `completeBuild`,
|
||
`_applyBuildEffects` (the **effects seam**: `tether {level: N}` → the
|
||
world's tether strengthens to at least N via `TetherField.setLevel` —
|
||
never a downgrade — + toast; `capability "flag"` →
|
||
`scene.researchCapabilities`; `mining {rate, capacity}` →
|
||
`_applyMiningUpgrade` — the ship's stats, MAX wins; unknown shapes log
|
||
and no-op). `_restoreMiningUpgrades()` re-derives the ship's mining
|
||
stats from the build records in `create()` (the save carries the
|
||
records, not the stats — same seam as `_rematerializeBuiltTethers`;
|
||
idempotent, a fresh run has none). The state lives on the GameScene
|
||
(`buildState`), so the records + the in-progress build outlive the
|
||
surface stay AND save with the run (`record.builds`).
|
||
|
||
**The clock (important — different from research):** the build's time
|
||
base is the **game-loop clock** (`game.loop.now` — the engine's global
|
||
monotonic ms), NOT a scene's `time.now`. A scene's clock freezes while
|
||
the scene sleeps, and the GameScene SLEEPS while the surface is active —
|
||
so the SurfaceScene ticks `buildState.tick(game.loop.now)` in `update()`
|
||
while on the surface, and the GameScene ticks it in space (a build
|
||
started on the surface keeps running if the player takes off mid-build —
|
||
then completes in space, effect and all). Research keeps its scene-clock
|
||
base (`GameScene.time.now` — the scene is awake while it runs). Saves
|
||
capture the build's `remainingMs` on the loop clock; `restoreActive`
|
||
rebuilds `startedAt` so the build finishes at the same wall time after
|
||
load. (Note: for an AWAKE scene, `scene.time.now` IS the loop timestamp
|
||
in this build — TimePlugin.update sets `now = t` — the two only diverge
|
||
while a scene sleeps.)
|
||
|
||
**Deck lock:** while a build runs, every deck action except BUILD is
|
||
refused (`SurfaceScene.deckAction`) — one build at a time. The BUILD slot
|
||
stays open: the window shows the in-progress build + its progress.
|
||
|
||
**Save:** `record.builds = { built: { planet: [buildId, …] }, active:
|
||
{ planet, build, durationMs, remainingMs } | null }`. A save from before
|
||
builds exist loads as fresh — old saves keep working (`dev/saves.test.mjs`
|
||
covers the staging).
|
||
|
||
**The starting installs are a rule, not save data:** a fresh run is
|
||
seeded with `tether-l1` installed on the home world (the player starts
|
||
with their home tether — "every world starts with its tether already in
|
||
place"), and `GameScene.applyRestore` RE-ASSERTS the `starting` pairs
|
||
AFTER a load (`_seedStartingBuilds`, idempotent) — a save captured before
|
||
the seed existed (or by an older iteration) replaces the seeded state and
|
||
must not un-install the home world's level-1 tether. A player resuming a
|
||
run always has Tether - Level 1 built on home (pinned in
|
||
`dev/builds.test.mjs`).
|
||
|
||
**Ship-scoped builds (the Mining tab) — one-off installs, ship-wide
|
||
effect:** `targets: ["ship"]` (the mining arms + storage, data/builds.json
|
||
→ the Mining category) are installed from any planet's console but the
|
||
effect is the SHIP's: `effects.mining.rate` → `ship.stats.miningSpeed`
|
||
(minerals/s — `Mining._extract` multiplies the base economy rate by it,
|
||
read live per tick) and `effects.mining.capacity` →
|
||
`ship.stats.mineralStorage` (the hold cap — `Ship.storageRoom` /
|
||
`addMinerals` read it live, so the corner HUD's `n / CAP` follows). Both
|
||
are MAX-guarded (the faster arm / bigger hold always wins — building the
|
||
advanced arm then the improved keeps 2.0/s). "Built" is SHIP-SCOPED:
|
||
`BuildState.isBuiltAnywhere` (installed on any planet ⇒ built wherever
|
||
the ship lands — `BuildWindow.ctxFor` + `_detailStatus` + the
|
||
`GameScene.beginBuild` gate all read it for these builds, so no re-buy on
|
||
the next world). The records still key the planet they were built from
|
||
(the save format is unchanged).
|
||
|
||
**Name casing (a real bug this caught):** world state is keyed by the
|
||
world's CANONICAL name (its discovery name — build records, tether
|
||
labels). The comms panel DISPLAYS names uppercased — and used to carry
|
||
that display casing back into the landing handoff (`lastTarget.name`),
|
||
so a surface reached through the panel read its own home world as
|
||
"nothing installed, no tether" (L1 available, L2's tether gate failing).
|
||
Fixed at the source: `CommsPanel` keeps the canonical spelling in the
|
||
data payload (uppercase is display-only), and `GameScene.startLanding`
|
||
normalizes the name against the known worlds before launch
|
||
(`js/utils/WorldNames.js` → `canonicalPlanetName`, Node-tested in
|
||
`dev/world-names.test.mjs`) — so `tetherLevelFor` and the surface's
|
||
`planetName` always match the world-state keys.
|
||
|
||
**SFX:** begin → `construct`, complete → `discovery`, refused action →
|
||
`ui_close`, window open/close → `ui_window`/`ui_close` (all existing
|
||
`data/sfx.json` keys).
|
||
|
||
**Diagnostics (in the real game):** `js/dev/BuildDiag.js` (installed by
|
||
`js/main.js`) is a read-only observer — it never mutates state.
|
||
- Opening the Build console logs a one-line `[orbit-diag v…]` summary:
|
||
planet, home, `isHome`, the `built` map, the planet's tether level, the
|
||
L1/L2/L3 row states (with missing gates), the ship's mining stats
|
||
(`mining={rate, hold, cap}`), a stale-JS probe (`jsSeed=true|false`),
|
||
and the loaded `tether-l1.starting` field.
|
||
- `orbitDiag()` in DevTools → Console prints the full report: env, JS
|
||
probes, the `data/builds.json` contract as loaded, home/current
|
||
planet, built records, tether objects, research state, the save bank's
|
||
`builds` per slot, pending restore, scene wiring, errors since boot.
|
||
- If `orbitDiag` is **undefined**, the browser served a cached older
|
||
`js/main.js` → serve with `node dev/server.mjs` (sends
|
||
`Cache-Control: no-store` on every response) or hard-reload with the
|
||
cache disabled. (A plain `python3 -m http.server` lets browsers
|
||
heuristically cache the ES modules — the classic "new data + old JS"
|
||
trap that makes an old `GameScene.js` keep running after edits.)
|
||
- `dev/build-check.html` plays the real Build flow (menu → New Game →
|
||
home world → build console) and prints the L1/L2 row states before and
|
||
after the `tether_l2` research; its failure path also reports the
|
||
stale-JS probe, so even a failed run is diagnostic.
|
||
|
||
**Verified:** `dev/builds.test.mjs` (data contract from builds.json —
|
||
categories/resources/builds entries — the pure model: requirements,
|
||
availability, row states, cost/afford, starting pairs — the state
|
||
machine: start/guards/progress/tick/completion — save/restore round-trip
|
||
incl. remaining-time preservation), `dev/research-builds.test.mjs`
|
||
(research↔build unlock lock-step, incl. `tether_l2` carrying NO effect —
|
||
the build carries it), `dev/saves.test.mjs` (the builds field rides the
|
||
pending restore; legacy records load clean).
|
||
|
||
## Reputation — standing on planets & space stations (data layer; factions later)
|
||
|
||
The player holds a REPUTATION (standing) on each planet and space station:
|
||
- **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best),
|
||
integer steps, `neutral` = the standing with a place the player has no
|
||
standing with (0), `home` = the home world's standing (+20).
|
||
- **The home-world exception** — the player's home world is ALWAYS +20:
|
||
pinned. Nothing sets, changes, or clears it (it's not even storable —
|
||
the key is derived from the scale itself).
|
||
- **The faction check (placeholder)** — `Reputation.standingFor(place)`
|
||
resolves: **home → stored standing → the place's `owner` → faction
|
||
standing → neutral**. `owner` is the settlements' reserved seam (always
|
||
`null` until factions exist) and `Reputation.factionStanding(id)` is a
|
||
marked `TODO(factions)` stub returning null — so today the whole galaxy
|
||
reads neutral (home: +20). When factions land: populate `owner` in
|
||
generation + fill `factionStanding()`; the resolution order is final.
|
||
- **The mutation seams** — `set(key, value)` / `change(key, delta)`, both
|
||
clamped to the scale; "ways to influence reputation" land on these.
|
||
- **Place identity** — every planet and settlement now carries a stable
|
||
`id` from the generator (seed-deterministic: planets
|
||
`<systemId>-p<ordinal>`, settlements `<systemId>-s<n>`, n = draw order);
|
||
the home world is the fixed key `'home'` (`Reputation.HOME_KEY`, same id
|
||
discovery uses). Same seed ⇒ same ids ⇒ saved standing lines up with the
|
||
regenerated galaxy. Lazy === eager holds (id is content, from the
|
||
per-system stream's own record/ordinal).
|
||
- **Module & save** — `js/reputation/Reputation.js` (pure, no Phaser —
|
||
the Discovery pattern): `toJSON()`/`fromJSON()`; the save record carries
|
||
`reputation` (scale snapshot + stored standings); the scene keeps one
|
||
instance in the shared registry (New Game resets it via
|
||
`resetRunState`). A save from before reputation exists loads as fresh
|
||
all-neutral — old saves keep working. **The comms panel shows it** (a
|
||
41-mark bar, −20 → +20, red→green, lit up to the standing —
|
||
`Reputation.marksFor()`) and gates REQUEST LANDING at standing ≤ −4;
|
||
nothing CHANGES standing yet — the influence mechanics land on the
|
||
mutation seams above.
|
||
- **Tests** — `dev/reputation.test.mjs` (scale, home pinning, clamping,
|
||
faction-check order incl. a monkey-patched "factions exist" pass,
|
||
save round-trips + legacy/corrupt records, capture/prepare/reset
|
||
integration, generator place-id shape/uniqueness/stability).
|
||
|
||
## Phaser version
|
||
|
||
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
|
||
- App code is v4-specific where v4 changed things (e.g.
|
||
`Phaser.Math.Angle.RotateTo`, `banner: false`, `Clamp(value, min, max)`).
|
||
- **v4 quirks that bite (verified against this build, Sept 2026):**
|
||
- The engine calls a scene's `update(time, delta)` directly but does **not**
|
||
step the scene's `TimeClock` or `TweenManager` (the v3 scene-events
|
||
PRE_UPDATE/UPDATE plumbing is not fired in the v4 loop). Each scene must
|
||
call `this.time.update(time, delta)` and `this.tweens.update()` at the top
|
||
of `update()`, or `delayedCall`/`addEvent`/tweens silently never run.
|
||
(Done in `MenuScene.update` / `GameScene.update`.)
|
||
- A GameObject constructed with `new` (e.g. our `Container` subclasses in
|
||
`js/ui/`) is **not** added to the scene display list — the `scene.add.*`
|
||
factories do that. Call `scene.add.existing(this)` in the constructor
|
||
(Ship/Planet already do; GlitchText/MenuButton now do too).
|
||
- **`Container.add(child, index)` — the v3 varargs form is gone.**
|
||
`cont.add(a, b, c)` adds ONLY `a` (the second arg is an insert index,
|
||
the rest are silently dropped); the dropped children stay at scene level
|
||
and paint *under* the window's opaque backplate, so they simply vanish.
|
||
Multi-add is the ARRAY form: `cont.add([a, b, c])` (ActionBar/CommsPanel
|
||
use it; BuildWindow rows/button and ResearchWindow's action button
|
||
regressed on the varargs form until fixed, Sept 2026).
|
||
- Text colors go straight to the canvas: v4 writes `fillStyle =
|
||
style.color`, so a **numeric** color is an invalid fillStyle and the text
|
||
silently renders **black**. Text styles and `setColor()` must get CSS
|
||
strings — use `toCss()` from `js/utils/Color.js` (keep `toColor()` for
|
||
Graphics/shape APIs).
|
||
- An `Image` created **before** its texture key exists can stay bound to
|
||
the `__MISSING` texture forever — even after the key is generated later
|
||
in the same session. Generate the texture **first** (see
|
||
`DiscoveryCompass.ensureArrowTexture`: texture, then `scene.add.image`).
|
||
- **Input on screen-fixed containers is per-child.** Hit-testing uses
|
||
each object's *own* scrollFactor (`InputManager`: `g = worldX +
|
||
scrollX*sf - scrollX`), while a child of a `scrollFactor(0)` container
|
||
renders screen-fixed. So a child whose scrollFactor is left at the
|
||
default (1) is drawn pinned but hit-tested in world space — clicks miss
|
||
it whenever the camera has scrolled. (MenuButton never bit by this:
|
||
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
||
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
|
||
- **`sound.play()` throws on a missing key — and `cache.hasAudio()`
|
||
doesn't exist in this build.** Playing a key that never loaded is a
|
||
hard `Error: Audio key "…" not found in cache`, not a no-op, and the
|
||
`cache.hasAudio()` guard the old playSfx relied on is absent here. The
|
||
shared voice (`js/utils/Sfx.js → playSfxOn`) therefore checks
|
||
`scene.cache.audio.has(key)` directly (the real v4 API) before
|
||
playing, so a not-yet-loaded asset is a silent no-op — verified in the
|
||
browser (a `playSfx('ui_hover')` against an unloaded key threw through
|
||
the click handler until the guard was fixed).
|
||
- **The sound manager stops BY KEY — there is no `sound.stop()`.** In
|
||
this build the manager exposes `stopByKey(key)` / `stopAll()` (and
|
||
`isPlaying(key)` / `getAll(key)` / `getAllPlaying()`), but NOT a
|
||
keyless `stop()`. So stopping a looping SFX is
|
||
`scene.sound.stopByKey('sfx_<name>')` — the shared voice wraps it as
|
||
`js/utils/Sfx.js → stopSfxOn`. (`sound.play(key, { loop: true })`
|
||
DOES loop — verified: a 20 s clip kept playing past its natural end,
|
||
and stopped cleanly on `stopByKey`.)
|
||
- **A scene transition does NOT call `scene.shutdown()`.** The manager
|
||
stops a scene via `sys.shutdown()`, which flips the status and emits
|
||
the `shutdown` EVENT on the scene's emitter — the scene's own
|
||
`shutdown()` method is never invoked (verified: a wrapped
|
||
`MenuScene.shutdown` did not run on menu → game, while the
|
||
`'shutdown'` event fired). Scene-level cleanup that must run on
|
||
transition (stopping a music loop, …) therefore hooks the event:
|
||
`this.events.once('shutdown', …)` from `create()` (done for the
|
||
menu hum and the surface hum); the `shutdown()` methods stay as the
|
||
game-destroy path.
|
||
- **The surface is a LAUNCH + SLEEP, not a transition.** Landing does
|
||
`scene.launch('SurfaceScene')` and then `scene.sleep()` — a sleeping
|
||
scene never emits `shutdown` (verified: the game soundtrack and the
|
||
sound manager kept running through the surface until the scene's
|
||
`'sleep'` event was hooked). Take Off wakes the scene (`scene.wake`),
|
||
which emits `'wake'`. So GameScene's music + mining hum stop on
|
||
`'sleep'` and resume on `'wake'` (repeatable — `events.on`, not
|
||
`once`), while `'shutdown'` covers return-to-menu and destroy
|
||
(js/scenes/GameScene.js → create).
|
||
- **Ship art** — the starter ship is now a spritesheet frame:
|
||
`data/ship.json → texture` (assets/images/ships-player.png, 256×256
|
||
frames, `frame` 0), mirroring the planets pattern. The frame art faces
|
||
`artFacing` ("north" — the nose is the frame's top edge; the glass
|
||
cockpit section is aft, per the art's author); `Ship` applies a
|
||
constant render offset (`artOffset`) so heading math is identical and
|
||
the ship rotates about the frame center. Sprite
|
||
scale = (size×scale)/frameWidth keeps the 46 px world size (and 23 px
|
||
collision radius) regardless of frame size. Missing/failed sheet →
|
||
built-in procedural dart (facing east), with a console note.
|
||
- **Jump gate art** — the gate is now a spritesheet frame pair:
|
||
`data/gates.json → texture` (assets/images/jumpgate.png, 256×256
|
||
frames). Frame 0 = the gate body (ring + pylons), drawn static at full
|
||
alpha in every state (the swirl's absence — not a dim — is the
|
||
dormant tell); frame 1 = the ACTIVE swirl — a swirling energy disc
|
||
that fills the mouth, shown only when the gate is active (activation
|
||
per
|
||
`data/gates.json → activation`), spinning slowly clockwise and
|
||
breathing its alpha between 0.6 and 0.9 (tunables in `gates.swirl`:
|
||
spinSpeed rad/s, alphaMin/Max, breathRate). Sprite
|
||
scale = (size×2)/frameWidth keeps the ring's outer edge on the 96 px
|
||
keepout disc (the ship hovers `shipClearance` px outside it)
|
||
regardless of frame size. Missing/failed sheet → built-in procedural
|
||
gate, with a console note.
|
||
- To upgrade: replace the vendored file + note the version here (and re-check
|
||
the quirks above — they may go away).
|
||
|
||
## System effects — the star's character (data-driven, WebGL)
|
||
|
||
- A system's visual character is a full-screen composite effect on the
|
||
WORLD camera, configured per archetype in `data/systems.json →
|
||
types.<type>.effect` (`kind: none | ripple`). Nebula systems wear the
|
||
RIPPLE — concentric rings radiating from the middle of the view
|
||
(`center: "screen"`; `"star"` tracks world 0,0 instead) — young,
|
||
bright, still messy: the light itself is unsettled. The star itself is
|
||
invisible flavor; the shimmer is its character.
|
||
- Files: `js/visuals/SystemEffects.js` (filter + facade),
|
||
`js/visuals/SystemEffectsMath.js` (pure UV math),
|
||
`js/visuals/UiCameras.js` (the UI camera split),
|
||
`js/galaxy/FxSystems.js` (demo system picker),
|
||
`dev/system-effects.test.mjs` (35 checks, bare Node).
|
||
- **How it draws (Phaser 4.2.1):** the ripple is a
|
||
`Phaser.Filters.Controller` on `cameras.main`'s `filters.internal`
|
||
list — when any internal filter is active the camera pass
|
||
auto-composites (FBO + blit), so the WORLD (starfield included) is
|
||
displaced and the HUD is not. The UI pass is a second camera
|
||
(`fx-ui`, `forceComposite`) that draws the screen-pinned objects on
|
||
top WITHOUT clearing the world (a second direct pass would `clear()`
|
||
the canvas). Both directions are partitioned with `camera.ignore()`
|
||
(set-only — there is no un-ignore): screen-pinned roots are ignored by
|
||
MAIN, world roots (220+) by the UI pass, so nothing draws twice and
|
||
input still hits the UI first (hit-test walks cameras in reverse
|
||
array order). Verified in the browser: a scrollFactor-0 test ruler on
|
||
the UI pass is pixel-stable across frames while the world behind it
|
||
shimmers.
|
||
- **WebGL-only, Canvas-safe:** `apply()` requires `renderer.gl`; on the
|
||
Canvas fallback the system renders untouched (no crash, no effect).
|
||
The node constructor is registered once per renderer
|
||
(`ensureRippleNode`, `hasNode`-guarded — the registry is global and
|
||
throws on duplicates).
|
||
- **Tuning is a JSON edit:** `strength` (wave cycles across the screen),
|
||
`amplitude` (displacement, fraction of screen width), `speed` (phase
|
||
rad/s), `padding` (framebuffer slack — keep ≥ amplitude×width). The
|
||
displacement is `dir * sin(dist*strength − phase) * amplitude * fade`,
|
||
sampled through the injected `boundedSampler` (out-of-bounds reads
|
||
return transparent — a too-big amplitude punches holes; the padding is
|
||
the guard).
|
||
- **Demo:** `?fx=<type>` (e.g. `dev/test-game.html?fx=nebula`) retargets
|
||
the run to the richest system of that archetype
|
||
(`pickFxSystem`), force-discovers everything
|
||
(`Discovery.markAllDiscovered`) and activates the gates — a clean
|
||
minute of flying to judge the star. No param → ordinary play, and
|
||
`kind: none` systems never split the cameras (the single-camera
|
||
pipeline renders exactly as before).
|
||
|
||
## Nebula atmosphere — the gas behind the art
|
||
|
||
- A nebula is not only the ripple: it fills with soft coloured GAS.
|
||
A handful of large, low-alpha cloud sprites sit at **depth 3** — above
|
||
the starfield (depths 0–2) and below the planets (5) and the ship (10) —
|
||
so the gas reads as distant atmosphere and your art floats in FRONT of
|
||
it, never tinted by it. It is content (world-space sprites), not a
|
||
filter: it parallax-scrolls with the camera, wraps like the starfield,
|
||
and works in the Canvas fallback too (no WebGL pass). Only NEBULA-type
|
||
systems wear it.
|
||
- **Per-system colour:** every system gets a stable atmosphere color —
|
||
one of the 8-shade palette, picked at random at galaxy generation
|
||
(SystemGenerator, dedicated fork `(seed,'system',id,'atmosphere')`, so
|
||
lazy === eager). Stored as `content.atmosphere.color`; the layer tints
|
||
to it. The 8 shades are well-spread around the hue wheel (orange,
|
||
gold, green, cyan, blue, violet, magenta, crimson) — each nebula is a
|
||
different-feeling place.
|
||
- **The cloud:** a small set of distinct procedural puffs (value-noise
|
||
fbm for the internal structure, multiplied by a WIDE RADIAL falloff so
|
||
each has no perceptible silhouette — fully transparent by the inscribed
|
||
circle, no hard border or corner). Generated once via the shared
|
||
`canvasTexture` helper; every nebula reuses the set and differs only in
|
||
tint. NORMAL blending (the default) so overlaps layer softly instead of
|
||
ADD-ing into bright seams, and each puff swirls slowly on the scene
|
||
clock.
|
||
- **Files:** `js/visuals/NebulaAtmosphere.js` (layer + `drawCloud`),
|
||
`data/game.json → nebula` (palette + `count/alpha/size/parallax/drift/
|
||
texture` tunables), `js/galaxy/SystemGenerator.js` (per-system color),
|
||
`js/scenes/GameScene.js` (create/update/destroy wiring),
|
||
`dev/nebula.test.mjs` (15 checks, bare Node: palette contract, per-system
|
||
color determinism, cloud texture).
|
||
- **Tuning is a JSON edit** (`data/game.json → nebula`): `alpha`
|
||
(opacity — keep it a wash, not a wall), `count` (cloud sprites),
|
||
`size` (cloud px), `parallax` (keep it slower than the starfield's
|
||
0.15–0.85 so it sits far behind), `drift`, `shapes` (distinct puff
|
||
varieties — more = less repetition), and `texture` (noise
|
||
`octaves/persistence`).
|
||
- **See it:** `dev/test-game.html?fx=nebula&seed=<seed>` drops you in a
|
||
nebula (a different seed → a different gas color) — fly around and
|
||
judge the look.
|
||
|
||
## Roadmap (working list, intentionally rough)
|
||
|
||
- [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 (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
|
||
bases, deep-space stations, beacons) with per-type rates, a
|
||
core→rim density gradient, populations, and a `owner` seam reserved
|
||
for the factions/pirates to come; readable as a HUD dossier
|
||
(`SystemReport`)
|
||
- [x] The system as a place: generated worlds laid out in the world
|
||
(deterministic band), solid to the ship; discovery (within 540 px
|
||
of an edge, once, per system) with rim ping + toast; off-screen
|
||
compass — themed screen-edge arrows with type/name chips pointing
|
||
at discovered worlds; pure, save-ready discovery state
|
||
- [x] Compass autopilot: clicking a name tag sends the ship to that
|
||
world — it targets the keep-out rim on the side the ship is
|
||
approaching from (arrival facing the world; docking seam next).
|
||
The scene's click-to-fly guards against deck AND chip clicks
|
||
(`ActionBar.contains` / `DiscoveryCompass.contains`)
|
||
(`js/galaxy/Discovery.js`)
|
||
- [x] The tether — the player's range: a level-1 tether (5120 px) anchored
|
||
on the home world; the ship lives in the UNION of its tethers' zones
|
||
(overlap = no line, no wall), the rim is a hard barrier rendered as a
|
||
thick glitchy dotted line; click/autopilot targets clamp to the
|
||
boundary; contact feedback (line shudder, camera kick, toast).
|
||
Pure range math in `js/tether/Tether.js` (Node-tested), visuals +
|
||
constraint seam in `js/tether/TetherField.js`, tuning in
|
||
`data/tether.json`
|
||
- [x] UI hover/click ticks: `ui_hover` + `ui_click` in `data/sfx.json` play
|
||
on every clickable item (menu/deck buttons, compass name tags, panel
|
||
buttons, save slots) — hover on pointerover, click on press; the
|
||
SCAN slot skips `ui_click` (it plays its own sonar ping — `scan` in
|
||
`data/sfx.json`, now assets/fx/scan-01.mp3). Shared
|
||
voice in `js/utils/Sfx.js → playSfxOn` (both scenes expose
|
||
`playSfx(name)`; components call the `this.scene.playSfx?.(…)` seam),
|
||
with a v4 cache guard so a missing asset never throws
|
||
(js/utils/Sfx.js, dev/sfx.test.mjs)
|
||
- [x] Window whoosh: `ui_window` (assets/fx/ui-window.mp3) plays when a
|
||
window OPENS — the mining pop-up (`MiningPopup.open`) and the
|
||
comms / request-landing panel (`CommsPanel.open`) play it from
|
||
their own open seam (the `this.scene.playSfx?.(…)` convention);
|
||
the old `construct` plays at those two open sites were dropped so
|
||
the whoosh stands on its own
|
||
- [x] Close tick: `ui_close` (assets/fx/ui-close.mp3) plays when a UI
|
||
element is CANCELLED/CLOSED — the menu sub-bar (`MenuSubBar.close`),
|
||
the save vault (`SavePanel.close`) and the confirm dialog
|
||
(`ConfirmOverlay.startClose`); `type-deconstruct.mp3` is reserved
|
||
for text UNDECODING now (the dossier collapsing — the only
|
||
remaining `deconstruct` play), and stopping mining is silent
|
||
(the hum just ends — the old deconstruct blip on 'stopped' is gone)
|
||
- [x] Mining hum: `mining_loop` (assets/fx/mining-01.mp3) LOOPS while the
|
||
mining beam is live — starts when the arm finishes extending
|
||
(phase 'mining'), stops when the sequence ends (phase 'stopped'),
|
||
one hum across retargets (isPlaying guard). v4 quirk: the manager
|
||
stops by key — `sound.stopByKey()` (no `sound.stop()` in this build)
|
||
- [x] Music: `data/music.json` — the menu loops mainmenu.mp3 while the
|
||
main menu is up; in the game scene the DEEP-SPACE soundtrack
|
||
(music.game — a plain file list: deepspace-01/02 for now, adding a
|
||
track = adding a line) SHUFFLES: one track at its natural length
|
||
(loop OFF), the next picked at random when one ends (never the same
|
||
twice in a row) — the v4 build has no sound 'complete' event, so a
|
||
1 s scene-clock tick advances it (js/utils/Music.js →
|
||
startMusicShuffleOn/stopMusicShuffleOn, files queue under their
|
||
filename-derived key: deepspace-01.mp3 → music_deepspace_01);
|
||
on a world's surface the track for that world's
|
||
planets.png frame (terran / gas-giant families — the SAME key as
|
||
the landing videos) runs from the start of the landing clip to the
|
||
end of the takeoff clip. Shared voice in `js/utils/Music.js`
|
||
(looped play + `stopByKey` + one-loop-per-track guard;
|
||
`music.enabled` / `music.volume`), stops hooked on the scene's
|
||
`shutdown` EVENT (v4 quirk — see the list above), so the hum dies
|
||
on Take Off AND on Return to Main Menu, and the game soundtrack
|
||
dies on landing AND on returning to the menu
|
||
(js/utils/Music.js, dev/music.test.mjs). The soundtrack plays ONLY
|
||
in space: landing is a launch+sleep (not a shutdown — v4 quirk), so
|
||
it dies on the scene's `'sleep'` event and the shuffle restarts on
|
||
`'wake'` (Take Off) — the mining hum rides the same seam, so
|
||
neither leaks onto a world's surface. Also fixed the landing
|
||
frame hand-off — `startLanding` passed the texture `frame` OBJECT
|
||
(→ NaN); it now passes `sheetFrame`, so the land/surface/takeoff
|
||
clips are actually selected per world. intro-01.mp3 is parked for
|
||
the intro sequence that hasn't been built yet
|
||
(js/scenes/GameScene.js → setMiningLoop, data/sfx.json)
|
||
- [x] Research rules + panel: start a project (one at a time), tick its
|
||
duration, award `unlocks`; the Research slot on the deck opens the
|
||
console window — looping archive feed, category tabs, the branching
|
||
tech tree, per-tech dossier + RESEARCH button, deck progress bar.
|
||
Pure rules/state in `js/research/` (Node-tested), the window is a
|
||
passive view, `effects` carry the payload (e.g. `tether.level` grows
|
||
the home tether). First category **Exploration** is live (Tether
|
||
Level 1–3); second category
|
||
**Mining** is live (Improved Mining Arm → Advanced Mining Arm +
|
||
Improved Mining Storage — all 60 s blueprints, each unlocking its
|
||
ship-upgrade build in the console's Mining tab); add a category =
|
||
one JSON file + one line in the registry + one line in the manifest
|
||
(`js/ui/ResearchWindow.js`, `js/research/*`, `data/research.json`,
|
||
`data/research/exploration.json`, dev/research-builds.test.mjs)
|
||
- [x] Tether progression: levels 2 and 3 are **builds** — research
|
||
`tether_l2` / `tether_l3` (blueprint, no effect) → BUILD slot on a
|
||
world holding a level-1 / level-2 tether → 200 minerals / 20 s (L2)
|
||
or 300 minerals / 30 s (L3) → the world's tether strengthens to
|
||
level 2 (10240 px) or level 3 (20480 px), effect and all, outliving
|
||
the stay. Anchoring extra rings on planets/stations remains
|
||
(the `add`/`setLevel`/`onChange` seams are in place)
|
||
(`data/builds.json → tether-l2 / tether-l3`, `js/build/*`,
|
||
`js/ui/BuildWindow.js`, `dev/builds.test.mjs`)
|
||
- [x] Galaxy map (the MAP console's GALAXY tab): the whole galaxy on the
|
||
chart plate — glowing stars pulsing per archetype, the jump-lane
|
||
web (traveled lanes bright + flow packets, frontier lanes, the
|
||
charted region's outline), HOME/SHIP markers, hover readouts, and
|
||
CONFIRM JUMP on a live lane (the gate transport, unchanged). The
|
||
run's footprint (visited systems + traveled lanes) is save-ready;
|
||
the FACTIONS seam is reserved (per-system `faction: null` + the
|
||
documented color-layer / territory-region passes)
|
||
(`js/ui/GalaxyView.js`, `js/galaxy/GalaxyChart.js`,
|
||
`data/map.json → galaxy`, dev/galaxy-map.test.mjs)
|
||
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
|
||
and the player's place in a populated galaxy (the reputation layer
|
||
already resolves standing through `owner` — `Reputation.
|
||
factionStanding()` is the stub to fill; the galaxy tab's reserved
|
||
seams are the star color layer + the territory region — see the
|
||
Galaxy map section above)
|
||
- [x] Reputation (data layer): standing on each planet & space station,
|
||
−20…+20 (best = +20), neutral 0 default, home world pinned at +20,
|
||
faction check as a placeholder on the `owner` seam; stable place ids
|
||
from the generator; save-ready + Node-tested
|
||
(js/reputation/Reputation.js, data/reputation.json)
|
||
- [ ] Landing & exploration: settlements become points of interest you
|
||
can approach (the data — kind, anchor, population — is already there).
|
||
The comms panel is the door: clicking a planet or space station opens
|
||
a comms panel at the click (NOT a fly-here — the ship stays put; a
|
||
world click is the landing request, the compass autopilot is how the
|
||
ship flies to a world) — the name decodes
|
||
in, the standing bar draws (settled), REQUEST LANDING (gated at standing
|
||
≤ −4) or LAND on an unsettled world + CANCEL (`js/ui/CommsPanel.js`, a
|
||
rusty-metal frame around a scanlined green CRT). Free-space stations are
|
||
solid objects now (`js/entities/Station.js`, `data/stations.json`).
|
||
Button actions are seams (`GameScene.commsAction`) — the landing
|
||
sequence lands there
|
||
- [x] The player's loop, laid down as data + seams: research (time-based,
|
||
one at a time, gates builds/research) and building (credits + minerals,
|
||
ship/planet/station) data layers (`data/research.json`,
|
||
`data/builds.json`) with templates, and the command deck
|
||
(`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Scan, Ship,
|
||
·, ·, Menu. The deck reuses the menu's CRT language: the same
|
||
scanline tile recipe as `CyberOverlay` (clipped to the bar) and the
|
||
`GlitchText` RGB pull-apart (icons + labels at all times, panel outline
|
||
during bursts) — see `actionbar.animation.rgb` / `actionbar.scanline`
|
||
- [x] Build panel: the BUILD slot (on a planet surface) opens the console
|
||
— looping build feed, category tabs (Planet / Cargo), the build
|
||
LIST (locked items grayed with their missing gates), per-build
|
||
dossier + highlighted cost + BUILD button (available + affordable
|
||
only). Pay minerals up front, run the clock (game-loop clock —
|
||
survives takeoff mid-build), apply `effects` on completion, respect
|
||
`requires` + `planetRequires`, one at a time, deck locked while it
|
||
runs, records + in-progress build save with the run and resume with
|
||
their remaining time
|
||
(`js/ui/BuildWindow.js`, `js/build/BuildModel.js`,
|
||
`js/build/BuildState.js`, `data/builds.json`, dev/builds.test.mjs)
|
||
— incl. the Mining tab: three ship-scoped upgrades (the improved /
|
||
advanced mining arm → 1.5 / 2.0 minerals per second, the improved
|
||
storage → 350 hold), 200 / 300 / 200 minerals, 20 s each
|
||
- [x] Ship base stats: `data/ship.json → stats` (hullIntegrity 100,
|
||
shields 0, cargoHold 100, mineralStorage 250), exposed as
|
||
`ship.stats` — combat/trading/mining systems will read them as
|
||
capacities; upgrades (ship-category builds) will layer deltas on top
|
||
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
|
||
(ship-category builds) from one place
|
||
- [x] Galaxy regions: wide 2:1 field (plate-shaped) + home→far `d` coordinate +
|
||
near/middle/far zones + per-zone type mix (data/galaxy.json →
|
||
distribution.zones/zoneMix) — the region layer is live
|
||
- [ ] Factions: Voronoi territories around seeded capitals → each
|
||
system's `owner` (the reserved seam in reputation + the galaxy
|
||
plate), faction strength and hazard keyed off `zone`/`d`; the
|
||
middle zone (where the faction borders cross) = the contested space
|
||
- [ ] Trade: trade hubs on every planet/station, goods priced by zone +
|
||
jump distance (hops already ≈ map distance)
|
||
- [ ] World model in play: the ship still flies unbounded open space;
|
||
wire in current-system boundaries, jumps between systems (use
|
||
`galaxy.neighborsOf`), and a star map scene
|
||
- [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
|
||
- [ ] HUD (speed, fuel/crew) — the current system's dossier (name,
|
||
identity, settlements) is already shown top-left
|
||
- [x] Save/load (the `config` + entity split should make this tractable;
|
||
a save = seed + player state, since the galaxy regenerates) —
|
||
10-slot bank in localStorage, in-game SAVE/LOAD vault, and the menu
|
||
**Continue** button (newest save)
|
||
- [ ] Economy/trading loop (the Privateer heart)
|