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:
parent
7cf96bb237
commit
eafc3c4314
36
README.md
36
README.md
|
|
@ -17,13 +17,26 @@ python3 -m http.server 8080
|
|||
> 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.1 foundation
|
||||
## Current state — v0.2: a seedable galaxy
|
||||
|
||||
- Main menu with a **New Game** button
|
||||
- Game screen with a basic top-down ship: **click anywhere to fly there** in
|
||||
infinite, unbounded space (borders/sectors come later)
|
||||
- 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
|
||||
- Main menu with **New Game** 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**: 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`
|
||||
|
||||
## Project layout
|
||||
|
|
@ -34,17 +47,21 @@ orbit/
|
|||
├── 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
|
||||
│ └── ship.json # ship feel: thrust, drag, maxSpeed, …
|
||||
│ ├── menu.json # menu text, colors, button layout, seed panel
|
||||
│ ├── 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)
|
||||
├── js/
|
||||
│ ├── main.js # entry point: load config → boot Phaser
|
||||
│ ├── config/ # Config singleton, ConfigLoader, game config
|
||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||
│ ├── entities/ # Ship (own behavior)
|
||||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator
|
||||
│ ├── ui/ # MenuButton (reusable)
|
||||
│ ├── visuals/ # Starfield (decorative)
|
||||
│ ├── utils/ # small helpers (Color)
|
||||
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
||||
│ └── vendor/ # shim to the vendored Phaser
|
||||
└── docs/PROJECT_NOTES.md # ← project conventions: read this
|
||||
```
|
||||
|
|
@ -62,6 +79,7 @@ orbit/
|
|||
```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, distribution, lazy vs eager
|
||||
```
|
||||
|
||||
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Orbit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"backgroundColor": "#04060d",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
"files": [
|
||||
"game.json",
|
||||
"menu.json",
|
||||
"ship.json"
|
||||
"ship.json",
|
||||
"galaxy.json",
|
||||
"systems.json",
|
||||
"naming.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,26 @@
|
|||
"buttons": {
|
||||
"newGame": {
|
||||
"label": "New Game",
|
||||
"position": { "x": 0.5, "y": 0.62 },
|
||||
"position": { "x": 0.5, "y": 0.60 },
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns
|
||||
*parameters*.
|
||||
- Split config by concern as the game grows: `data/sectors.json` for world
|
||||
generation, `data/economy.json`, `data/ships.json`, `data/crew.json`, etc.
|
||||
One file per system beats one giant file.
|
||||
- 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` (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)
|
||||
|
||||
|
|
@ -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/ui/` — reusable UI components (MenuButton, panels, HUD)
|
||||
- `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/vendor/` — shims to pinned third-party libraries
|
||||
- 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
|
||||
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
|
||||
|
||||
- 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)
|
||||
|
||||
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
||||
- [ ] Decide the world model: **infinite open space for now** (ship flies
|
||||
unbounded; borders/sectors planned for later) — affects camera,
|
||||
starfield, and world gen
|
||||
- [ ] Procedural star map / sector generation (driven by `data/sectors.json`)
|
||||
- [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 (40k systems) + lazy,
|
||||
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)
|
||||
- [ ] HUD (speed, sector name, later: fuel/crew)
|
||||
- [ ] Save/load (the `config` + entity split should make this tractable)
|
||||
- [ ] HUD (speed, sector name, later: fuel/crew) — system name/identity
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,12 @@ class Config {
|
|||
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 = {}) {
|
||||
const value = this.data[name];
|
||||
const value = this.get(name, undefined);
|
||||
return (value && typeof value === 'object') ? value : fallback;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.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 { Starfield } from '../visuals/Starfield.js';
|
||||
|
||||
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.
|
||||
*/
|
||||
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.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.
|
||||
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) {
|
||||
this.ship.update(_time, delta);
|
||||
this.updateCamera(delta);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor } from '../utils/Color.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 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.
|
||||
*
|
||||
* 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 {
|
||||
constructor() {
|
||||
|
|
@ -44,17 +60,40 @@ export class MenuScene extends Phaser.Scene {
|
|||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Buttons (menu.json lists each one; add more here as the menu grows)
|
||||
// New Game
|
||||
const btn = menu.buttons?.newGame ?? {};
|
||||
new MenuButton(
|
||||
this,
|
||||
(btn.position?.x ?? 0.5) * width,
|
||||
(btn.position?.y ?? 0.62) * height,
|
||||
(btn.position?.y ?? 0.60) * height,
|
||||
btn.label ?? 'New Game',
|
||||
() => this.scene.start('GameScene'),
|
||||
() => this.startNewGame(),
|
||||
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
|
||||
this.add
|
||||
.text(width - 16, height - 14, config.get('game.version', ''), {
|
||||
|
|
@ -64,4 +103,181 @@ export class MenuScene extends Phaser.Scene {
|
|||
})
|
||||
.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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
||||
/** Fisher–Yates. 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 (Box–Muller, 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue