Add v0.2: seedable galaxy, system archetypes, and two-level procedural generation

- Main menu Galaxy Seed panel: displayed, editable, rerollable; the menu
  previews what the seed builds (galaxy name, scale, archetypes) before
  committing. Same seed always generates the same galaxy.
- js/galaxy/Galaxy.js: seeded roster of 40k systems (disk + core + spiral
  arms, type weights, radial bands) with spatial hash for neighbor queries;
  contents generated lazily on arrival — identical to eager generateAll()
  because each system draws from Rng.derive(seed, 'system', id).
- js/galaxy/SystemGenerator.js + data/systems.json: six themed archetypes
  whose attributes (star classes, planet count spread, class weights,
  moons, belts, habitability, hazard) steer content generation.
- data/galaxy.json layout/distribution knobs, incl. distribution.rules[]
  hook for future proximity/clustering rules; data/naming.json name pools.
- GameScene: current system name/identity HUD (lazy content generation
  on arrival); dev boot without a menu falls back to a dev galaxy.
- dev/galaxy.test.mjs: determinism, distribution, bands, lazy==eager,
  neighbor brute-force checks (40k roster in ~70ms).
- Docs: world model, determinism rules, roadmap update.
This commit is contained in:
Brian Fertig 2026-09-03 10:49:22 -06:00
parent 7cf96bb237
commit eafc3c4314
16 changed files with 1420 additions and 28 deletions

View File

@ -17,13 +17,26 @@ python3 -m http.server 8080
> Must be served over **http(s)** — opening `index.html` via `file://` won't > 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. > work, because the game uses ES modules and `fetch`es its JSON config.
## Current state — v0.1 foundation ## Current state — v0.2: a seedable galaxy
- Main menu with a **New Game** button - Main menu with **New Game** and a **Galaxy Seed** panel: the seed is
- Game screen with a basic top-down ship: **click anywhere to fly there** in displayed, editable (click it and type), and rerollable — and the menu
infinite, unbounded space (borders/sectors come later) shows what that seed builds (the galaxy's name, system count, archetype
- Camera gently trails the ship; the **parallax starfield** streams past while it count) **before** you commit. Same seed ⇒ same galaxy.
flies and the view slowly recenters (≈1.5 s) once the ship comes to rest - **Procedural galaxy**: 40,000 star systems in a seeded disk + core +
spiral arms (`data/galaxy.json`), typed into six themed archetypes
(`data/systems.json`) with per-type distribution weights and radial
bands — the first "how does the galaxy lay itself out" rules.
- **Two-level generation**: the whole galaxy roster is generated at New
Game (~70 ms); each system's planets/moons/belts are generated lazily on
arrival, deterministically (seed + system id), so lazy and eager give
identical results. The current system's name/identity shows top-left in
the game scene.
- Game screen with a basic top-down ship: **click anywhere to fly there**
in infinite, unbounded space (system boundaries/jumps come next)
- 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` - Config-driven setup: every tunable value lives in `data/*.json`
## Project layout ## Project layout
@ -34,17 +47,21 @@ orbit/
├── data/ # ← ALL tunable config (edit these freely) ├── data/ # ← ALL tunable config (edit these freely)
│ ├── manifest.json # which config files exist │ ├── manifest.json # which config files exist
│ ├── game.json # dimensions, colors, starfield, … │ ├── game.json # dimensions, colors, starfield, …
│ ├── menu.json # menu text, colors, button layout │ ├── menu.json # menu text, colors, button layout, seed panel
│ └── ship.json # ship feel: thrust, drag, maxSpeed, … │ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ └── naming.json # syllable pools for names
├── lib/ # vendored third-party libs (Phaser 4.2.1) ├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/ ├── js/
│ ├── main.js # entry point: load config → boot Phaser │ ├── main.js # entry point: load config → boot Phaser
│ ├── config/ # Config singleton, ConfigLoader, game config │ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration) │ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior) │ ├── entities/ # Ship (own behavior)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator
│ ├── ui/ # MenuButton (reusable) │ ├── ui/ # MenuButton (reusable)
│ ├── visuals/ # Starfield (decorative) │ ├── visuals/ # Starfield (decorative)
│ ├── utils/ # small helpers (Color) │ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser │ └── vendor/ # shim to the vendored Phaser
└── docs/PROJECT_NOTES.md # ← project conventions: read this └── docs/PROJECT_NOTES.md # ← project conventions: read this
``` ```
@ -62,6 +79,7 @@ orbit/
```sh ```sh
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node 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/starfield.test.mjs # runs the real Starfield.create() in Node
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
``` ```
`dev/test-game.html` boots straight into the GameScene (no menu click), `dev/test-game.html` boots straight into the GameScene (no menu click),

16
data/galaxy.json Normal file
View File

@ -0,0 +1,16 @@
{
"systemCount": 40000,
"radius": 20000,
"layout": {
"coreFraction": 0.25,
"bulgeSigma": 0.09,
"diskSkew": 1.7,
"flatten": 0.62,
"spiral": { "enabled": true, "arms": 2, "twist": 2.6, "strength": 0.5 }
},
"distribution": {
"rules": []
},
"startingSystem": { "policy": "center" },
"neighbors": 8
}

View File

@ -1,6 +1,6 @@
{ {
"name": "Orbit", "name": "Orbit",
"version": "0.1.0", "version": "0.2.0",
"width": 1280, "width": 1280,
"height": 720, "height": 720,
"backgroundColor": "#04060d", "backgroundColor": "#04060d",

View File

@ -2,6 +2,9 @@
"files": [ "files": [
"game.json", "game.json",
"menu.json", "menu.json",
"ship.json" "ship.json",
"galaxy.json",
"systems.json",
"naming.json"
] ]
} }

View File

@ -18,8 +18,26 @@
"buttons": { "buttons": {
"newGame": { "newGame": {
"label": "New Game", "label": "New Game",
"position": { "x": 0.5, "y": 0.62 }, "position": { "x": 0.5, "y": 0.60 },
"fontSize": 24 "fontSize": 24
} }
},
"seed": {
"label": "GALAXY SEED",
"position": { "x": 0.5, "y": 0.755 },
"labelFontSize": 12,
"fontSize": 24,
"fieldWidth": 300,
"fieldHeight": 46,
"rerollLabel": "reroll",
"rerollFontSize": 14,
"colors": {
"label": "#54608a",
"value": "#e9edf8",
"fieldBg": "#0b1226",
"border": "#2a3a63",
"activeBorder": "#41c7ff",
"hint": "#54608a"
}
} }
} }

16
data/naming.json Normal file
View File

@ -0,0 +1,16 @@
{
"_comment": "Syllable pools for deterministic name synthesis (NameGenerator.js). Edit freely — names are drawn per-entity from the galaxy seed, so changing pools changes every name, consistently.",
"star": {
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
"minParts": 2,
"maxParts": 4
},
"planet": {
"roots": ["aurel", "bryn", "cald", "dross", "emrys", "fen", "gale", "hyra", "ilv", "jor", "kest", "lun", "mora", "neth", "orb", "pyre", "quill", "ross", "sable", "tarn", "ulric", "vex", "wren", "xan", "yrra", "zeph"]
},
"galaxy": {
"syllables": ["an", "dro", "me", "mil", "ky", "cen", "tau", "rhi", "sa", "vel", "pyr", "os", "eth", "ni"],
"minParts": 3,
"maxParts": 5
}
}

119
data/systems.json Normal file
View File

@ -0,0 +1,119 @@
{
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet count spread, planet class weights, moon/belt chances, habitability, hazard. distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
"types": {
"main": {
"label": "Main Sequence",
"description": "An ordinary star and its worlds — the galaxy's working majority.",
"theme": { "color": "#9fb4e8" },
"distribution": { "weight": 34, "radiusBand": null },
"attributes": {
"star": {
"classes": { "G": 30, "K": 40, "M": 30 },
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
},
"binaryChance": 0.05,
"planetCount": { "min": 4, "max": 9, "mean": 6.5 },
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 },
"moonChance": 0.3,
"beltChance": 0.35,
"habitability": 0.1,
"hazard": 0.1
}
},
"redDwarf": {
"label": "Red Dwarf",
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
"theme": { "color": "#e8927c" },
"distribution": { "weight": 26, "radiusBand": [0.0, 0.6] },
"attributes": {
"star": {
"classes": { "M": 85, "K": 15 },
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
},
"binaryChance": 0.12,
"planetCount": { "min": 3, "max": 7, "mean": 5 },
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 },
"moonChance": 0.45,
"beltChance": 0.3,
"habitability": 0.22,
"hazard": 0.12
}
},
"binary": {
"label": "Binary",
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
"theme": { "color": "#c9a7ff" },
"distribution": { "weight": 10, "radiusBand": [0.15, 0.95] },
"attributes": {
"star": {
"classes": { "F": 25, "G": 40, "K": 35 },
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
},
"binaryChance": 1.0,
"planetCount": { "min": 2, "max": 6, "mean": 4 },
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 },
"moonChance": 0.5,
"beltChance": 0.6,
"habitability": 0.06,
"hazard": 0.18
}
},
"habitable": {
"label": "Habitable",
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
"theme": { "color": "#7ce8a4" },
"distribution": { "weight": 10, "radiusBand": [0.2, 0.75] },
"attributes": {
"star": {
"classes": { "G": 70, "K": 30 },
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
},
"binaryChance": 0.03,
"planetCount": { "min": 3, "max": 8, "mean": 5.5 },
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 },
"moonChance": 0.35,
"beltChance": 0.25,
"habitability": 0.5,
"hazard": 0.05
}
},
"nebula": {
"label": "Nebula",
"description": "Young, bright, and still messy — debris where planets should be.",
"theme": { "color": "#5fd4d0" },
"distribution": { "weight": 12, "radiusBand": [0.4, 1.0] },
"attributes": {
"star": {
"classes": { "A": 20, "F": 30, "G": 50 },
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
},
"binaryChance": 0.1,
"planetCount": { "min": 1, "max": 5, "mean": 3 },
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 },
"moonChance": 0.2,
"beltChance": 0.8,
"habitability": 0.02,
"hazard": 0.35
}
},
"void": {
"label": "Void",
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
"theme": { "color": "#7d88a8" },
"distribution": { "weight": 8, "radiusBand": [0.7, 1.0] },
"attributes": {
"star": {
"classes": { "M": 90, "K": 10 },
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
},
"binaryChance": 0.08,
"planetCount": { "min": 0, "max": 4, "mean": 1.5 },
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 },
"moonChance": 0.1,
"beltChance": 0.2,
"habitability": 0.02,
"hazard": 0.5
}
}
}
}

239
dev/galaxy.test.mjs Normal file
View File

@ -0,0 +1,239 @@
/**
* Galaxy & system generation test (dev tool, run with Node no browser):
*
* node dev/galaxy.test.mjs
*
* Runs the REAL Rng, NameGenerator, SystemGenerator, and Galaxy from js/
* against the real data/*.json config, then asserts the determinism
* contract the whole game rests on:
* - same seed identical roster (ids, names, types, positions);
* - different seed different galaxy;
* - type distribution matches the weights in data/systems.json;
* - type radius bands (the first proximity rule) are respected;
* - every generated system obeys its type's attribute bounds;
* - lazy (on-arrival) content === eager (generateAll) content;
* - spatial-hash neighbor queries agree with brute force;
* - starting system policy works.
* Also reports generation timing for the default 40,000-system galaxy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { NameGenerator } = await import(pathToFileURL(join(__dirname, '../js/utils/NameGenerator.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const deepEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const SEED = 'orbit-determinism-test';
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
const weights = typeIds.map((id) => Math.max(0, types[id].distribution?.weight ?? 1));
const totalW = weights.reduce((s, w) => s + w, 0);
const expected = Object.fromEntries(typeIds.map((id, i) => [id, weights[i] / totalW]));
// ----------------------------------------------------------------------
// 1. Rng: determinism, streams, weighted
// ----------------------------------------------------------------------
{
const a = new Rng('same-seed');
const b = new Rng('same-seed');
const seqA = Array.from({ length: 5 }, () => a.next());
const seqB = Array.from({ length: 5 }, () => b.next());
check('same seed ⇒ same sequence', deepEq(seqA, seqB));
check('different seed ⇒ different sequence', !deepEq(seqA, Array.from({ length: 5 }, () => new Rng('other').next())));
const f1 = Rng.derive('seed', 'system', 'S001');
const f2 = Rng.derive('seed', 'system', 'S001');
check('derive() is stable for same labels', deepEq([f1.next(), f1.next()], [f2.next(), f2.next()]));
check('derive() streams differ for different labels', Rng.derive('seed', 'system', 'S001').next() !== Rng.derive('seed', 'system', 'S002').next());
const c = new Rng('parent');
const fork = c.fork('child');
c.next(); // parent use must not disturb the fork
const fresh = Rng.derive('parent', 'child');
check('fork independent of parent state', deepEq([fork.next(), fork.next()], [fresh.next(), fresh.next()]));
const w = new Rng('w');
const draws = Array.from({ length: 10000 }, () => w.weighted({ a: 30, b: 70 }));
const shareA = draws.filter((x) => x === 'a').length / draws.length;
check(`weighted() respects weights (a=${(shareA * 100).toFixed(1)}% ≈ 30%)`, Math.abs(shareA - 0.3) < 0.03);
const ints = Array.from({ length: 1000 }, () => new Rng('i').int(3, 7));
check('int() stays in [min,max]', ints.every((n) => Number.isInteger(n) && n >= 3 && n <= 7));
const sh = new Rng('s').shuffle([1, 2, 3, 4, 5]);
check('shuffle() is a permutation', [...sh].sort((x, y) => x - y).join() === '1,2,3,4,5');
const n = new Rng('n');
const norms = Array.from({ length: 4000 }, () => n.normal(0, 1));
const mean = norms.reduce((s, v) => s + v, 0) / norms.length;
const sd = Math.sqrt(norms.reduce((s, v) => s + v * v, 0) / norms.length);
check(`normal() ≈ N(0,1) (mean ${mean.toFixed(2)}, sd ${sd.toFixed(2)})`, Math.abs(mean) < 0.08 && Math.abs(sd - 1) < 0.08);
const s1 = Rng.randomSeedString(8);
check('randomSeedString() length & charset', /^[a-z2-9]{8}$/.test(s1));
const star = NameGenerator.star(Rng.derive('x', 'name', 'S1'));
check('star name synthesis', typeof star === 'string' && star.length >= 3 && star === star.charAt(0).toUpperCase() + star.slice(1));
}
// ----------------------------------------------------------------------
// 2. Galaxy: determinism & seed sensitivity (full default size)
// ----------------------------------------------------------------------
let big;
{
const t0 = performance.now();
big = Galaxy.create(SEED);
const tCreate = performance.now() - t0;
const n = config.get('galaxy.systemCount', 0);
check(`roster size = galaxy.systemCount (${n})`, big.records.length === n);
check('ids are stable & unique', new Set(big.records.map((r) => r.id)).size === n);
check('current system is part of the roster', big.byId.has(big.currentSystemId));
const again = Galaxy.create(SEED);
check('same seed ⇒ identical roster (names, types, x, y)', deepEq(big.records, again.records));
check('same seed ⇒ same starting system', big.currentSystemId === again.currentSystemId);
check('same seed ⇒ same galaxy name', big.name === again.name);
const other = Galaxy.create('totally-different');
check('different seed ⇒ different galaxy', !deepEq(big.records, other.records));
// Type distribution vs configured weights.
const counts = {};
for (const r of big.records) counts[r.type] = (counts[r.type] ?? 0) + 1;
const nSys = big.records.length;
let distOk = true;
for (const id of typeIds) {
const obs = (counts[id] ?? 0) / nSys;
const sd = Math.sqrt((expected[id] * (1 - expected[id])) / nSys);
if (Math.abs(obs - expected[id]) > 4 * sd + 0.004) {
distOk = false;
console.log(` type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(expected[id] * 100).toFixed(1)}%`);
}
}
check('type distribution matches configured weights (±4σ)', distOk);
// Radius bands (the first "proximity" rule).
const R = Math.max(1, big.params.radius ?? 20000);
const flatten = big.params.layout?.flatten ?? 0.62;
let bandOk = true;
for (const r of big.records) {
const band = types[r.type].distribution?.radiusBand;
if (!Array.isArray(band) || band.length !== 2) continue;
const rNorm = Math.sqrt(r.x * r.x + (r.y / flatten) ** 2) / R;
if (rNorm < band[0] - 1e-9 || rNorm > band[1] + 1e-9) {
bandOk = false;
break;
}
}
check('radius bands (proximity rule) respected by every system', bandOk);
// Lazy contents: attribute bounds across the WHOLE galaxy.
const t1 = performance.now();
let boundsOk = true;
for (const r of big.records) {
const content = big.ensureContent(r.id);
const attr = types[r.type].attributes ?? {};
const pc = attr.planetCount ?? { min: 0, max: 99 };
if (content.planets.length < pc.min || content.planets.length > pc.max) {
boundsOk = false;
break;
}
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
}
const tGen = performance.now() - t1;
check('every system obeys its type attribute bounds (planetCount etc.)', boundsOk);
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
const fresh = Galaxy.create(SEED);
const sample = [big.records[0].id, big.records[999].id, big.records[nSys - 1].id];
const lazyEager = sample.every((id) => deepEq(fresh.ensureContent(id), big.ensureContent(id)));
check('lazy (on-arrival) content === content already generated', lazyEager);
const eager = Galaxy.create(SEED).generateAll();
check('generateAll() (eager mode) identical to lazy', sample.every((id) => deepEq(eager.contentOf(id), fresh.contentOf(id))));
console.log(` timing: roster(${nSys}) ${tCreate.toFixed(0)} ms · all contents ${tGen.toFixed(0)} ms`);
}
// ----------------------------------------------------------------------
// 3. Small galaxy: neighbors & starting system vs brute force
// ----------------------------------------------------------------------
{
const small = Galaxy.create('small', { systemCount: 300 });
const recs = small.records;
const bruteNearest = (x, y, k) =>
recs
.map((r) => ({ d2: (r.x - x) ** 2 + (r.y - y) ** 2, record: r }))
.sort((a, b) => a.d2 - b.d2)
.slice(0, k)
.map((e) => e.record.id);
let nnOk = true;
for (let i = 0; i < 20; i++) {
const probe = recs[(i * 17) % recs.length];
const k = 5;
const got = small
.neighborsOf(probe.id, k)
.map((r) => r.id)
.sort();
const want = bruteNearest(probe.x, probe.y, k + 1)
.filter((id) => id !== probe.id)
.slice(0, k)
.sort();
if (!deepEq(got, want)) {
nnOk = false;
break;
}
}
check('neighborsOf() matches brute-force k-nearest (300-system galaxy)', nnOk);
const point = { x: 1234.5, y: -777.25 };
const gotP = small.nearest(point.x, point.y, 3).map((r) => r.id).sort();
const wantP = bruteNearest(point.x, point.y, 3).sort();
check('nearest(point, k) matches brute force', deepEq(gotP, wantP));
const centerPolicy = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
check('random starting policy picks a roster member', centerPolicy.byId.has(centerPolicy.currentSystemId));
const center = Galaxy.create('center-policy', { systemCount: 250 });
const centerRecs = center.records;
const trueCenter = centerRecs.slice().sort((a, b) => (a.x ** 2 + a.y ** 2) - (b.x ** 2 + b.y ** 2))[0].id;
const gridNearest = center.nearest(0, 0, 1)[0].id;
check('center starting policy picks the record nearest the origin', center.currentSystem().id === trueCenter && gridNearest === trueCenter);
}
// ----------------------------------------------------------------------
// 4. Sanity: content shape & theming hooks
// ----------------------------------------------------------------------
{
const g = Galaxy.create('content-shape');
const sys = g.currentSystem();
const c = g.ensureContent(sys.id);
check('content has star/planets/belt/hazard', !!c.star && Array.isArray(c.planets) && !!c.belt && typeof c.hazard === 'boolean');
check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean'));
check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string'));
check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name);
}
console.log(failures === 0 ? '\nAll galaxy tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -35,9 +35,11 @@ trading/economy, stations, quests — to be scoped as we go.
- **Rule of thumb:** if a value might ever change (balance, layout, copy, - **Rule of thumb:** if a value might ever change (balance, layout, copy,
colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns
*parameters*. *parameters*.
- Split config by concern as the game grows: `data/sectors.json` for world - Split config by concern as the game grows: world generation already has
generation, `data/economy.json`, `data/ships.json`, `data/crew.json`, etc. `data/galaxy.json` (shape/scale), `data/systems.json` (archetypes), and
One file per system beats one giant file. `data/naming.json` (name pools); 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) ## Code is modular & class-based (important)
@ -48,7 +50,9 @@ trading/economy, stations, quests — to be scoped as we go.
- `js/entities/` — in-world objects that own their behavior (Ship, NPC, …) - `js/entities/` — in-world objects that own their behavior (Ship, NPC, …)
- `js/ui/` — reusable UI components (MenuButton, panels, HUD) - `js/ui/` — reusable UI components (MenuButton, panels, HUD)
- `js/visuals/` — decorative, non-interactive visuals (Starfield, …) - `js/visuals/` — decorative, non-interactive visuals (Starfield, …)
- `js/utils/` — small pure helpers (Color, math, rng) - `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/config/` — config loading + Phaser game config
- `js/vendor/` — shims to pinned third-party libraries - `js/vendor/` — shims to pinned third-party libraries
- Scenes stay thin: they compose entities/UI and wire input. They don't hold - Scenes stay thin: they compose entities/UI and wire input. They don't hold
@ -63,6 +67,50 @@ trading/economy, stations, quests — to be scoped as we go.
- Import Phaser only through `js/vendor/phaser.js` — the single place that - Import Phaser only through `js/vendor/phaser.js` — the single place that
changes if we swap framework versions. 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) up front. 40,000 systems is ~70 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/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 count spread, planet class weights,
moon/belt chances, habitability, hazard). New attribute key = JSON +
a few lines in `SystemGenerator.js`;
- **distributed**`distribution.weight` (how common) and
`distribution.radiusBand` (first proximity rule: e.g. `void` systems
live in the outer rim). Richer galaxy-level rules (clustering,
faction borders, adjacency affinity) will slot into
`data/galaxy.json``distribution.rules[]`, read in
`Galaxy._generate()` — the hook is marked in code.
The player's **current system** starts at `galaxy.currentSystem()`
(`startingSystem.policy`: `center` or `random`). Jumping between systems
(the eventual star map / jump drives) will use `galaxy.neighborsOf(id)`
and the spatial hash already built for it.
## Phaser version ## Phaser version
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`. - Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
@ -73,11 +121,20 @@ trading/economy, stations, quests — to be scoped as we go.
## Roadmap (working list, intentionally rough) ## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship - [x] v0.1 foundation — menu → New Game → click-to-fly ship
- [ ] Decide the world model: **infinite open space for now** (ship flies - [x] Galaxy seed on the main menu (displayed, editable, rerollable;
unbounded; borders/sectors planned for later) — affects camera, same seed → same galaxy, shown before you commit)
starfield, and world gen - [x] Two-level worldgen: seeded galaxy roster (40k systems) + lazy,
- [ ] Procedural star map / sector generation (driven by `data/sectors.json`) order-independent system contents; system archetypes in JSON
(theme + attributes + distribution weight/radius band)
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
clustering by type, borders, adjacency affinity) — hook marked in
`Galaxy._generate()`
- [ ] 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) - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- [ ] HUD (speed, sector name, later: fuel/crew) - [ ] HUD (speed, sector name, later: fuel/crew) — system name/identity
- [ ] Save/load (the `config` + entity split should make this tractable) already shown top-left
- [ ] Save/load (the `config` + entity split should make this tractable;
a save = seed + player state, since the galaxy regenerates)
- [ ] Economy/trading loop (the Privateer heart) - [ ] Economy/trading loop (the Privateer heart)

View File

@ -28,9 +28,12 @@ class Config {
return Object.prototype.hasOwnProperty.call(this.data, name); return Object.prototype.hasOwnProperty.call(this.data, name);
} }
/** @returns {object} the whole section as a plain object */ /**
* @returns {object} the value at `name` if it's an object dotted
* paths allowed (`section('systems.types')` works) else `fallback`.
*/
section(name, fallback = {}) { section(name, fallback = {}) {
const value = this.data[name]; const value = this.get(name, undefined);
return (value && typeof value === 'object') ? value : fallback; return (value && typeof value === 'object') ? value : fallback;
} }

301
js/galaxy/Galaxy.js Normal file
View File

@ -0,0 +1,301 @@
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { generateSystemContent } from './SystemGenerator.js';
const TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const wrapPI = (a) => {
const t = (a + Math.PI) % TAU;
return (t < 0 ? t + TAU : t) - Math.PI;
};
/**
* The Galaxy an immense, procedurally generated collection of star
* systems, fully determined by its seed.
*
* Two-level generation (the "grand scale" design):
*
* 1. GALAXY ROSTER generated once, up front, when New Game is pressed:
* `systemCount` lightweight records ({ id, name, type, x, y }).
* This is cheap: ~40,000 systems is a fraction of a second and a few
* MB. It fixes the shape of the galaxy, where every system sits, and
* what KIND each one is for the entire galaxy, from the seed alone.
*
* 2. SYSTEM CONTENTS planets, moons, belts, hazards generated LAZILY,
* the first time the player arrives (`ensureContent(id)`), then
* cached. Because each system's draw stream is derived from
* (seed, 'system', id), a lazy generation is identical to an eager
* one so this is a pure performance choice, never a correctness one.
* `generateAll()` exists for exactly that, if it's ever "just as easy".
*
* Determinism contract:
* same seed same roster (positions, types, names), same contents,
* in any order of generation. Dev/test tools rely on this.
*
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
* - data/galaxy.json `distribution.rules[]` proximity/clustering rules
* (e.g. "void systems cluster in the outer rim", faction borders).
* Read in _generate(); today only per-type weight + radiusBand
* (from data/systems.json) apply.
* - more layout knobs in data/galaxy.json `layout`.
*/
export class Galaxy {
constructor(seed, params, typeDefs) {
this.seed = seed;
this.params = params;
this.typeDefs = typeDefs;
this.records = [];
this.byId = new Map();
this.contentCache = new Map();
this.currentSystemId = null;
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
}
/**
* Build a galaxy from a seed (trimmed; ' abc ' and 'abc' are the same
* galaxy). `overrides` shallow-merges over data/galaxy.json used by
* dev tools (e.g. a 300-system galaxy for brute-force tests).
*/
static create(seed, overrides = {}) {
if (seed === undefined || seed === null || String(seed).trim().length === 0) {
throw new Error('Galaxy.create() needs a non-empty seed');
}
const params = { ...config.section('galaxy', {}), ...overrides };
const typeDefs = config.get('systems.types', {});
if (Object.keys(typeDefs).length === 0) {
throw new Error('No system types found — is data/systems.json listed in data/manifest.json?');
}
const count = Math.floor(params.systemCount ?? 40000);
if (!(count >= 1)) {
throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`);
}
const galaxy = new Galaxy(String(seed).trim(), params, typeDefs);
galaxy._generate(count);
return galaxy;
}
// ------------------------------------------------------------------
// Roster generation (level 1)
// ------------------------------------------------------------------
_generate(count) {
const g = Rng.derive(this.seed, 'layout');
const L = this.params.layout ?? {};
const R = Math.max(1, this.params.radius ?? 20000);
const flatten = clamp(L.flatten ?? 0.62, 0.05, 1);
// Spiral arms (optional; `enabled: false` or strength 0 = off).
const spiral = L.spiral ?? {};
const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0;
const twist = spiral.twist ?? 2.5;
const strength = clamp(spiral.strength ?? 0.5, 0, 1);
const armPhase = g.next() * TAU; // one phase for the whole galaxy
// Type selection: weights from data/systems.json (distribution.weight).
// FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in
// here, before position sampling.
const typeIds = Object.keys(this.typeDefs);
const typeWeights = {};
for (const id of typeIds) {
typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1);
}
let centerId = null;
let centerD2 = Infinity;
const records = this.records;
for (let i = 1; i <= count; i++) {
const id = `S${String(i).padStart(6, '0')}`;
const type = g.weighted(typeWeights, typeIds[0]);
// Radius: a center-weighted shape sample, re-anchored into the
// type's radial band (the first "proximity" rule: e.g. void systems
// live out in the rim, habitable ones in the mid-galaxy).
const shape = this._sampleShape(g, L); // [0,1], dense toward center
const band = this.typeDefs[type]?.distribution?.radiusBand;
const rNorm = Array.isArray(band) && band.length === 2
? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1)
: shape;
let theta = g.next() * TAU;
if (arms >= 2) {
theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength);
}
const r = R * rNorm;
const x = r * Math.cos(theta);
const y = r * Math.sin(theta) * flatten;
const d2 = x * x + y * y;
if (d2 < centerD2) {
centerD2 = d2;
centerId = id;
}
// Name comes from a per-record fork so roster generation order can
// never leak into it.
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
const rec = { id, name, type, x, y };
records.push(rec);
this.byId.set(id, rec);
}
// Starting system (the player's home port).
const policy = this.params.startingSystem?.policy ?? 'center';
this.currentSystemId =
policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id);
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
// the eventual star map).
const area = Math.PI * R * R * flatten;
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
this.grid = new Map();
for (const rec of records) {
const key = `${Math.floor(rec.x / this.cellSize)},${Math.floor(rec.y / this.cellSize)}`;
let cell = this.grid.get(key);
if (!cell) {
cell = [];
this.grid.set(key, cell);
}
cell.push(rec);
}
}
/** Center-weighted radius sample in [0,1]: core bulge + disk. */
_sampleShape(g, L) {
if (g.chance(L.coreFraction ?? 0.25)) {
const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09);
return Math.min(1, Math.abs(g.normal(0, sigma)));
}
return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7)));
}
/** Ease `theta` toward the nearest spiral arm (by `strength`). */
_snapToArm(theta, rNorm, armPhase, arms, twist, strength) {
if (strength <= 0) return theta;
const step = TAU / arms;
const base = armPhase + twist * rNorm;
const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step);
let bestD = Infinity;
let bestA = base + k * step;
for (const cand of [k - 1, k, k + 1]) {
const a = base + cand * step;
const d = Math.abs(wrapPI(a - theta));
if (d < bestD) {
bestD = d;
bestA = a;
}
}
return theta + wrapPI(bestA - theta) * strength;
}
// ------------------------------------------------------------------
// Queries
// ------------------------------------------------------------------
/**
* The k nearest systems to a world-space point (k=1 by default).
* Ring-expands the spatial hash; exact as long as k systems exist.
*/
nearest(x, y, k = 1) {
const c = this.cellSize;
const cx = Math.floor(x / c);
const cy = Math.floor(y / c);
const best = [];
const consider = (rec) => {
const d2 = (rec.x - x) ** 2 + (rec.y - y) ** 2;
best.push({ d2, record: rec });
best.sort((a, b) => a.d2 - b.d2);
if (best.length > k) best.length = k;
};
const maxRing = Math.min(1024, Math.ceil((4 * this.params.radius) / c) + 1);
for (let ring = 0; ring <= maxRing; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
const cell = this.grid.get(`${cx + dx},${cy + dy}`);
if (cell) for (const rec of cell) consider(rec);
}
}
// Everything left unscanned is at least ring·c away; if the kth
// best is already closer, the answer is final.
if (best.length >= k) {
const bound = ring * c;
if (best[k - 1].d2 <= bound * bound) break;
}
}
return best.slice(0, k).map((e) => e.record);
}
/** The k nearest OTHER systems to a system (jump-range candidate list). */
neighborsOf(id, k = null) {
const rec = this.byId.get(id);
if (!rec) throw new Error(`Unknown system "${id}"`);
const need = k ?? Math.floor(this.params.neighbors ?? 8);
const out = [];
for (const cand of this.nearest(rec.x, rec.y, need + 1)) {
if (cand.id !== id) out.push(cand);
if (out.length >= need) break;
}
return out;
}
/** @returns {object} the player's current (starting) system record */
currentSystem() {
return this.byId.get(this.currentSystemId) ?? this.records[0];
}
// ------------------------------------------------------------------
// Contents (level 2, lazy)
// ------------------------------------------------------------------
/**
* Generate (once) and return a system's full contents. Safe to call from
* "the player arrived here" the result is identical to what an
* up-front generateAll() would have produced.
*/
ensureContent(id) {
const cached = this.contentCache.get(id);
if (cached) return cached;
const record = this.byId.get(id);
if (!record) throw new Error(`Unknown system "${id}"`);
const content = generateSystemContent(this, record);
this.contentCache.set(id, content);
return content;
}
/** Alias of ensureContent() — reads nicer at call sites. */
contentOf(id) {
return this.ensureContent(id);
}
/**
* Eager mode: generate every system now. Deterministically identical to
* lazy generation (per-system seeded streams) use it if profiling ever
* shows "just do it all at once" is fine.
*/
generateAll() {
for (const rec of this.records) this.ensureContent(rec.id);
return this;
}
get generatedCount() {
return this.contentCache.size;
}
// ------------------------------------------------------------------
/** Debug/console summary. */
summary() {
const byType = {};
for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1;
return {
name: this.name,
seed: this.seed,
systems: this.records.length,
byType,
generated: this.generatedCount,
currentSystemId: this.currentSystemId,
};
}
}

View File

@ -0,0 +1,88 @@
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator, ordinalLabel } from '../utils/NameGenerator.js';
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/**
* Turns a lightweight galaxy record (id, name, type, x, y) into a fully
* generated system: star, planets, moons, debris belt, hazard flag.
*
* Deterministic contract:
* same galaxy seed + same record id identical content, every time.
* The draw stream is derived from (seed, 'system', id) NOT from the
* galaxy-level sequence so a system generated when the player arrives
* is byte-for-byte identical to one generated during an up-front
* generateAll(). That's what makes lazy generation safe.
*
* What the system actually contains is steered by the TYPE'S ATTRIBUTES
* in data/systems.json (`types.<id>.attributes`): star classes, binary
* chance, planet count spread, planet class weights, moon/belt chances,
* habitability, hazard. Theme (`types.<id>.theme`) is for UI. Add new
* attribute keys here + in the JSON and types become richer without
* touching the galaxy generator.
*/
export function generateSystemContent(galaxy, record, typeDefs = null) {
const defs = typeDefs ?? config.get('systems.types', {});
const type = defs[record.type] ?? { label: record.type, attributes: {} };
const attr = type.attributes ?? {};
const rng = Rng.derive(galaxy.seed, 'system', record.id);
// --- Star -------------------------------------------------------------
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
const massTable = attr.star?.mass ?? {};
const starClass = rng.weighted(starClasses, 'M');
const mass = Array.isArray(massTable[starClass]) ? massTable[starClass] : [0.3, 1.2];
const star = {
name: NameGenerator.star(rng),
class: starClass,
mass: Number(rng.range(mass[0], mass[1]).toFixed(2)),
binary: false,
};
if (rng.chance(attr.binaryChance ?? 0.05)) {
star.binary = true;
star.secondary = {
name: NameGenerator.star(rng),
class: rng.weighted(starClasses, starClass),
};
}
// --- Planets ----------------------------------------------------------
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 };
const count = Math.round(
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
);
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
const planets = [];
for (let i = 1; i <= count; i++) {
const pclass = rng.weighted(classWeights, 'rocky');
let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
}
planets.push({
name: `${NameGenerator.planetRoot(rng)} ${ordinalLabel(i)}`,
ordinal: i,
class: pclass,
moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
});
}
// --- Debris belt & system-level hazard --------------------------------
const belt = {
present: rng.chance(attr.beltChance ?? 0.35),
kind: rng.pick(['asteroid', 'debris']) ?? 'asteroid',
};
const hazard = rng.chance(attr.hazard ?? 0.1);
return {
name: record.name,
type: record.type,
star,
planets,
belt,
hazard,
};
}

View File

@ -1,13 +1,15 @@
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { Starfield } from '../visuals/Starfield.js'; import { Starfield } from '../visuals/Starfield.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/** /**
* The game world (v0.1: one ship in open space). * The game world (v0.2: one ship in the current system's open space).
* Click anywhere to fly there. * Click anywhere to fly there.
*/ */
export class GameScene extends Phaser.Scene { export class GameScene extends Phaser.Scene {
@ -25,6 +27,9 @@ export class GameScene extends Phaser.Scene {
this.ship = new Ship(this, 0, 0); this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10); this.ship.setDepth(10);
// We are, after all, in a system. Show the player which one.
this.createSystemHud();
// Center the camera on the ship from the very first frame. // Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(-this.scale.width / 2, -this.scale.height / 2); this.cameras.main.setScroll(-this.scale.width / 2, -this.scale.height / 2);
@ -50,6 +55,55 @@ export class GameScene extends Phaser.Scene {
}); });
} }
/**
* Top-left HUD: the current system's name and identity.
*
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
* fresh dev galaxy so the scene always works standalone.
*
* The system's CONTENTS are generated here, on arrival the first touch
* of the lazy level-2 generation. (Roster/positions were fixed at the
* menu's New Game click; this is just the "build the room" part.)
*/
createSystemHud() {
this.galaxy = this.registry.get('galaxy') ?? null;
if (!this.galaxy) {
const seed = Rng.randomSeedString(8);
this.galaxy = Galaxy.create(seed);
this.registry.set('galaxy', this.galaxy);
this.registry.set('seed', seed);
console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
}
const current = this.galaxy.currentSystem();
const content = this.galaxy.ensureContent(current.id);
const typeDef = this.galaxy.typeDefs?.[current.type] ?? {};
const fam = FONT_FALLBACK;
this.add
.text(16, 14, content.name, {
fontFamily: fam,
fontSize: '17px',
fontStyle: 'bold',
color: toColor(typeDef.theme?.color ?? '#9fb4e8'),
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
const label = typeDef.label ?? current.type;
this.add
.text(16, 38, `${label} \u00b7 star ${content.star.class} \u00b7 ${content.planets.length} planets \u00b7 seed ${this.galaxy.seed}`, {
fontFamily: fam,
fontSize: '12px',
color: '#8fa0c9',
})
.setOrigin(0, 0)
.setScrollFactor(0)
.setDepth(30);
}
update(_time, delta) { update(_time, delta) {
this.ship.update(_time, delta); this.ship.update(_time, delta);
this.updateCamera(delta); this.updateCamera(delta);

View File

@ -1,11 +1,27 @@
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { MenuButton } from '../ui/MenuButton.js'; import { MenuButton } from '../ui/MenuButton.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { Galaxy } from '../galaxy/Galaxy.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const MONO = "'Cascadia Mono', 'Consolas', 'Menlo', monospace";
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
const SEED_MAX = 32;
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
/** /**
* Main menu. All text/colors/layout come from data/menu.json. * Main menu. All text/colors/layout come from data/menu.json.
*
* The Galaxy Seed panel is where a new universe is chosen:
* - the seed is displayed, editable (click it, type; Backspace; Enter to
* commit), and rerollable;
* - the same seed always builds the same galaxy (Galaxy.create), and the
* panel shows what that seed contains before you commit;
* - "New Game" builds the galaxy roster from the seed and hands it to the
* rest of the game via the shared registry.
*/ */
export class MenuScene extends Phaser.Scene { export class MenuScene extends Phaser.Scene {
constructor() { constructor() {
@ -44,17 +60,40 @@ export class MenuScene extends Phaser.Scene {
}) })
.setOrigin(0.5); .setOrigin(0.5);
// Buttons (menu.json lists each one; add more here as the menu grows) // New Game
const btn = menu.buttons?.newGame ?? {}; const btn = menu.buttons?.newGame ?? {};
new MenuButton( new MenuButton(
this, this,
(btn.position?.x ?? 0.5) * width, (btn.position?.x ?? 0.5) * width,
(btn.position?.y ?? 0.62) * height, (btn.position?.y ?? 0.60) * height,
btn.label ?? 'New Game', btn.label ?? 'New Game',
() => this.scene.start('GameScene'), () => this.startNewGame(),
btn, btn,
); );
// Galaxy seed panel
this.createSeedPanel(menu, fontFamily, cx, height);
// Global input: typing goes to the seed field; a click elsewhere blurs it.
this.input.keyboard.on('keydown', (e) => this.onSeedKey(e));
this.input.on('pointerdown', (pointer) => {
if (this.seedFieldHit && pointer.over(this.seedFieldHit)) {
this.setSeedFocus(true);
} else if (this.seedFocused) {
this.setSeedFocus(false);
}
});
this.cursorTimer = this.time.addEvent({
delay: 430,
loop: true,
callback: () => {
if (this.seedFocused) {
this.cursorOn = !this.cursorOn;
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
}
},
});
// Version footer // Version footer
this.add this.add
.text(width - 16, height - 14, config.get('game.version', ''), { .text(width - 16, height - 14, config.get('game.version', ''), {
@ -64,4 +103,181 @@ export class MenuScene extends Phaser.Scene {
}) })
.setOrigin(1, 0.5); .setOrigin(1, 0.5);
} }
// ------------------------------------------------------------------
// Galaxy seed panel
// ------------------------------------------------------------------
createSeedPanel(menu, fontFamily, cx, height) {
const cfg = menu.seed ?? {};
const colors = cfg.colors ?? {};
const fieldW = cfg.fieldWidth ?? 300;
const fieldH = cfg.fieldHeight ?? 46;
const cy = (cfg.position?.y ?? 0.755) * height;
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
this.seedFocused = false;
this.cursorOn = true;
// Label
this.add
.text(cx, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
fontFamily,
fontSize: `${cfg.labelFontSize ?? 12}px`,
color: colors.label ?? '#54608a',
letterSpacing: 3,
})
.setOrigin(0.5, 1);
// Field (click to edit)
this.seedFieldHit = this.add
.rectangle(cx, cy, fieldW, fieldH, toColor(colors.fieldBg ?? '#0b1226'), 1)
.setStrokeStyle(1, toColor(colors.border ?? '#2a3a63'), 0.9);
this.seedFieldHit.setInteractive({ useHandCursor: true });
this.seedFieldHit.on('pointerdown', () => this.setSeedFocus(true));
this.seedText = this.add
.text(cx - fieldW / 2 + 14, cy, '', {
fontFamily: MONO,
fontSize: `${cfg.fontSize ?? 24}px`,
color: colors.value ?? '#e9edf8',
})
.setOrigin(0, 0.5);
// Reroll
new MenuButton(
this,
cx + fieldW / 2 + 26,
cy,
cfg.rerollLabel ?? 'reroll',
() => {
this.seedValue = Rng.randomSeedString(8);
this.setSeedFocus(false);
},
{
fontSize: cfg.rerollFontSize ?? 14,
paddingX: 18,
paddingY: 10,
},
);
// Hint: what this seed will build (galaxy name + scale), live-updated.
this.seedHint = this.add
.text(cx, cy + fieldH / 2 + 12, '', {
fontFamily,
fontSize: '12px',
color: colors.hint ?? '#54608a',
})
.setOrigin(0.5, 0);
this.drawSeed();
}
drawSeed() {
if (!this.seedText || this.seedDead) return;
this.seedText.setText(this.seedValue + (this.seedFocused ? (this.cursorOn ? ' \u258c' : ' ') : ''));
const cfg = config.get('menu.seed.colors', {});
const border = this.seedFocused
? toColor(cfg.activeBorder ?? '#41c7ff')
: toColor(cfg.border ?? '#2a3a63');
this.seedFieldHit.setStrokeStyle(1, border, this.seedFocused ? 1 : 0.9);
const count = config.get('galaxy.systemCount', 0);
const archetypes = Object.keys(config.get('systems.types', {})).length;
let hint = `${Number(count).toLocaleString('en-US')} systems \u00b7 ${archetypes} archetypes \u00b7 same seed \u2192 same galaxy`;
if (this.seedValue.trim()) {
const gname = NameGenerator.galaxy(Rng.derive(this.seedValue.trim(), 'galaxy', 'name'));
hint = `the galaxy of ${gname} \u00b7 ${hint}`;
}
this.seedHint.setText(hint);
}
setSeedFocus(focused) {
this.seedFocused = !!focused;
this.cursorOn = true;
this.drawSeed();
}
onSeedKey(e) {
const key =
e && typeof e.key === 'string' && e.key.length > 0
? e.key
: e?.keyCode === 13 ? 'Enter'
: e?.keyCode === 8 ? 'Backspace'
: '';
if (key === 'Enter' || key === 'Escape') {
this.setSeedFocus(false);
return;
}
if (key === 'Backspace') {
if (this.seedFocused) {
this.seedValue = this.seedValue.slice(0, -1);
this.drawSeed();
}
return;
}
if (SEED_CHAR.test(key)) {
this.setSeedFocus(true);
if (this.seedValue.length < SEED_MAX) {
this.seedValue += key;
this.drawSeed();
}
}
}
// ------------------------------------------------------------------
startNewGame() {
let seed = this.seedValue.trim();
if (!seed) {
seed = Rng.randomSeedString(8);
this.seedValue = seed;
this.drawSeed();
}
try {
this.setSeedFocus(false);
const galaxy = Galaxy.create(seed);
this.registry.set('galaxy', galaxy);
this.registry.set('seed', seed);
this.saveSeed(seed);
console.info(`orbit \u2014 the galaxy of ${galaxy.name} (seed ${seed})`, galaxy.summary());
this.scene.start('GameScene');
} catch (err) {
console.error(err);
const msg = this.add
.text(this.scale.width / 2, this.scale.height - 64, `could not build the galaxy: ${err.message}`, {
fontFamily: FONT_FALLBACK,
fontSize: '14px',
color: '#ff9b9b',
})
.setOrigin(0.5);
this.time.delayedCall(4000, () => msg.destroy());
}
}
/** Guard for post-shutdown input events (e.g. a click that also fired
* "New Game" on the same frame). */
shutdown() {
this.seedDead = true;
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
}
loadSavedSeed() {
try {
const v = globalThis.localStorage?.getItem?.(SEED_STORAGE_KEY);
return typeof v === 'string' && v.trim() ? v.trim() : null;
} catch {
return null;
}
}
saveSeed(seed) {
try {
globalThis.localStorage?.setItem?.(SEED_STORAGE_KEY, seed);
} catch {
/* private mode / no storage — fine */
}
}
} }

73
js/utils/NameGenerator.js Normal file
View File

@ -0,0 +1,73 @@
import { config } from '../config/Config.js';
/**
* Deterministic name synthesis. Everything is drawn from the caller's
* Rng (so names are part of the seed), with syllable pools coming from
* data/naming.json. Code-level fallbacks keep this working even if the
* file is missing.
*
* const rng = Rng.derive(seed, 'name', 'S000123');
* const name = NameGenerator.star(rng); // "Kethavoryn"
*/
const FALLBACK = {
star: {
syllables: ['ka', 'vel', 'thu', 'ori', 'an', 'esh', 'mar', 'dy', 'neth', 'avi', 'cor', 'lu', 'tan', 'ys', 'brei', 'hal', 'ion'],
minParts: 2,
maxParts: 4,
},
planet: {
roots: ['aurel', 'bryn', 'cald', 'dross', 'emrys', 'fen', 'gale', 'hyra', 'ilv', 'jor', 'kest', 'lun', 'mora', 'neth', 'orb', 'pyre', 'quill', 'ross', 'sable', 'tarn', 'ulric', 'vex', 'wren', 'xan', 'yrra', 'zeph'],
},
galaxy: {
syllables: ['an', 'dro', 'me', 'mil', 'ky', 'cen', 'tau', 'rhi', 'sa', 'vel', 'pyr', 'os'],
minParts: 3,
maxParts: 5,
},
};
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX'];
function section(name, fallback) {
const v = config.get(`naming.${name}`);
return (v && typeof v === 'object') ? v : fallback;
}
function capitalize(s) {
return s.length ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function joinSyllables(rng, syllables, minParts, maxParts) {
const parts = rng.int(minParts, maxParts);
let out = '';
for (let i = 0; i < parts; i++) out += rng.pick(syllables) ?? 'a';
return capitalize(out);
}
/** "1" → "I" … "20" → "XX"; beyond that, plain digits. */
export function ordinalLabel(n) {
return ROMAN[n - 1] ?? String(n);
}
export const NameGenerator = {
/** A star / system name: "Kethavoryn". */
star(rng) {
const s = section('star', FALLBACK.star);
const syllables = Array.isArray(s.syllables) && s.syllables.length ? s.syllables : FALLBACK.star.syllables;
return joinSyllables(rng, syllables, s.minParts ?? 2, s.maxParts ?? 4);
},
/** A planet root word: "Aurel" (compose the ordinal yourself). */
planetRoot(rng) {
const p = section('planet', FALLBACK.planet);
const roots = Array.isArray(p.roots) && p.roots.length ? p.roots : FALLBACK.planet.roots;
return capitalize(rng.pick(roots) ?? 'Novus');
},
/** A galaxy name: "Vethani". */
galaxy(rng) {
const g = section('galaxy', FALLBACK.galaxy);
const syllables = Array.isArray(g.syllables) && g.syllables.length ? g.syllables : FALLBACK.galaxy.syllables;
return joinSyllables(rng, syllables, g.minParts ?? 3, g.maxParts ?? 5);
},
};

171
js/utils/Rng.js Normal file
View File

@ -0,0 +1,171 @@
/**
* Deterministic, seedable PRNG for procedural generation.
*
* Rules of the road (see docs/PROJECT_NOTES.md "World model"):
* - same seed + same call sequence same results, always;
* - every derived stream is LABELLED (e.g. 'system:S000123'), so
* generating system A before or after system B never changes what
* either one draws. That's the property that lets us generate the
* whole galaxy's *roster* up front but defer each system's actual
* contents until the player arrives lazy and eager generation
* produce identical results.
*
* Pure ES module, no Phaser runs in the browser and in Node
* (dev/*.test.mjs).
*/
// xmur3 — 32-bit string hash (the "mur" family).
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return () => {
h = (h ^ (h >>> 16)) >>> 0;
h = Math.imul(h, 2246822507);
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 3266489909);
return (h ^ (h >>> 16)) >>> 0;
};
}
function hashSeed(seed) {
return xmur3(String(seed))();
}
// mulberry32 — tiny, fast 32-bit generator. Plenty for worldgen;
// not for anything cryptographic.
function mulberry32(state) {
let a = state >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export class Rng {
/** @param {string|number} seed — anything; it's hashed to a 32-bit state */
constructor(seed) {
if (seed === undefined || seed === null || String(seed).length === 0) {
throw new Error('Rng requires a non-empty seed');
}
this.seed = String(seed);
this._gen = mulberry32(hashSeed(this.seed));
this._spareNormal = null;
}
/** @returns {number} uniform float in [0, 1) */
next() {
return this._gen();
}
/** @returns {number} uniform float in [min, max) */
range(min, max) {
return min + this.next() * (max - min);
}
/** @returns {number} uniform integer in [min, max], inclusive */
int(min, max) {
return Math.floor(this.next() * (max - min + 1)) + min;
}
/** @returns {boolean} true with probability p */
chance(p) {
return this.next() < Math.min(1, Math.max(0, p ?? 0));
}
/** @returns {*} a random element of the array (undefined if empty) */
pick(arr) {
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr[Math.floor(this.next() * arr.length)];
}
/**
* Weighted pick. Entries: { a: 30, b: 70 } or [[ 'a', 30 ], [ 'b', 70 ]].
* @returns {*} a key, or `fallback` if every weight is 0
*/
weighted(entries, fallback = undefined) {
const pairs = Array.isArray(entries) ? entries : Object.entries(entries);
if (pairs.length === 0) return fallback;
const total = pairs.reduce((sum, [, w]) => sum + (w > 0 ? w : 0), 0);
if (total <= 0) return fallback;
let u = this.next() * total;
for (const [key, w] of pairs) {
if (w <= 0) continue;
if (u < w) return key;
u -= w;
}
return pairs[pairs.length - 1][0];
}
/** FisherYates. Returns a NEW array; the input is untouched. */
shuffle(arr) {
const out = Array.from(arr);
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(this.next() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
/** Standard normal (BoxMuller, with a cached spare), scaled to mean/sd. */
normal(mean = 0, sd = 1) {
if (this._spareNormal !== null) {
const spare = this._spareNormal;
this._spareNormal = null;
return mean + sd * spare;
}
let u = 0, v = 0, s = 0;
do {
u = 2 * this.next() - 1;
v = 2 * this.next() - 1;
s = u * u + v * v;
} while (s >= 1 || s === 0);
const m = Math.sqrt((-2 * Math.log(s)) / s);
this._spareNormal = v * m;
return mean + sd * (u * m);
}
/**
* A child stream derived from THIS seed plus labels. Independent of the
* parent's state: drawing from the parent afterwards does not change it,
* and two forks with the same labels always agree.
*/
fork(...labels) {
return Rng.derive(this.seed, ...labels);
}
/**
* Derive an independent stream from a seed + purpose labels, e.g.
* Rng.derive(galaxySeed, 'system', 'S000123')
* Same arguments same stream, always.
*/
static derive(seed, ...labels) {
const key = [seed, ...labels].map((x) => String(x)).join('\u0000');
return new Rng(key);
}
/**
* A fresh, NON-deterministic seed string (for "roll a new galaxy").
* Deliberately NOT one of the PRNG streams this is the human's
* free will picking a point in seed space.
*/
static randomSeedString(len = 8) {
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
let out = '';
const cryptoObj = globalThis.crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
const buf = new Uint32Array(len);
cryptoObj.getRandomValues(buf);
for (let i = 0; i < len; i++) out += alphabet[buf[i] % alphabet.length];
} else {
for (let i = 0; i < len; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
}
return out;
}
}