Add asteroid clusters as discoverable, solid objects in each star system
- Generate 4–8 rock groups per system with seeded placement, spacing, and slow tumble/drift motion driven by data/asteroids.json - Add AsteroidCluster entity (Phaser container) with halo, dust motes, per-rock spins, and ship keep-out collision reusing Planet.resolve - Integrate clusters into GameScene: load spritesheet, constrain ship post-physics, include in click-to-fly/autopilot/discovery/compass - Synthesise unique cluster names via NameGenerator.asteroid (syllables + field suffix) - Add dev test suite and ?seed= param for deterministic galaxy generation
This commit is contained in:
parent
48dfc7cd8f
commit
ec7671d0cb
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"menu.json",
|
"menu.json",
|
||||||
"ship.json",
|
"ship.json",
|
||||||
"planets.json",
|
"planets.json",
|
||||||
|
"asteroids.json",
|
||||||
"tether.json",
|
"tether.json",
|
||||||
"galaxy.json",
|
"galaxy.json",
|
||||||
"systems.json",
|
"systems.json",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@
|
||||||
"minParts": 3,
|
"minParts": 3,
|
||||||
"maxParts": 5
|
"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": {
|
"banks": {
|
||||||
"_comment": "planet.colonial = familiar, Earth-flavored names (colonial 'New <city>' + 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.",
|
"_comment": "planet.colonial = familiar, Earth-flavored names (colonial 'New <city>' + 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": {
|
"planet": {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
* (rim ping + toast) with the world in view.
|
* (rim ping + toast) with the world in view.
|
||||||
* ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests)
|
* ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests)
|
||||||
* ?homeLevel=<n> set the home tether's level (bigger/smaller range)
|
* ?homeLevel=<n> set the home tether's level (bigger/smaller range)
|
||||||
|
* ?seed=<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
|
* ?report=1 paint console errors (or "SMOKE OK") on the canvas
|
||||||
*/
|
*/
|
||||||
import Phaser from '../js/vendor/phaser.js';
|
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 nearParam = params ? params.get('near') : null;
|
||||||
const tethersParam = params ? params.get('tethers') : null;
|
const tethersParam = params ? params.get('tethers') : null;
|
||||||
const homeLevelParam = params ? params.get('homeLevel') : 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.
|
// Dev: capture console errors + uncaught exceptions for ?report=1.
|
||||||
const __errors = [];
|
const __errors = [];
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import { Rng } from '../utils/Rng.js';
|
||||||
import { NameGenerator } from '../utils/NameGenerator.js';
|
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||||
|
|
||||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
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
|
* 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');
|
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
|
||||||
layoutSystem(galaxy.seed, record.id, planets, freeSpace);
|
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 --------------------------------
|
// --- Debris belt & system-level hazard --------------------------------
|
||||||
const belt = {
|
const belt = {
|
||||||
present: rng.chance(attr.beltChance ?? 0.35),
|
present: rng.chance(attr.beltChance ?? 0.35),
|
||||||
|
|
@ -121,6 +131,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
star,
|
star,
|
||||||
planets,
|
planets,
|
||||||
settlements,
|
settlements,
|
||||||
|
asteroids,
|
||||||
belt,
|
belt,
|
||||||
hazard,
|
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
|
* Core→rim density: the settled heart of the galaxy has more activity per
|
||||||
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,12 @@ export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
|
||||||
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`;
|
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`;
|
||||||
|
|
||||||
const planetN = content.planets.length;
|
const planetN = content.planets.length;
|
||||||
|
const asteroidN = Array.isArray(content.asteroids) ? content.asteroids.length : 0;
|
||||||
return {
|
return {
|
||||||
title: content.name,
|
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,
|
settlements,
|
||||||
summary,
|
summary,
|
||||||
population,
|
population,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||||||
import { Discovery } from '../galaxy/Discovery.js';
|
import { Discovery } from '../galaxy/Discovery.js';
|
||||||
import { Ship } from '../entities/Ship.js';
|
import { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
|
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
import { ActionBar } from '../ui/ActionBar.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() {
|
create() {
|
||||||
|
|
@ -119,7 +134,25 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.systemPlanets.push(p);
|
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
|
// 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.
|
// 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.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
|
||||||
if (this.compass.contains(pointer.x, pointer.y)) return;
|
if (this.compass.contains(pointer.x, pointer.y)) return;
|
||||||
let aim = { x: pointer.worldX, y: pointer.worldY };
|
let aim = { x: pointer.worldX, y: pointer.worldY };
|
||||||
for (const p of this.solidPlanets) {
|
for (const s of this.solids) {
|
||||||
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
}
|
}
|
||||||
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
||||||
this.showTargetMarker(aim.x, aim.y);
|
this.showTargetMarker(aim.x, aim.y);
|
||||||
this.ship.setTarget(aim.x, aim.y);
|
this.ship.setTarget(aim.x, aim.y);
|
||||||
this.hideHint();
|
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
|
* 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
|
* 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() {
|
ensureGalaxy() {
|
||||||
this.galaxy = this.registry.get('galaxy') ?? null;
|
this.galaxy = this.registry.get('galaxy') ?? null;
|
||||||
if (!this.galaxy) {
|
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.galaxy = Galaxy.create(seed);
|
||||||
this.registry.set('galaxy', this.galaxy);
|
this.registry.set('galaxy', this.galaxy);
|
||||||
this.registry.set('seed', seed);
|
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.time.update(_time, delta);
|
||||||
this.tweens.update();
|
this.tweens.update();
|
||||||
this.ship.update(_time, delta);
|
this.ship.update(_time, delta);
|
||||||
// Every world in the system is solid: the ship may come within the
|
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
|
||||||
// clearance in data/planets.json of a rim, but never closer (or
|
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
|
||||||
// through it).
|
// after the physics step has moved the ship.)
|
||||||
for (const p of this.solidPlanets) {
|
for (const c of this.asteroidClusters) c.update(_time);
|
||||||
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);
|
|
||||||
this.updateCamera(delta);
|
this.updateCamera(delta);
|
||||||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||||||
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
|
||||||
|
|
@ -485,6 +535,18 @@ export class GameScene extends Phaser.Scene {
|
||||||
name: p.discoveryName,
|
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;
|
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
|
* 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
|
* 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
|
* 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) {
|
autopilotTo(id) {
|
||||||
const o = this.discoverableObjects().find((v) => v.id === id);
|
const o = this.discoverableObjects().find((v) => v.id === id);
|
||||||
if (!o) return;
|
if (!o) return;
|
||||||
// The solid planet behind this discovery entry.
|
// The solid body behind this discovery entry (a world or a cluster).
|
||||||
const planet =
|
const solid = this.solids.find((s) => s.discoveryId === id) ?? this.planet;
|
||||||
id === 'home' ? this.planet : (this.systemPlanets.find((p) => p.discoveryId === id) ?? this.planet);
|
|
||||||
// Approach point: on the rim (clearance + hull), on the side the ship
|
// Approach point: on the rim (clearance + hull), on the side the ship
|
||||||
// is approaching from (center → ship direction) — it ends up facing
|
// 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
|
// 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.
|
// tether lets it (and rests on the line) until the tether grows.
|
||||||
const dx = this.ship.x - o.x;
|
const dx = this.ship.x - o.x;
|
||||||
const dy = this.ship.y - o.y;
|
const dy = this.ship.y - o.y;
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius);
|
||||||
let aim = planet.edgePoint(Math.atan2(dy, dx), planet.clearance, this.ship.radius);
|
// Belt and braces: no other solid may own this point either.
|
||||||
// Belt and braces: no other world may own this point either.
|
for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
for (const p of this.solidPlanets) aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
|
||||||
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
aim = this.tetherField.clampPoint(aim.x, aim.y);
|
||||||
this.showTargetMarker(aim.x, aim.y);
|
this.showTargetMarker(aim.x, aim.y);
|
||||||
this.ship.setTarget(aim.x, aim.y);
|
this.ship.setTarget(aim.x, aim.y);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ const FALLBACK = {
|
||||||
minParts: 3,
|
minParts: 3,
|
||||||
maxParts: 5,
|
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
|
// 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.
|
// is missing. The real banks live in data/naming.json → banks.
|
||||||
planet: ['New Denver', 'Klaxoria', 'New Austin', 'Zyneatha', 'New Phoenix', 'Vexithunhal', 'New Dallas', 'Kordrasul'],
|
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);
|
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
|
* 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) ⇒
|
* distinct names (until the bank is exhausted). Pure function of (rng) ⇒
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue