diff --git a/assets/images/asteroids.png b/assets/images/asteroids.png new file mode 100644 index 0000000..c228c48 Binary files /dev/null and b/assets/images/asteroids.png differ diff --git a/assets/images/asteroids.psd b/assets/images/asteroids.psd new file mode 100644 index 0000000..71e57bb Binary files /dev/null and b/assets/images/asteroids.psd differ diff --git a/assets/videos/terran-takeoff-01.mp4 b/assets/videos/terran-takeoff-01.mp4 index b9b6ae6..8dfc228 100644 Binary files a/assets/videos/terran-takeoff-01.mp4 and b/assets/videos/terran-takeoff-01.mp4 differ diff --git a/data/asteroids.json b/data/asteroids.json new file mode 100644 index 0000000..1718050 --- /dev/null +++ b/data/asteroids.json @@ -0,0 +1,51 @@ +{ + "_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects − planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.", + "enabled": true, + "texture": "assets/images/asteroids.png", + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 10, + "typeLabel": "Asteroid Cluster", + "shipClearance": 50, + "cluster": { + "groupSize": { "min": 4, "max": 8 }, + "sizes": { "min": 64, "max": 128 }, + "minFullSize": 1, + "spread": 140, + "spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 }, + "groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 }, + "tint": { + "enabled": true, + "anchors": ["#dfe9ff", "#ffe9d6", "#e8e4f8"], + "strength": 0.5 + }, + "halo": { + "enabled": true, + "scale": 2.8, + "alpha": 0.26, + "color": "#7fa8d8" + }, + "debris": { + "enabled": true, + "count": [18, 36], + "inner": 0.9, + "outer": 2.4, + "size": [2.5, 6], + "alpha": [0.4, 0.85] + } + }, + "distribution": { + "targetObjects": 9, + "jitter": 1, + "minClusters": 1, + "maxClusters": 6, + "startingSystemMinClusters": 2 + }, + "placement": { + "minObjectSpacing": 1024, + "minRadius": 2048, + "maxRadius": 18432, + "tetherMargin": 96, + "maxPlacementAttempts": 400 + } +} diff --git a/data/manifest.json b/data/manifest.json index 3913ca5..8723228 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -5,6 +5,7 @@ "menu.json", "ship.json", "planets.json", + "asteroids.json", "tether.json", "galaxy.json", "systems.json", diff --git a/data/naming.json b/data/naming.json index ccfa384..23d6147 100644 --- a/data/naming.json +++ b/data/naming.json @@ -10,6 +10,13 @@ "minParts": 3, "maxParts": 5 }, + "asteroid": { + "_comment": "Asteroid cluster names: a synthesised syllable name + a field-like suffix (\"Kaveru Field\", \"Nixdor Drift\"). Unbounded, like stars. Drawn per-system (dedicated stream), never repeating another name in the same system.", + "syllables": ["ka", "ver", "dor", "thra", "nix", "oru", "mal", "cra", "zeth", "ba", "lu", "tan", "gra", "vel", "kor", "ash", "pyr", "dra", "sol", "rin", "eth", "qua"], + "minParts": 2, + "maxParts": 3, + "suffixes": ["Field", "Drift", "Patch", "Reef", "Shoals", "Belt", "Reach", "Wash"] + }, "banks": { "_comment": "planet.colonial = familiar, Earth-flavored names (colonial 'New ' + American place-names). planet.alien = unfamiliar, exotic names. station.procedural = official designations. station.smuggler = underworld hangouts. A system's worlds/stations are drawn from the COMBINED pool for each category (colonial+alien, procedural+smuggler) without replacement.", "planet": { diff --git a/dev/asteroids.test.mjs b/dev/asteroids.test.mjs new file mode 100644 index 0000000..69a32af --- /dev/null +++ b/dev/asteroids.test.mjs @@ -0,0 +1,280 @@ +/** + * Asteroid cluster test (dev tool, run with Node — no browser needed): + * + * node dev/asteroids.test.mjs + * + * Asserts the whole data/asteroids.json contract, generated by the real + * SystemGenerator across a large seeded sample of the galaxy: + * - COUNT vs PLANETS: clusterCount ≈ targetObjects − planetCount (± + * jitter, clamped to [minClusters, maxClusters]) — so systems with + * more planets get fewer clusters and vice versa (also checked as an + * aggregate correlation across the sample); + * - the STARTING system always gets ≥ startingSystemMinClusters, and + * those first ones sit INSIDE the initial tether (whole cluster, + * minus placement.tetherMargin); + * - GROUP SHAPE: 4–8 rocks, sizes in [64, 128], at least one full-size + * rock, frames in [0, frameCount) with NO frame twice in a cluster, + * bound = max(offset + size/2); + * - SPACING: no cluster within 1024 px (center-to-center) of ANY other + * object — home world (origin), planets, free-space stations, other + * clusters — and every cluster sits in the [minRadius, maxRadius] + * annulus around the origin; + * - MOTION: each rock's spin and the group drift are within the + * configured slow-spin ranges; + * - NAMES: synthesised, and never repeating a name in the same system; + * - DETERMINISM: same seed ⇒ identical clusters (deep equal), different + * seed ⇒ different, and lazy (on-arrival) === eager (fresh galaxy). + */ + +process.env.NODE_ENV = 'dev'; + +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 { NameGenerator } = await import(pathToFileURL(join(__dirname, '../js/utils/NameGenerator.js')).href); +const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); +const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href); + +let pass = 0; +let failures = 0; +function check(name, cond, detail = '') { + if (!cond) { + failures++; + console.error(`✗ ${name}${detail ? ` — ${detail}` : ''}`); + } else { + pass++; + console.log(`✓ ${name}`); + } +} +function deepEq(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +// --- Config sanity --------------------------------------------------------- +const A = config.section('asteroids', {}); +check('asteroids.json is listed & loaded (enabled)', A.enabled !== false && typeof A.texture === 'string'); +check('frame count configured', Number.isInteger(A.frameCount) && A.frameCount >= 8); +const CL = A.cluster ?? {}; +check('group size 4–8', (CL.groupSize?.min ?? 4) >= 4 && (CL.groupSize?.max ?? 8) <= 8 && CL.groupSize.min <= CL.groupSize.max); +check('sizes 64–128 with a full-size minimum', (CL.sizes?.min ?? 64) === 64 && (CL.sizes?.max ?? 128) === 128 && (CL.minFullSize ?? 1) >= 1); +const D = A.distribution ?? {}; +check('distribution has target/min/max/jitter', Number.isFinite(D.targetObjects) && D.minClusters >= 1 && D.maxClusters >= D.minClusters && Number.isFinite(D.jitter)); +check('starting system minimum ≥ 2', (D.startingSystemMinClusters ?? 2) >= 2); +const P = A.placement ?? {}; +check('placement rules present', P.minObjectSpacing === 1024 && P.minRadius > 0 && P.maxRadius >= P.minRadius); +const TETHER = config.get('tether.level1Radius', 5120) * Math.pow(config.get('tether.radiusGrowth', 1.25), Math.max(1, Math.floor(config.get('tether.homeLevel', 1))) - 1); + +// --- Name synthesis --------------------------------------------------------- +{ + const n1 = NameGenerator.asteroid(Rng.derive('x', 'names', 'S1', 'asteroids')); + const n2 = NameGenerator.asteroid(Rng.derive('x', 'names', 'S1', 'asteroids')); + check('asteroid names are "Name Suffix" strings', /^\S+ \S+$/.test(n1) && n1 === n2); +} + +// --- The big sample --------------------------------------------------------- +const SEED = 'asteroid-test-galaxy'; +const g = Galaxy.create(SEED); +const homeId = g.currentSystemId; +const homeRec = g.records.find((r) => r.id === homeId); +// The starting system MUST be in the sample (it carries the special rules). +const sample = [homeRec, ...g.records.slice(0, 5000).filter((r) => r.id !== homeId)]; + +let shapeOk = true, shapeWhy = ''; +let spacingOk = true, spacingWhy = ''; +let annulusOk = true, annulusWhy = ''; +let spinOk = true, spinWhy = ''; +let namesOk = true, namesWhy = ''; +let countOk = true, countWhy = ''; +let homeOk = true, homeWhy = ''; + +const spinMin = (CL.spin?.minDegPerSec ?? 0.3) * (Math.PI / 180); +const spinMax = (CL.spin?.maxDegPerSec ?? 1.6) * (Math.PI / 180); +const gspinMin = (CL.groupSpin?.minDegPerSec ?? 0.12) * (Math.PI / 180); +const gspinMax = (CL.groupSpin?.maxDegPerSec ?? 0.4) * (Math.PI / 180); + +for (const rec of sample) { + const c = g.ensureContent(rec.id); + const clusters = c.asteroids; + const isHome = rec.id === homeId; + + if (!Array.isArray(clusters)) { + countOk = false; countWhy = `${rec.id}: asteroids is not an array`; break; + } + + // COUNT vs PLANETS (the inverse rule): count is the clamped, jittered + // target — targetObjects − planetCount, ±jitter, clamped into [min, max] + // (and [startingMin, max] for the home system). When the ideal band + // clamps to empty, the clamped range itself is the contract. + const want = D.targetObjects - c.planets.length; + let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter)); + let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter)); + if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; } + if (clusters.length < loW || clusters.length > hiW) { + countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break; + } + + // Names already used in this system (planets + stations + clusters). + const used = new Set(); + for (const p of c.planets) used.add(p.name); + for (const s of c.settlements ?? []) if (s.name) used.add(s.name); + + // Spacing obstacles. + const objects = [{ x: 0, y: 0 }]; + for (const p of c.planets) if (typeof p.x === 'number') objects.push({ x: p.x, y: p.y }); + for (const s of c.settlements ?? []) if (s.anchor?.type === 'space' && typeof s.x === 'number') objects.push({ x: s.x, y: s.y }); + + for (let i = 0; i < clusters.length; i++) { + const cl = clusters[i]; + + // --- Group shape ----------------------------------------------------- + const rocks = cl.asteroids ?? []; + if (rocks.length < CL.groupSize.min || rocks.length > CL.groupSize.max) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: ${rocks.length} rocks (want ${CL.groupSize.min}–${CL.groupSize.max})`; break; + } + if (!rocks.every((r) => r.size >= CL.sizes.min && r.size <= CL.sizes.max)) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: rock size out of [${CL.sizes.min}, ${CL.sizes.max}]`; break; + } + if (!rocks.some((r) => r.size === CL.sizes.max)) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: no full-size rock`; break; + } + const frames = rocks.map((r) => r.frame); + if (!frames.every((f) => Number.isInteger(f) && f >= 0 && f < A.frameCount)) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: frame out of sheet [0, ${A.frameCount})`; break; + } + if (new Set(frames).size !== frames.length) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: a frame is used twice in one cluster`; break; + } + const bound = Math.max(...rocks.map((r) => Math.hypot(r.x, r.y) + r.size / 2)); + if (Math.abs(bound - cl.bound) > 1e-9) { + shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break; + } + + // --- Motion ---------------------------------------------------------- + if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) { + spinOk = false; spinWhy = `${rec.id}#${i}: rock spin out of range`; break; + } + if (Math.abs(cl.groupSpin) < gspinMin - 1e-12 || Math.abs(cl.groupSpin) > gspinMax + 1e-12) { + spinOk = false; spinWhy = `${rec.id}#${i}: group spin out of range`; break; + } + const dspinMin = 0.2 * (Math.PI / 180); + const dspinMax = 0.5 * (Math.PI / 180); + if (typeof cl.debrisSpin !== 'number' || Math.abs(cl.debrisSpin) < dspinMin - 1e-12 || Math.abs(cl.debrisSpin) > dspinMax + 1e-12) { + spinOk = false; spinWhy = `${rec.id}#${i}: debris spin out of range (${cl.debrisSpin})`; break; + } + + // --- Names ----------------------------------------------------------- + if (typeof cl.name !== 'string' || cl.name.length < 3) { + namesOk = false; namesWhy = `${rec.id}#${i}: bad name "${cl.name}"`; break; + } + if (used.has(cl.name)) { + namesOk = false; namesWhy = `${rec.id}#${i}: name "${cl.name}" repeats a name in the system`; break; + } + used.add(cl.name); + + // --- Spacing: ≥ 1024 px from EVERY other object ---------------------- + for (const o of objects) { + const d = Math.hypot(cl.x - o.x, cl.y - o.y); + if (d < P.minObjectSpacing - 1e-6) { + spacingOk = false; spacingWhy = `${rec.id}#${i}: ${Math.round(d)} px from an object (< ${P.minObjectSpacing})`; break; + } + } + if (!spacingOk) break; + + // --- Scatter annulus --------------------------------------------------- + const dist0 = Math.hypot(cl.x, cl.y); + if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) { + annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}–${P.maxRadius})`; break; + } + + // --- Starting-system tether guarantee --------------------------------- + if (isHome && i < D.startingSystemMinClusters) { + if (dist0 + cl.bound + (P.tetherMargin ?? 96) > TETHER + 1e-6) { + homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(dist0 + cl.bound)} + margin > ${TETHER})`; break; + } + } + } + if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break; +} + +check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk); +check(`shape: groups of ${CL.groupSize.min}–${CL.groupSize.max}, sizes ${CL.sizes.min}–${CL.sizes.max}, ≥1 full-size, frames unique per cluster, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); +check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); +check(`scatter: every cluster inside the ${P.minRadius}–${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); +check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk); +check('names: synthesised, never repeating a name in the same system', namesOk); +const homeClusters = g.ensureContent(homeId).asteroids; +check( + `starting system: ≥ ${D.startingSystemMinClusters} clusters inside the initial tether (radius ${TETHER} px) — got ${homeClusters.length} clusters, first ${D.startingSystemMinClusters} inside`, + homeClusters.length >= D.startingSystemMinClusters && homeOk, +); + +// The inverse rule, in aggregate: rockier systems (few planets) get more +// clusters than planet-rich ones. +{ + const rows = sample.map((r) => { + const c = g.ensureContent(r.id); + return { planets: c.planets.length, clusters: c.asteroids.length }; + }); + const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length; + const rich = rows.filter((r) => r.planets >= 6); + const poor = rows.filter((r) => r.planets <= 2); + check( + `inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥6 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`, + rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)), + ); +} + +// Clusters actually appear with variety (frame, size, spin direction mix). +{ + const c = g.ensureContent(homeId); + const all = c.asteroids.flatMap((cl) => cl.asteroids); + check('home system clusters use multiple frames', new Set(all.map((r) => r.frame)).size >= 3); + check('home system clusters mix spin directions', all.some((r) => r.spin > 0) && all.some((r) => r.spin < 0)); + const report = formatSystemReport(c); + check(`report subtitle names the clusters (${c.asteroids.length})`, report.subtitle.includes(`${c.asteroids.length} asteroid cluster`)); +} + +// --- Determinism ------------------------------------------------------------- +{ + const a = Galaxy.create(SEED).ensureContent(g.records[42].id); + const b = Galaxy.create(SEED).ensureContent(g.records[42].id); + check('same seed ⇒ identical clusters (deep equal)', deepEq(a.asteroids, b.asteroids)); + const c = Galaxy.create('totally-different-seed').ensureContent(g.records[42].id); + check('different seed ⇒ different clusters', !deepEq(a.asteroids, c.asteroids)); + + // Lazy === eager: a FRESH galaxy's on-arrival content matches the cached + // one, including every cluster's position, rocks, spins and debris. + const fresh = Galaxy.create(SEED); + const ids = [homeId, g.records[999].id, g.records[g.records.length - 1].id]; + const lazyEager = ids.every((id) => { + const f = fresh.ensureContent(id); + const cached = g.ensureContent(id); + return deepEq(f.asteroids, cached.asteroids) && deepEq(f, cached); + }); + check('lazy (on-arrival) content === cached content (clusters included)', lazyEager); + + // And the starting-system tether guarantee holds on the fresh galaxy too. + const fHome = fresh.ensureContent(homeId).asteroids; + const inside = fHome.slice(0, D.startingSystemMinClusters).every( + (cl) => Math.hypot(cl.x, cl.y) + cl.bound + (P.tetherMargin ?? 96) <= TETHER + 1e-6, + ); + check('fresh galaxy: starting-system tether clusters still inside', inside); +} + +console.log(failures === 0 ? `\n${pass} asteroid cluster checks passed ✔` : `\n${failures} check(s) FAILED ✘ (${pass} passed)`); +process.exit(failures === 0 ? 0 : 1); diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index 18e3449..36290eb 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -16,6 +16,8 @@ * (rim ping + toast) with the world in view. * ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests) * ?homeLevel= set the home tether's level (bigger/smaller range) + * ?seed= use a DETERMINISTIC dev galaxy (same seed ⇒ same + * system, planets, asteroid clusters) — reproducible shots * ?report=1 paint console errors (or "SMOKE OK") on the canvas */ import Phaser from '../js/vendor/phaser.js'; @@ -32,6 +34,11 @@ const shipParam = params ? params.get('ship') : null; const nearParam = params ? params.get('near') : null; const tethersParam = params ? params.get('tethers') : null; const homeLevelParam = params ? params.get('homeLevel') : null; +const seedParam = params ? params.get('seed') : null; + +// Deterministic dev galaxy for reproducible screenshots (GameScene. +// ensureGalaxy reads this when the registry is empty). +if (seedParam && typeof globalThis !== 'undefined') globalThis.__ORBIT_DEV_SEED = seedParam; // Dev: capture console errors + uncaught exceptions for ?report=1. const __errors = []; diff --git a/js/entities/AsteroidCluster.js b/js/entities/AsteroidCluster.js new file mode 100644 index 0000000..8c0eb75 --- /dev/null +++ b/js/entities/AsteroidCluster.js @@ -0,0 +1,264 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor } from '../utils/Color.js'; +import { Planet } from './Planet.js'; + +const HALO_KEY = '__asteroid_halo'; +const MOTE_KEY = '__asteroid_mote'; + +/** + * An asteroid cluster: a loose group of 4–8 slowly tumbling rocks drifting + * in the void (record from SystemGenerator.generateAsteroidClusters). + * + * The render is pure presentation of the generated record — every rock's + * frame, size, offset, spin, the group's drift, the dust halo, the tint. + * The motion (the "key part" of the game's feel): + * - each rock tumbles on its OWN slow spin — its own speed and direction + * (record.asteroids[].spin / .phase); + * - the whole group is LOOSE: it drifts very slowly around its center + * (record.groupSpin / .groupPhase) — a random patch of rocks, not a + * rigid rock; + * - a fine halo of dust motes orbits the rocks just outside them, + * counter-drifting a little (record.debris); + * - a soft starlight halo sits behind the group, and each cluster carries + * a subtle warm/cool tint (record.tint) so no two fields read alike. + * + * A cluster is SOLID, like a world: the ship may close in to + * `asteroids.shipClearance` px (edge-to-edge) of any rock, but can never + * fly through one (constrainShip — the same circle rule as Planet, applied + * to each rock; planets never overlap, and cluster rocks only slightly, so + * a couple of projection passes are stable). + * + * update(time) is driven by the scene (GameScene.update) — it advances the + * spins and CACHES each rock's current world position (m.wx/m.wy), which + * the ship constraint (GameScene.onPostUpdate, after the physics step) + * reads, so collision tracks the drifting rocks. + */ +export class AsteroidCluster extends Phaser.GameObjects.Container { + static TEXTURE_KEY = 'asteroids'; + + /** + * @param {Phaser.Scene} scene + * @param {object} record — the generated cluster (SystemGenerator) + * @param {object} [o={}] + * @param {number} [o.depth=5] — render depth (planets sit at 5) + */ + constructor(scene, record, o = {}) { + super(scene, record.x, record.y); + scene.add.existing(this); // v4 quirk: new'd objects are not on the display list + this.record = record; + this.discoveryId = record.id; // discovery + compass identity + this.discoveryName = record.name; + this.bound = record.bound ?? 200; // max extent from center (discovery radius) + this.clearance = config.get('asteroids.shipClearance', 50); + this.setDepth(o.depth ?? 5); + + // Rocks: local (un-rotated) data + render image. World positions are + // cached in wx/wy every update() — collision reads those. + this.members = (record.asteroids ?? []).map((a) => ({ + frame: a.frame, + lx: a.x, + ly: a.y, + radius: a.size / 2, + spin: a.spin, + phase: a.phase, + img: null, + wx: this.x, + wy: this.y, + })); + + this.groupPhase = record.groupPhase ?? 0; + this.groupSpin = record.groupSpin ?? 0; // rad/s, signed + this.debrisPhase = record.debrisPhase ?? 0; + this.debrisSpin = record.debrisSpin ?? -this.groupSpin * 0.5; // rad/s, signed + + // --- Starlight halo (soft, behind the group) ------------------------- + const haloCfg = config.get('asteroids.cluster.halo', {}); + if (haloCfg.enabled !== false) { + ensureHaloTexture(scene); + const halo = scene.add.image(0, 0, HALO_KEY); + halo.setScale((this.bound * (haloCfg.scale ?? 2.4)) / 32); + halo.setAlpha(haloCfg.alpha ?? 0.14); + halo.setTint(toColor(haloCfg.color, '#7fa8d8')); + this.add(halo); + } + + // --- The loose group: a container that drifts, holding each rock ---- + const frameSize = Math.max(1, config.get('asteroids.frameWidth', 128)); + const tint = record.tint; + this.groupBody = new Phaser.GameObjects.Container(scene, 0, 0); + for (const m of this.members) { + const img = scene.add.image(m.lx, m.ly, AsteroidCluster.TEXTURE_KEY, m.frame); + img.setScale((m.radius * 2) / frameSize); // 128 px rock at 1.0, 64 px at 0.5 + if (tint) img.setTint(tint); + m.img = img; + this.groupBody.add(img); + } + this.add(this.groupBody); + + // --- Dust: fine motes orbiting just outside the rocks ---------------- + if (Array.isArray(record.debris) && record.debris.length > 0) { + ensureMoteTexture(scene); + this.debrisBody = new Phaser.GameObjects.Container(scene, 0, 0); + for (const d of record.debris) { + const dot = scene.add.image(d.x, d.y, MOTE_KEY); + dot.setScale(Math.max(0.6, d.size) / 6); + dot.setAlpha(d.alpha); + this.debrisBody.add(dot); + } + this.add(this.debrisBody); + } + } + + /** + * Advance the motion (driven by GameScene.update BEFORE ship constraint, + * so the rocks the ship is held back by are exactly where they render). + * @param {number} time — scene time, ms + */ + update(time) { + const t = time / 1000; + const rot = this.groupPhase + this.groupSpin * t; + this.groupBody.rotation = rot; + const cos = Math.cos(rot); + const sin = Math.sin(rot); + for (const m of this.members) { + if (m.img) m.img.rotation = m.phase + m.spin * t; + // World position of this rock (group drift applied): + m.wx = this.x + m.lx * cos - m.ly * sin; + m.wy = this.y + m.lx * sin + m.ly * cos; + } + if (this.debrisBody) { + // The dust glides on its own slow orbit — loose, not locked to the + // rocks (record.debrisSpin; legacy records fall back to a gentle + // counter-drift). + this.debrisBody.rotation = this.debrisPhase + this.debrisSpin * t; + } + } + + /** + * Keep a ship out of every rock: the same keep-out circle rule as a + * planet (ship may reach `clearance` edge-to-edge, never closer), + * projected per rock. Cluster rocks may slightly overlap, so the + * projection is iterated a few passes until stable — cheap (≤ 8 circles). + */ + constrainShip(ship, shipRadius = 0) { + const body = ship.body; + let x = ship.x; + let y = ship.y; + let vx = body.velocity.x; + let vy = body.velocity.y; + let ax = body.acceleration ? body.acceleration.x : 0; + let ay = body.acceleration ? body.acceleration.y : 0; + for (let pass = 0; pass < 4; pass++) { + let moved = false; + for (const m of this.members) { + const r = Planet.resolve( + m.wx, m.wy, m.radius + this.clearance + shipRadius, + x, y, vx, vy, ax, ay, + ); + if (r.x !== x || r.y !== y || r.vx !== vx || r.vy !== vy) moved = true; + x = r.x; + y = r.y; + vx = r.vx; + vy = r.vy; + ax = r.ax; + ay = r.ay; + } + if (!moved) break; + } + ship.x = x; + ship.y = y; + body.velocity.x = vx; + body.velocity.y = vy; + if (body.acceleration) { + body.acceleration.x = ax; + body.acceleration.y = ay; + } + } + + /** + * A world point the ship may be sent to (click-to-fly / autopilot): a + * point inside the cluster's keep-out is pushed out along the ray from + * the cluster center, then out of every rock's keep-out circle. + */ + aimPoint(wx, wy, shipRadius = 0) { + let x = wx; + let y = wy; + const dx = x - this.x; + const dy = y - this.y; + const d = Math.hypot(dx, dy); + const boundMin = this.bound + this.clearance + shipRadius; + if (d < boundMin) { + if (d > 0) { + x = this.x + (dx / d) * boundMin; + y = this.y + (dy / d) * boundMin; + } else { + x = this.x + boundMin; // dead center: +x + y = this.y; + } + } + return this._pushOutOfRocks(x, y, shipRadius); + } + + /** + * An approach point on the cluster's rim at `angle` (rad), `gap` px + * (edge-to-edge) off the nearest rock — the side the ship is coming from. + */ + edgePoint(angle, gap, shipRadius = 0) { + const x = this.x + Math.cos(angle) * (this.bound + gap + shipRadius); + const y = this.y + Math.sin(angle) * (this.bound + gap + shipRadius); + return this._pushOutOfRocks(x, y, shipRadius); + } + + /** Push a point outside every rock's keep-out circle (a few passes). */ + _pushOutOfRocks(x, y, shipRadius = 0) { + for (let pass = 0; pass < 4; pass++) { + let moved = false; + for (const m of this.members) { + const r = Planet.resolve( + m.wx, m.wy, m.radius + this.clearance + shipRadius, + x, y, 0, 0, + ); + if (r.x !== x || r.y !== y) moved = true; + x = r.x; + y = r.y; + } + if (!moved) break; + } + return { x, y }; + } +} + +// ---------------------------------------------------------------------- + +/** + * The starlight halo: a soft radial falloff, generated once (white — the + * per-cluster color/alpha come from data/asteroids.json → cluster.halo at + * use time). Same pattern as Ship.ensureTexture / the compass arrow. + */ +function ensureHaloTexture(scene) { + if (scene.textures.exists(HALO_KEY)) return; + const S = 64; + const C = S / 2; + const g = scene.make.graphics({ add: false }); + const steps = 18; + for (let i = steps; i >= 1; i--) { + const t = i / steps; + g.fillStyle(0xffffff, Math.pow(1 - t, 1.7) * 0.5); + g.fillCircle(C, C, C * t); + } + g.generateTexture(HALO_KEY, S, S); + g.destroy(); +} + +/** A single dust mote: a soft 8 px dot (white-blue). */ +function ensureMoteTexture(scene) { + if (scene.textures.exists(MOTE_KEY)) return; + const g = scene.make.graphics({ add: false }); + g.fillStyle(0xdfe8ff, 0.35); + g.fillCircle(4, 4, 4); + g.fillStyle(0xffffff, 0.8); + g.fillCircle(4, 4, 2); + g.generateTexture(MOTE_KEY, 8, 8); + g.destroy(); +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 41346c9..e17d7c2 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -3,6 +3,8 @@ import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +const TAU = Math.PI * 2; +const DEG = Math.PI / 180; /** * Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a @@ -108,6 +110,14 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { const freeSpace = settlements.filter((s) => s.anchor?.type === 'space'); layoutSystem(galaxy.seed, record.id, planets, freeSpace); + // --- Asteroid clusters ------------------------------------------------ + // Loose groups of slowly tumbling rocks scattered through the void. + // Generated AFTER the layout (so every placed object is a spacing + // obstacle) from the dedicated stream (seed, 'system', id, 'asteroids') + // — independent of the star/planet/settlement/layout draws above, so + // lazy (on-arrival) === eager (generateAll) is preserved. + const asteroids = generateAsteroidClusters(galaxy, record, planets, freeSpace); + // --- Debris belt & system-level hazard -------------------------------- const belt = { present: rng.chance(attr.beltChance ?? 0.35), @@ -121,6 +131,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { star, planets, settlements, + asteroids, belt, hazard, }; @@ -264,6 +275,264 @@ function layoutSystem(seed, systemId, planets, freeSpace) { } } +/** + * Asteroid clusters — the system's loose rock fields (data/asteroids.json). + * + * A cluster is a GROUP of 4–8 rocks (data → cluster.groupSize), each 64–128 + * px (cluster.sizes) with at least one full-size rock, sitting within + * `cluster.spread` of the cluster center. Every rock tumbles on its own + * slow spin (its own speed and direction — cluster.spin), and the whole + * group drifts slowly around its center (cluster.groupSpin) — the render + * (js/entities/AsteroidCluster.js) reads these straight off the record. + * + * The rules (all from data/asteroids.json): + * - COUNT vs PLANETS — clusterCount ≈ targetObjects − planetCount (± + * jitter, clamped to [minClusters, maxClusters]): the more planets a + * system has, the fewer asteroid clusters, and vice versa. The + * STARTING system always gets at least startingSystemMinClusters, and + * those first ones are placed inside the player's initial tether + * (tether.level1Radius × radiusGrowth^(homeLevel−1), whole cluster, + * minus placement.tetherMargin) so the player can reach them. + * - SPACING — no cluster center may sit closer than + * placement.minObjectSpacing (1024 px, center-to-center) to ANY other + * object: the home world (origin), every planet, every free-space + * station, and every other cluster. + * - SCATTER — other clusters land anywhere in the annulus + * [placement.minRadius, placement.maxRadius] around the origin, picked + * by seeded rejection sampling (uniform in area) — sprinkled through + * the void, inside and outside the planet orbits. + * - FRAMES — each rock is a random sheet frame, with NO frame repeated + * twice in the same cluster (a seeded shuffle of the frame pool). + * - NAMES — synthesised (NameGenerator.asteroid) and never repeating a + * name already used in the system (planets, stations, other clusters). + * + * Determinism: every draw comes from the dedicated forks + * Rng.derive(seed, 'system', id, 'asteroids') — members, spins, placement + * Rng.derive(seed, 'system', id, 'names', 'asteroids') — names + * — so clusters are seed-deterministic, independent of the other streams, + * and lazy === eager (see generateSystemContent). + * + * Record shape (one per cluster): + * { + * id, name, x, y, // cluster center + identity + * bound, // max extent from center = discovery radius + * tint, // per-cluster starlight tint (int, or null) + * groupSpin, groupPhase, // the loose group's slow drift (rad/s, rad) + * debrisPhase, debris: [], // dust motes (local px, size, alpha) + * asteroids: [{ frame, x, y, size, spin, phase }] // rocks, local px + * } + */ +function generateAsteroidClusters(galaxy, record, planets, freeSpace) { + const cfg = config.section('asteroids', {}); + if (cfg.enabled === false) return []; + // Without the solar-system layout there are no placed objects to space + // against — no clusters either (the scene renders nothing else anyway). + if (config.get('planets.solarSystem.enabled', true) === false) return []; + + const clusterCfg = cfg.cluster ?? {}; + const dist = cfg.distribution ?? {}; + const placement = cfg.placement ?? {}; + const isHome = record.id === galaxy?.currentSystemId; + + // --- How many clusters: the inverse of the planet count --------------- + const target = dist.targetObjects ?? 9; + const jitter = dist.jitter ?? 1; + const minC = Math.max(1, Math.floor(dist.minClusters ?? 1)); + const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6)); + const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC; + + const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids'); + const count = clamp( + Math.round(target - planets.length + rng.range(-jitter, jitter)), + homeMin, maxC, + ); + if (count === 0) return []; + + // --- Parameter plumbing (every value steerable from data/asteroids.json) + const frameCount = Math.max(1, Math.floor(cfg.frameCount ?? 10)); + const sizeMin = Math.floor(clusterCfg.sizes?.min ?? 64); + const sizeMax = Math.max(sizeMin, Math.floor(clusterCfg.sizes?.max ?? 128)); + const groupMin = Math.max(1, Math.floor(clusterCfg.groupSize?.min ?? 4)); + const groupMax = Math.max(groupMin, Math.floor(clusterCfg.groupSize?.max ?? 8)); + const spread = clusterCfg.spread ?? 140; + const spinMin = Math.max(0, clusterCfg.spin?.minDegPerSec ?? 0.3) * DEG; + const spinMax = Math.max(spinMin, clusterCfg.spin?.maxDegPerSec ?? 1.6) * DEG; + const gspinMin = Math.max(0, clusterCfg.groupSpin?.minDegPerSec ?? 0.12) * DEG; + const gspinMax = Math.max(gspinMin, clusterCfg.groupSpin?.maxDegPerSec ?? 0.4) * DEG; + const tintAnchors = + clusterCfg.tint?.enabled === false ? [] : (clusterCfg.tint?.anchors ?? ['#dfe9ff', '#ffe9d6', '#e8e4f8']); + const tintStrength = clamp(clusterCfg.tint?.strength ?? 0.5, 0, 1); + const debrisCfg = clusterCfg.debris ?? {}; + + // --- Placement rules --------------------------------------------------- + const rMin = placement.minRadius ?? 2048; + const rMax = Math.max(rMin, placement.maxRadius ?? 18432); + const minSep = placement.minObjectSpacing ?? 1024; + const maxAttempts = Math.max(1, Math.floor(placement.maxPlacementAttempts ?? 400)); + const tetherMargin = placement.tetherMargin ?? 96; + const tetherRadius = homeTetherRadius(); + const homeSlots = isHome ? Math.min(count, homeMin) : 0; + + // Names already taken in this system (planets + stations). + const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids'); + const usedNames = new Set(); + for (const p of planets) if (p.name) usedNames.add(p.name); + for (const s of freeSpace) if (s.name) usedNames.add(s.name); + + // Spacing obstacles: the home world (origin) + every placed object. + const placed = [{ x: 0, y: 0 }]; + for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y }); + for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y }); + + const clusters = []; + for (let i = 0; i < count; i++) { + // --- The group's rocks --------------------------------------------- + const memberCount = rng.int(groupMin, groupMax); + + // Frames: a random subset of the sheet — NO frame twice in this cluster. + const frames = rng.shuffle(Array.from({ length: frameCount }, (_, k) => k)).slice(0, memberCount); + + // Sizes: random in [sizeMin, sizeMax]; at least minFullSize full-size. + const sizes = Array.from({ length: memberCount }, () => rng.int(sizeMin, sizeMax)); + const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount); + for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax; + + // Offsets: a dense random blob around the center (slight overlap is + // allowed — collision clusters look organic; solids never fully + // interpenetrate, see the 0.75 factor). + const members = []; + for (let k = 0; k < memberCount; k++) { + let ox = 0; + let oy = 0; + for (let attempt = 0; attempt < 24; attempt++) { + const a = rng.range(0, TAU); + const r = spread * Math.sqrt(rng.next()); // uniform in area + ox = Math.cos(a) * r; + oy = Math.sin(a) * r; + if (members.every((m) => Math.hypot(ox - m.x, oy - m.y) >= 0.75 * (sizes[k] / 2 + m.size / 2))) break; + } + members.push({ x: ox, y: oy, size: sizes[k] }); + } + const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2)); + + // Spins: each rock tumbles on its own — its own slow speed, its own + // direction, its own starting phase. + const spins = members.map(() => (rng.chance(0.5) ? 1 : -1) * rng.range(spinMin, spinMax)); + const phases = members.map(() => rng.range(0, TAU)); + + // Dust: a fine halo of motes orbiting just outside the rocks. + const debris = []; + if (debrisCfg.enabled !== false) { + const n = rng.int(debrisCfg.count?.[0] ?? 14, debrisCfg.count?.[1] ?? 30); + const rIn = bound * (debrisCfg.inner ?? 1.0); + const rOut = Math.max(rIn, bound * (debrisCfg.outer ?? 2.1)); + for (let k = 0; k < n; k++) { + const a = rng.range(0, TAU); + const r = Math.sqrt(rng.range(rIn * rIn, rOut * rOut)); + debris.push({ + x: Math.cos(a) * r, + y: Math.sin(a) * r, + size: rng.range(debrisCfg.size?.[0] ?? 0.8, debrisCfg.size?.[1] ?? 2.4), + alpha: rng.range(debrisCfg.alpha?.[0] ?? 0.12, debrisCfg.alpha?.[1] ?? 0.4), + }); + } + } + + // Starlight: a subtle warm/cool shift, per cluster. + const tint = tintAnchors.length + ? mixTint(tintAnchors[rng.int(0, tintAnchors.length - 1)] ?? tintAnchors[0], tintStrength) + : null; + + // Name: synthesised, unique within the system. + let name = NameGenerator.asteroid(nameRng); + for (let tries = 0; tries < 10 && usedNames.has(name); tries++) name = NameGenerator.asteroid(nameRng); + usedNames.add(name); + + // --- Where: sprinkle it in the void -------------------------------- + // The starting system's first clusters live INSIDE the initial tether + // (whole group: center + bound + margin ≤ tether radius); the rest — + // and every cluster elsewhere — go anywhere in the scatter annulus. + const zoneMax = + i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax; + const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts); + placed.push(pos); + + clusters.push({ + id: `asteroid-${i + 1}`, + name, + x: pos.x, + y: pos.y, + bound, + tint, + groupSpin: (rng.chance(0.5) ? 1 : -1) * rng.range(gspinMin, gspinMax), + groupPhase: rng.range(0, TAU), + debrisPhase: rng.range(0, TAU), + // The dust glides on its OWN slow orbit (0.2–0.5 deg/s, random way) + // — fine particles drifting around the rocks, not locked to them. + debrisSpin: (rng.next() < 0.5 ? 1 : -1) * rng.range(0.2, 0.5) * DEG, + debris, + asteroids: members.map((m, k) => ({ + frame: frames[k], + x: m.x, + y: m.y, + size: m.size, + spin: spins[k], + phase: phases[k], + })), + }); + } + return clusters; +} + +/** + * A random point in the annulus [rMin, rMax] around the origin (uniform in + * AREA) that is ≥ minSep from every placed object — seeded rejection + * sampling. If the annulus is hopelessly crowded (it isn't, at these + * numbers) it returns the best candidate rather than failing. + */ +function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) { + let best = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const a = rng.range(0, TAU); + const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax)); + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + let ok = true; + let worst = Infinity; + for (const p of placed) { + const d = Math.hypot(x - p.x, y - p.y); + if (d < minSep) { + ok = false; + if (d < worst) worst = d; + } + } + if (ok) return { x, y }; + if (best === null || worst > best.worst) best = { x, y, worst }; // best = furthest from the closest object + } + return { x: best.x, y: best.y }; +} + +/** The starting system's initial tether radius (home world's level). */ +function homeTetherRadius() { + const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); + const base = config.get('tether.level1Radius', 5120); + const growth = config.get('tether.radiusGrowth', 1.25); + return base * Math.pow(growth, level - 1); +} + +/** + * Blend a hex tint anchor toward white by `strength` (0 = white, 1 = the + * anchor) → a 24-bit canvas tint. Pure (no Phaser — this runs in Node). + */ +function mixTint(hex, strength) { + let h = String(hex ?? '').trim().replace(/^#/, ''); + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + const n = parseInt(h, 16); + if (!Number.isFinite(n)) return null; + const mix = (c) => Math.round(255 + (c - 255) * strength); + return (mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255); +} + /** * Core→rim density: the settled heart of the galaxy has more activity per * system; the rim is thinner, lonelier. `factor` scales every settlement diff --git a/js/galaxy/SystemReport.js b/js/galaxy/SystemReport.js index 338c8b1..8334726 100644 --- a/js/galaxy/SystemReport.js +++ b/js/galaxy/SystemReport.js @@ -42,9 +42,12 @@ export function formatSystemReport(content, typeDefs = null, kindDefs = null) { : `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`; const planetN = content.planets.length; + const asteroidN = Array.isArray(content.asteroids) ? content.asteroids.length : 0; return { title: content.name, - subtitle: `${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}`, + subtitle: + `${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}` + + (asteroidN ? ` · ${asteroidN} asteroid cluster${asteroidN === 1 ? '' : 's'}` : ''), settlements, summary, population, diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index c175ff9..a73c86d 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -8,6 +8,7 @@ import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; +import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; @@ -69,6 +70,20 @@ export class GameScene extends Phaser.Scene { }, ); } + + // The asteroid spritesheet (data/asteroids.json → texture): 128×128 + // rock frames, one per cluster member (the generator picks frames). + const asteroidTexture = config.get('asteroids.texture', ''); + if (asteroidTexture && config.get('asteroids.enabled', true) !== false) { + this.load.spritesheet( + AsteroidCluster.TEXTURE_KEY, + asteroidTexture, + { + frameWidth: config.get('asteroids.frameWidth', 128), + frameHeight: config.get('asteroids.frameHeight', 128), + }, + ); + } } create() { @@ -119,7 +134,25 @@ export class GameScene extends Phaser.Scene { this.systemPlanets.push(p); } } - this.solidPlanets = [this.planet, ...this.systemPlanets]; + + // Asteroid clusters — loose groups of slowly tumbling rocks scattered + // through the system's void (data/asteroids.json). Every cluster is a + // DISCOVERABLE object (the compass knows it once found) and a SOLID one + // (the ship can park at a rock's rim, never fly through it) — the same + // contract as the worlds, at rock scale. Mining them comes later. + this.asteroidClusters = []; + if (config.get('asteroids.enabled', true) !== false) { + for (const rec of this.systemContent.asteroids ?? []) { + this.asteroidClusters.push(new AsteroidCluster(this, rec, { depth: 5 })); + } + } + + // Every solid in the system — worlds first (their keep-out circles are + // disjoint), then the clusters. Ship constraint, click-to-fly clamping + // and autopilot all run against this list. + this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters]; + this.planet.discoveryId = 'home'; + this.planet.discoveryName = this.homeWorldName; // The ship — a short hop (~150 px, edge-to-edge) from the home world's // rim, in a seed-derived direction: same galaxy ⇒ same start. @@ -231,29 +264,53 @@ export class GameScene extends Phaser.Scene { if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return; let aim = { x: pointer.worldX, y: pointer.worldY }; - for (const p of this.solidPlanets) { - aim = p.aimPoint(aim.x, aim.y, this.ship.radius); + for (const s of this.solids) { + aim = s.aimPoint(aim.x, aim.y, this.ship.radius); } aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); }); + + // The solid keep-outs run AFTER the arcade world has integrated the + // ship's motion (the scene's 'postupdate' fires after the physics step + // has moved the sprite). Constrained in update() instead, the body + // re-applies this frame's inward velocity right after the push-out — + // the ship would dip a frame-deep into every rim it meets at speed. + this.events.on('postupdate', this.onPostUpdate, this); + } + + /** + * Post-physics constraints: every solid in the system (worlds AND + * asteroid clusters) holds the ship back at its clearance — fly close, + * never through — and the tether holds the player's range (clamped to + * the union boundary, the line shudders where it was hit). + */ + onPostUpdate(_time, delta) { + for (const s of this.solids) { + s.constrainShip(this.ship, this.ship.radius); + } + const contact = this.tetherField.constrainShip(this.ship); + if (contact) this.onTetherContact(contact); } /** * 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. + * fresh dev galaxy so the scene always works standalone — with a + * deterministic seed when one is injected (`globalThis.__ORBIT_DEV_SEED`, + * set by dev/smoke-game.mjs from ?seed=…). */ ensureGalaxy() { this.galaxy = this.registry.get('galaxy') ?? null; if (!this.galaxy) { - const seed = Rng.randomSeedString(8); + const devSeed = typeof globalThis !== 'undefined' ? globalThis.__ORBIT_DEV_SEED : null; + const seed = devSeed && String(devSeed).length > 0 ? String(devSeed) : 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}")`); + if (!devSeed) console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`); } } @@ -338,17 +395,10 @@ export class GameScene extends Phaser.Scene { this.time.update(_time, delta); this.tweens.update(); this.ship.update(_time, delta); - // Every world in the system is solid: the ship may come within the - // clearance in data/planets.json of a rim, but never closer (or - // through it). - for (const p of this.solidPlanets) { - p.constrainShip(this.ship, this.ship.radius); - } - // The tether is the player's range: anywhere in the union of its - // tethers' zones is space; outside it, the ship is clamped to the - // boundary and the line shudders where it was hit. - const contact = this.tetherField.constrainShip(this.ship); - if (contact) this.onTetherContact(contact); + // The clusters are ALIVE: each rock tumbles, the loose group drifts, + // the dust orbits. (The keep-out constraint runs in onPostUpdate, + // after the physics step has moved the ship.) + for (const c of this.asteroidClusters) c.update(_time); this.updateCamera(delta); this.starfield.update(); // after the camera, so it sees this frame's motion this.tetherField.tick(_time, delta); // glitch/pulse lifecycle @@ -485,6 +535,18 @@ export class GameScene extends Phaser.Scene { name: p.discoveryName, }); } + // Asteroid clusters count as objects too: discoverable, compass arrows, + // autopilot — at the scale of their extent (bound). + for (const c of this.asteroidClusters) { + out.push({ + id: c.discoveryId, + x: c.x, + y: c.y, + radius: c.bound, + typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'), + name: c.discoveryName, + }); + } return out; } @@ -493,25 +555,25 @@ export class GameScene extends Phaser.Scene { * to this): send the ship to a discovered object. It flies to the * keep-out rim — the clearance point on the side the ship is coming * from — and arrives to a stop, exactly like a click-to-fly onto the - * rim. Clicking elsewhere retargets the same way (last click wins). + * rim. Works for worlds AND asteroid clusters (the solid behind the + * discovery entry). Clicking elsewhere retargets the same way (last + * click wins). */ autopilotTo(id) { const o = this.discoverableObjects().find((v) => v.id === id); if (!o) return; - // The solid planet behind this discovery entry. - const planet = - id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet); + // The solid body behind this discovery entry (a world or a cluster). + const solid = this.solids.find((s) => s.discoveryId === id) ?? this.planet; // Approach point: on the rim (clearance + hull), on the side the ship // is approaching from (center → ship direction) — it ends up facing - // the world. If the world is outside the player's tether range, the + // the object. If the object is outside the player's tether range, the // aim clamps to the union boundary — the ship flies as far as its // tether lets it (and rests on the line) until the tether grows. const dx = this.ship.x - o.x; const dy = this.ship.y - o.y; - const d = Math.hypot(dx, dy) || 1; - let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius); - // Belt and braces: no other world may own this point either. - for (const p of this.solidPlanets) aim = p.aimPoint(aim.x, aim.y, this.ship.radius); + let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius); + // Belt and braces: no other solid may own this point either. + for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius); aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js index 127fda9..aa19539 100644 --- a/js/utils/NameGenerator.js +++ b/js/utils/NameGenerator.js @@ -39,6 +39,12 @@ const FALLBACK = { minParts: 3, maxParts: 5, }, + asteroid: { + syllables: ['ka', 'ver', 'dor', 'thra', 'nix', 'oru', 'mal', 'cra', 'zeth', 'vel', 'kor'], + minParts: 2, + maxParts: 3, + suffixes: ['Field', 'Drift', 'Patch', 'Reef', 'Shoals', 'Belt'], + }, // Tiny built-in pools so the game still names things if data/naming.json // is missing. The real banks live in data/naming.json → banks. planet: ['New Denver', 'Klaxoria', 'New Austin', 'Zyneatha', 'New Phoenix', 'Vexithunhal', 'New Dallas', 'Kordrasul'], @@ -103,6 +109,17 @@ export const NameGenerator = { return joinSyllables(rng, syllables, g.minParts ?? 3, g.maxParts ?? 5); }, + /** + * An asteroid cluster name: "Kaveru Field" — synthesised syllables plus a + * field-like suffix (unbounded, like stars; data/naming.json → asteroid). + */ + asteroid(rng) { + const s = section('asteroid', FALLBACK.asteroid); + const syllables = Array.isArray(s.syllables) && s.syllables.length ? s.syllables : FALLBACK.asteroid.syllables; + const suffixes = Array.isArray(s.suffixes) && s.suffixes.length ? s.suffixes : FALLBACK.asteroid.suffixes; + return `${joinSyllables(rng, syllables, s.minParts ?? 2, s.maxParts ?? 3)} ${rng.pick(suffixes) ?? 'Field'}`; + }, + /** * A shuffled deck of PLANET names for one system. The first N draws are N * distinct names (until the bank is exhausted). Pure function of (rng) ⇒