diff --git a/README.md b/README.md index 5226433..269aab5 100644 --- a/README.md +++ b/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), diff --git a/data/galaxy.json b/data/galaxy.json new file mode 100644 index 0000000..058d43b --- /dev/null +++ b/data/galaxy.json @@ -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 +} diff --git a/data/game.json b/data/game.json index 2b22928..3716e9c 100644 --- a/data/game.json +++ b/data/game.json @@ -1,6 +1,6 @@ { "name": "Orbit", - "version": "0.1.0", + "version": "0.2.0", "width": 1280, "height": 720, "backgroundColor": "#04060d", diff --git a/data/manifest.json b/data/manifest.json index 80d3cef..2162fc4 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -2,6 +2,9 @@ "files": [ "game.json", "menu.json", - "ship.json" + "ship.json", + "galaxy.json", + "systems.json", + "naming.json" ] } diff --git a/data/menu.json b/data/menu.json index 9c00378..6ee0687 100644 --- a/data/menu.json +++ b/data/menu.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" + } } } diff --git a/data/naming.json b/data/naming.json new file mode 100644 index 0000000..d905263 --- /dev/null +++ b/data/naming.json @@ -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 + } +} diff --git a/data/systems.json b/data/systems.json new file mode 100644 index 0000000..804cf93 --- /dev/null +++ b/data/systems.json @@ -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 + } + } + } +} diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs new file mode 100644 index 0000000..ba1541a --- /dev/null +++ b/dev/galaxy.test.mjs @@ -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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 8b8dd41..053bf56 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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, , 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) diff --git a/js/config/Config.js b/js/config/Config.js index 4a71851..5126ab1 100644 --- a/js/config/Config.js +++ b/js/config/Config.js @@ -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; } diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js new file mode 100644 index 0000000..0e7f093 --- /dev/null +++ b/js/galaxy/Galaxy.js @@ -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, + }; + } +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js new file mode 100644 index 0000000..af71be2 --- /dev/null +++ b/js/galaxy/SystemGenerator.js @@ -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..attributes`): star classes, binary + * chance, planet count spread, planet class weights, moon/belt chances, + * habitability, hazard. Theme (`types..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, + }; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 1b6850c..304f198 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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); diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index d87b9e7..1e800b6 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -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 */ + } + } } diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js new file mode 100644 index 0000000..e92e826 --- /dev/null +++ b/js/utils/NameGenerator.js @@ -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); + }, +}; diff --git a/js/utils/Rng.js b/js/utils/Rng.js new file mode 100644 index 0000000..de6ffb8 --- /dev/null +++ b/js/utils/Rng.js @@ -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; + } +}