337 lines
22 KiB
Markdown
337 lines
22 KiB
Markdown
# Orbit
|
||
|
||
> A procedurally generated space RPG in the spirit of *Privateer*, in
|
||
> top-down 2D. Built with **Phaser 4** as plain **ES6 modules** — no build
|
||
> step, no package managers required to run.
|
||
|
||
## Run it
|
||
|
||
Any static file server works (Python, Node, Caddy, nginx, …):
|
||
|
||
```sh
|
||
cd orbit
|
||
node dev/server.mjs 8080
|
||
# → http://localhost:8080
|
||
```
|
||
|
||
> **Why not `python3 -m http.server`?** It sends `Last-Modified` with no
|
||
> `Cache-Control`, so browsers apply *heuristic* caching to the ES
|
||
> modules. While iterating, the browser then keeps running an **old**
|
||
> `js/*.js` module graph (e.g. a `GameScene.js` from before a feature
|
||
> landed) long after the files on disk changed — while the JSON data is
|
||
> fresh. That mix (new data + old JS) is the classic "it still behaves
|
||
> like the old version" bug, and clearing the JSON cache or hard-resetting
|
||
> the game does not fix it. The dev server above sends
|
||
> `Cache-Control: no-store` on every response, so a normal reload always
|
||
> runs what's on disk.
|
||
|
||
> Must be served over **http(s)** — opening `index.html` via `file://` won't
|
||
> work, because the game uses ES modules and `fetch`es its JSON config.
|
||
|
||
## Current state — v0.2: a seedable galaxy
|
||
|
||
- Main menu with **Continue** (resumes the newest save — grayed out while
|
||
the save bank is empty), **New Game**, **Load Game** (the full slot
|
||
bank), and a **Galaxy Seed** panel: the seed is displayed, editable
|
||
(click it and type), and rerollable — and the menu shows what that seed
|
||
builds (the galaxy's name, system count, archetype count) **before** you
|
||
commit. Same seed ⇒ same galaxy.
|
||
- **Procedural galaxy**: 90 star systems in a seeded wide (2:1, plate-shaped) field —
|
||
even, organic spacing (Poisson disk, `data/galaxy.json`), the player's
|
||
home in the lower-right corner, and a home→far difficulty axis split
|
||
into near/middle/far zones — typed into six themed archetypes
|
||
(`data/systems.json`) with per-type weights and per-zone mixing
|
||
(`galaxy.distribution.zoneMix`) — the galaxy lays itself out on a
|
||
progression the trade/combat economy can lean on.
|
||
- **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
|
||
lazy and eager give identical results.
|
||
- **A lived-in galaxy**: the galaxy was settled long before you arrive.
|
||
Every planet is settled (for now): colonies on habitable worlds, mining
|
||
stations over the rest, cloud bases riding gas giants — plus stations
|
||
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) 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.
|
||
The current system's dossier (name, identity, what's there) shows
|
||
top-left in the game scene.
|
||
- **Names with a point of view** (`data/naming.json`): planets draw from a
|
||
deep curated bank — colonial "New Denver" worlds beside alien "Klaxoria"
|
||
worlds — and stations from a bank of official designations ("Deep Space
|
||
SC-145") and smuggler hangouts ("Hell's Hideout"). Names are dealt out
|
||
randomly per system **without repeats until the bank is exhausted** (a
|
||
seeded shuffle, so the first N draws are N distinct names). Stars and the
|
||
galaxy itself are still synthesised from syllable pools, so you never run
|
||
out of those. Within a system a planet never shares a name with another
|
||
planet and a station never with another station.
|
||
- Game screen with a top-down ship (real spritesheet art — frame 0 of
|
||
`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 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
|
||
name tag autopilots the ship there** (it arrives on the keep-out rim,
|
||
facing the world)
|
||
- **The home planet** — the player's world — in the system you start in:
|
||
a 1024 px disc rendered 1:1 from the `assets/images/planets.png`
|
||
spritesheet, with a **proper name drawn from the planet name bank** (the
|
||
first name off the starting system's deck, so it never clashes with one
|
||
of the other worlds). Which Terran face it shows (frames 0–2) is a
|
||
seed-deterministic pick, so the same galaxy always yields the same home
|
||
world. The ship spawns ~150 px (edge-to-edge) off its rim in a
|
||
seed-derived direction, may fly in as close as 50 px from the rim, and
|
||
can never cross it — a planet is solid (tuning in `data/planets.json`)
|
||
- **The tether — your range.** You start with a level-1 tether anchored
|
||
on the home world: a circular zone, **5120 px from the planet's center**
|
||
(`data/tether.json`). The ship cannot leave it — anywhere in the union
|
||
of its tethers' zones is open space (where zones overlap there is no
|
||
line and no wall), and the rim is a hard barrier rendered as a thick,
|
||
glitchy, marching dotted line (neon core + RGB fringe + ambient glitch
|
||
bursts, contact shudder where the ship bumps it). Click-to-fly and
|
||
autopilot targets are clamped to the union boundary, so a world outside
|
||
your range is where the ship parks — on the line — until a tether grows.
|
||
Upgrades and extra anchors (planets/stations) plug into
|
||
`TetherField.add/setLevel` (tuning in `data/tether.json`)
|
||
- **The Research console** — the **RESEARCH** deck button opens a full-screen
|
||
window: the left pane loops the muted `assets/videos/research-computer.mp4`
|
||
archive feed (scanlines, sweep band, REC pulse, glitch bursts), the right
|
||
pane holds the category tabs and the **branching tech tree** for the
|
||
selected category — it starts at the top and unlocks downward, one branch
|
||
at a time. Selecting a tech shows its dossier (icon + description +
|
||
duration) and a **RESEARCH** button, which appears only when the tech is
|
||
researchable and nothing else is in progress (one project at a time,
|
||
`maxConcurrent: 1`). A deck progress bar tracks the run; completion fires
|
||
the tech's `effects` (e.g. `tether: {level: N}` grows the home tether's
|
||
range) and a toast. Each category's tree is its own JSON file under
|
||
`data/research/` (**Exploration**: Tether Level 1–3;
|
||
**Mining**: Improved Mining Arm, Advanced Mining
|
||
Arm, Improved Mining Storage — all three unlock the matching build in
|
||
the console's MINING tab). Unlocks and in-progress work
|
||
persist in the save bank — and resume correctly on load
|
||
- **The Build console** — the **BUILD** deck button (on a planet surface,
|
||
where the deck's SHOP/BUILD/TAKE OFF live) opens a full-screen window:
|
||
the left pane loops the muted 2:3 `assets/videos/build.mp4` build feed
|
||
(scanlines, sweep band, REC pulse), the right pane holds the category
|
||
tabs (**Planet / Cargo**, `data/builds.json`) and a **list** of the
|
||
category's buildable items — not a tree: builds are one-off installs on
|
||
the planet. Locked items are grayed out with their missing requirements
|
||
(research, tether level); installed ones read **BUILT ✓**. Selecting a
|
||
build shows its dossier (glyph + description) with the **highlighted
|
||
cost** and a **BUILD** button that appears only when the build is
|
||
available and affordable (one build at a time, `maxConcurrent: 1`). The
|
||
home world starts with **Tether – Level 1** already installed; **Tether
|
||
– Level 2** needs the level-2 tether research plus a world holding a
|
||
level-1 tether, and installs in **20 s for 200 minerals** — the planet's
|
||
tether range then grows from 5120 to 10240 px (world state that outlives
|
||
the surface stay). While a build runs the whole deck is locked (BUILD
|
||
stays open to watch the progress); completion fires the build's
|
||
`effects` and a toast. Build records + the in-progress build persist in
|
||
the save bank — and resume with their remaining time on load
|
||
- **The Map console** — the **MAP** deck button opens a full-screen
|
||
cartography window (`data/map.json`, `js/ui/MapWindow.js`), the
|
||
ResearchWindow's bones: the left pane loops the muted 2:3
|
||
`assets/videos/map.mp4` feed (scanlines, sweep band, REC pulse, glitch
|
||
bursts). Two tabs on the chart plate:
|
||
- **CURRENT SYSTEM** — the whole solar system on a padded frame
|
||
(discovered objects, the tether range as a glitch-dotted union
|
||
boundary, and a fog-of-war dim over what the tether doesn't reach
|
||
yet), the system's discovery + resources readout, hover tooltips,
|
||
and click-to-ENGAGE AUTOPILOT (the same autopilot as a world click);
|
||
wheel zoom / drag pan (the chart repaints crisp at the new scale)
|
||
- **GALAXY** — the whole galaxy: a **glowing star per system**, each
|
||
colored by its archetype and pulsing on that archetype's heartbeat
|
||
(binary stars beat fast with a twin core), the **jump-lane web** as
|
||
thin lanes — traveled lanes glow brighter with a flow packet drifting
|
||
the route, frontier lanes (one end charted) brighter still, unexplored
|
||
lanes faint threads — plus the **charted region** (the hull of the
|
||
visited systems, soft fill + subtle outline), HOME/SHIP markers, and
|
||
a reveal ripple out of home. Hover a star for its readout + its link
|
||
state to your current system; **click a star on a live lane → CONFIRM
|
||
JUMP** (the same gate transport as the world's gates). The run's
|
||
footprint — visited systems + traveled lanes — is part of the save
|
||
(old saves load, defaulting to “your system is charted, nothing
|
||
traveled”). All of it tuned in `data/map.json → galaxy`
|
||
- Camera gently trails the ship; the **parallax starfield** streams past
|
||
while it flies and the view slowly recenters (≈1.5 s) once the ship
|
||
comes to rest
|
||
- Config-driven setup: every tunable value lives in `data/*.json`
|
||
|
||
## Project layout
|
||
|
||
```
|
||
orbit/
|
||
├── index.html # boots the game
|
||
├── data/ # ← ALL tunable config (edit these freely)
|
||
│ ├── manifest.json # which config files exist
|
||
│ ├── game.json # dimensions, colors, starfield, …
|
||
│ ├── menu.json # menu text, colors, button layout, seed panel
|
||
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
|
||
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
|
||
│ ├── planets.json # home world + system layout + solid-disc rules
|
||
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
|
||
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
|
||
│ ├── galaxy.json # galaxy scale & shape (count, 2:1 field, zones, corner home…)
|
||
│ ├── systems.json # system archetypes: theme, attributes, distribution
|
||
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
|
||
│ ├── gates.json # JUMP GATES: network (1–3 gates, local jumps, pure spanning tree — maze, no shortcuts) + placement (tether anchor, facing, radii, gaps)
|
||
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
|
||
│ │ # trees live one-per-file below (section name = file basename)
|
||
│ ├── research/
|
||
│ │ ├── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
|
||
│ │ └── mining.json # the Mining tree: improved/advanced mining arm, improved storage (blueprints)
|
||
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
|
||
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
|
||
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
|
||
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
|
||
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
|
||
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
||
├── js/
|
||
│ ├── main.js # entry point: load config → boot Phaser
|
||
│ ├── config/ # Config singleton, ConfigLoader, game config
|
||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
|
||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
|
||
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
|
||
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
|
||
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
|
||
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow, BuildWindow
|
||
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
|
||
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
||
│ └── vendor/ # shim to the vendored Phaser
|
||
└── docs/PROJECT_NOTES.md # ← project conventions: read this
|
||
```
|
||
|
||
## Conventions (short version)
|
||
|
||
- **Config in JSON.** If a value might change, it goes in `data/`, not code.
|
||
Add a file = one line in `data/manifest.json`.
|
||
- **One class per file**, ES modules, scenes stay thin, entities own their
|
||
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
|
||
- Phaser is imported only via `js/vendor/phaser.js` (one-file version swap).
|
||
|
||
## The player's loop: research + building
|
||
|
||
Beyond flying, Orbit is built around two progression verbs:
|
||
|
||
- **Research** (`data/research.json` + `data/research/<category>.json`) —
|
||
*time-based*. A project takes a fixed `duration` (`timeUnit: seconds`);
|
||
the player researches **one thing at a time** (`maxConcurrent: 1`).
|
||
Research is the gate: it unlocks **builds** and **further research**
|
||
(each category tree is a DAG — a node's `requires` must be unlocked
|
||
first). The RESEARCH deck button opens the console window
|
||
(`js/ui/ResearchWindow.js`): looping archive feed, category tabs, the
|
||
branching tree, per-tech dossier + RESEARCH button, and a deck progress
|
||
bar. The rules are pure (`js/research/ResearchModel.js`,
|
||
`ResearchState.js` — Node-testable); the window is a passive view that
|
||
asks `GameScene` to start a run. `effects` on a finished node carry the
|
||
payload (e.g. `{ tether: { level: 2 } }` → `TetherField.setLevel`), so new
|
||
effect kinds plug in without touching the tree data.
|
||
- **Building** (`data/builds.json`) — *cost-based*, paid in **minerals**
|
||
(and credits, when they land; `resources`). A build is a **one-off
|
||
install** (`category: planet | cargo | mining`): planet builds land on
|
||
the world's surface (`targets: ["planet"]`); the Mining tab's builds are
|
||
**ship upgrades** (`targets: ["ship"]` — the improved/advanced mining
|
||
arm set the ship's mining rate to 1.5 / 2.0 minerals per second, the
|
||
improved storage grows the hold to 350): built from any planet's
|
||
console, the effect is ship-wide, and it counts as built wherever the
|
||
ship lands (no re-buy). Each build names its `cost`, its `duration`
|
||
(seconds), the research that unlocks it (`requires`, mirroring the
|
||
research tree's `unlocks.builds`), optional world-state gates
|
||
(`planetRequires`, e.g. the tether level on that world), and its
|
||
`effects` (e.g. `tether: {level: 2}` → the world's tether field
|
||
strengthens; `mining: {rate: 2.0}` / `mining: {capacity: 350}` → the
|
||
ship's stats, MAX wins). The BUILD deck button (on a planet surface)
|
||
opens the console window (`js/ui/BuildWindow.js`): looping build feed,
|
||
category tabs, the build LIST (locked items grayed with their missing
|
||
gates), per-build dossier + highlighted cost + BUILD button. While a
|
||
build runs the deck is locked (one at a time) and the state ticks on
|
||
the game-loop clock — so a build started on the surface keeps running
|
||
if the player takes off. The rules are pure (`js/build/BuildModel.js`,
|
||
`BuildState.js` — Node-tested in `dev/builds.test.mjs`); the window is
|
||
a passive view that asks `GameScene.beginBuild()` to run the gates,
|
||
charge the cost, and start the clock, and the scene applies `effects`
|
||
on completion (ship upgrades re-derive from the build records on load,
|
||
`GameScene._restoreMiningUpgrades`).
|
||
- **The command deck** (`data/actionbar.json`, rendered by
|
||
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the
|
||
screen. Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**
|
||
in space; on a planet surface the same bar re-deals itself —
|
||
**Shop, Build, Ship, ·, Take Off, Menu** (`surface.buttons`). The
|
||
slots fire `onAction` (GameScene / SurfaceScene own the behavior);
|
||
two slots are reserved. Dressed with the menu's CRT language —
|
||
scanlines over the strip and the GlitchText RGB pull-apart on labels
|
||
and the panel outline during bursts. All layout, palette, and motion
|
||
(boot flicker, rail comet, glitch bursts, RGB split, scanlines) live in
|
||
the config.
|
||
|
||
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
|
||
runtime data — loaders and tests ignore them.
|
||
|
||
## Dev tools
|
||
|
||
```sh
|
||
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
||
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
||
node dev/galaxy.test.mjs # galaxy determinism, composition, lazy vs eager, gradient + report
|
||
node dev/frames.test.mjs # the galaxy-wide (class, frame) pass: in-pool, spread, determinism
|
||
node dev/jumps.test.mjs # the gate network: 1–3 gates, locality, strong connectivity + placement invariants
|
||
node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields, dormant/active, the solid contract (Node)
|
||
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
|
||
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
|
||
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
|
||
node dev/builds.test.mjs # the build console's pure rules + state machine (Node)
|
||
node dev/world-names.test.mjs # world-name casing: display casing must never leak into the data
|
||
node dev/saves.test.mjs # the save bank + capture/restore hand-off (incl. builds)
|
||
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
|
||
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
|
||
node dev/quest-tracker.test.mjs # the quest tracker HUD: slide in/out, live checklist, priority, SHOW MORE, the popup contract
|
||
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
|
||
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
|
||
node dev/music.test.mjs # the shared music voice: guards + music.json contract
|
||
```
|
||
|
||
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||
handy for manual testing of the flight feel. `dev/research-shot.html`
|
||
does the same but opens the Research console and starts a run, and
|
||
`dev/cdp-shot.mjs` waits for a readiness expression before screenshotting
|
||
through CDP (any Chromium-CDP browser on `127.0.0.1:9333`):
|
||
|
||
```sh
|
||
node dev/cdp-shot.mjs "http://127.0.0.1:8081/dev/research-shot.html" shot.png \
|
||
"window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" 90000
|
||
```
|
||
|
||
`dev/build-check.html` plays the real Build flow (menu → New Game → home
|
||
world → build console) and prints a plain-text report of the L1/L2 row
|
||
states. Every real page (index.html) also carries a read-only console
|
||
diagnostic: opening the Build console logs a one-line `[orbit-diag v…]`
|
||
summary (planet, home, built records, tether level, L1/L2 row states,
|
||
stale-JS probe), and `orbitDiag()` in DevTools prints the full report
|
||
(data contract, save bank, scene wiring, errors since boot). If
|
||
`orbitDiag` is *undefined*, the browser served a cached older
|
||
`js/main.js` — run the dev server above, or hard-reload with the cache
|
||
disabled (DevTools → Network → Disable cache).
|
||
|
||
## Phaser
|
||
|
||
Phaser 4.2.1 is vendored at `lib/phaser.min.js` (UMD build, MIT license —
|
||
see `lib/PHASER_LICENSE.md`). No internet or npm needed at runtime.
|