Add stars as central bodies of non-home systems and fix home-system iden
- Introduce Star entity (procedural texture per spectral class) drawn at the origin of every non-home system; only the starting system's central body is the home world
- Freeze `homeSystemId` at roster build so lazily generated content for newly arrived systems no longer misreads as home and deals a second home world
- Anchor tether-l1 builds on newly discovered worlds (effects.tether in data/builds.json) and re-materialize installed tethers after jump cuts / scene rebuilds
- Discovery now reports the central body in every system ("Home World" vs. spectral-class star label); stars are solid but not comms targets
- Add dev tools: nav-reach-probe (soft-lock analysis), star unit tests, tether-newworld browser test; fix landing video references and Phaser stubs
This commit is contained in:
parent
507d00f818
commit
0950b143f5
Binary file not shown.
Binary file not shown.
|
|
@ -41,7 +41,7 @@
|
|||
"builds": {
|
||||
"tether-l1": {
|
||||
"label": "Tether - Level 1",
|
||||
"description": "The world's first tether: a level-1 field (range 5120 px) holding the ship's travel rim around the planet. Every world starts with its tether already in place.",
|
||||
"description": "The world's first tether: a level-1 field (range 5120 px) anchoring the ship's travel rim around the planet. The home world ships with it already in place; on every other world this is the build that installs it.",
|
||||
"icon": "tether",
|
||||
"category": "planet",
|
||||
"targets": ["planet"],
|
||||
|
|
@ -50,7 +50,12 @@
|
|||
"requires": [],
|
||||
"repeatable": false,
|
||||
"starting": ["home"],
|
||||
"effects": {},
|
||||
"effects": {
|
||||
"tether": {
|
||||
"level": 1,
|
||||
"anchor": "target"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"color": "#00e5ff"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@
|
|||
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" },
|
||||
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" },
|
||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" },
|
||||
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
|
||||
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
|
||||
{ "land": "rocky-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
|
||||
"_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every other system's central body is its star (`star` below). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
|
||||
"texture": "assets/images/planets.png",
|
||||
"frameWidth": 1024,
|
||||
"frameHeight": 1024,
|
||||
|
|
@ -33,6 +33,21 @@
|
|||
"homePlanet": "terran",
|
||||
"homeName": "Terra",
|
||||
"homeTypeLabel": "Home World",
|
||||
"star": {
|
||||
"_comment": "The central body of a NON-HOME system — the system's star (content.star from the generator; class = the spectral type G/K/M/…). The starting system's central body is the home world (homePlanet) and only there — a star is drawn in every other system. size = the SOLID disc's diameter px (1024 = a Terran world; the halo is visual only, outside the keep-out). classColor = the disc's hue per spectral class (a white core melts into it, js/entities/Star.js). glow.radiusFactor = halo radius as a multiple of the disc radius; glow.alpha = halo strength at the rim.",
|
||||
"size": 1536,
|
||||
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" },
|
||||
"glow": { "radiusFactor": 1.55, "alpha": 0.5 }
|
||||
},
|
||||
"starTypeLabels": {
|
||||
"O": "O-type Star",
|
||||
"B": "B-type Star",
|
||||
"A": "A-type Star",
|
||||
"F": "F-type Star",
|
||||
"G": "G-type Star",
|
||||
"K": "K-type Star",
|
||||
"M": "M-type Star"
|
||||
},
|
||||
"shipClearance": 50,
|
||||
"spawnDistanceFromEdge": 150,
|
||||
"solarSystem": {
|
||||
|
|
|
|||
|
|
@ -407,6 +407,30 @@ let big;
|
|||
const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id);
|
||||
check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.id)));
|
||||
|
||||
// REGRESSION (the "Home World in a non-home system" bug): the player's
|
||||
// position moves (jumps + saves write galaxy.currentSystemId = dest),
|
||||
// and content is generated LAZILY on arrival — so the isHome rule must
|
||||
// key off the frozen starting system (galaxy.homeSystemId), not the
|
||||
// movable currentSystemId. Move the pointer to a non-home system and
|
||||
// generate its content there: it must still have no home world, while
|
||||
// the starting system keeps its home world.
|
||||
const visited = big2.records.find((r) => r.id !== homeRec.id);
|
||||
big2.currentSystemId = visited.id; // what prepareLoad()/a jump does
|
||||
check(
|
||||
'homeSystemId froze the starting system before the pointer moved',
|
||||
big2.homeSystemId === homeRec.id && big2.currentSystemId !== big2.homeSystemId,
|
||||
);
|
||||
check(
|
||||
'a non-home system visited after the pointer moved still has no home world',
|
||||
!('homeName' in big2.ensureContent(visited.id)) && !('homeFrame' in big2.ensureContent(visited.id)),
|
||||
);
|
||||
check(
|
||||
'the starting system keeps its home world regardless of the pointer',
|
||||
typeof big2.ensureContent(homeRec.id).homeName === 'string' &&
|
||||
big2.isHomeSystem(homeRec.id) === true &&
|
||||
big2.isHomeSystem(visited.id) === false,
|
||||
);
|
||||
|
||||
// The starting system always holds exactly three planets: the home world
|
||||
// (the origin, not a generated planet) + a gas giant + a rocky world —
|
||||
// both generated worlds settled (gas ⇒ cloud base, rocky ⇒ colony or
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const ClassStub = class {};
|
|||
globalThis.window = {
|
||||
Phaser: {
|
||||
Scene: ClassStub,
|
||||
GameObjects: { Sprite: ClassStub, Container: ClassStub },
|
||||
GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub },
|
||||
Physics: { Arcade: { Sprite: ClassStub } },
|
||||
Geom: { Rectangle: class {} },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* dev/nav-reach-probe.mjs — soft-lock probe (path-aware): which systems'
|
||||
* NAV charts can NEVER be completed under the game's actual progression?
|
||||
*
|
||||
* node dev/nav-reach-probe.mjs [seed ...]
|
||||
*
|
||||
* Model (mirrors GameScene + SystemCategory):
|
||||
* - To jump Y→X the player must have RESEARCHED Y's gate tech, so every
|
||||
* system jumped OUT of is researched; arrival at X from Y ⇒ Y (and
|
||||
* everything before it on the path, back to home) is researched.
|
||||
* - On entry, X's ACTIVE gates = the gates from X to any researched
|
||||
* system (those return gates were flipped by the neighbor's tech).
|
||||
* - The player's room to move = disc(star, 5120) ∪ disc(activeGate, 5120)
|
||||
* for each active gate. A NAV point is discoverable iff that union
|
||||
* comes within (radius + 540) of the point.
|
||||
* - A system is clearable iff SOME researched neighbor lets it chart.
|
||||
* Clear systems stay clear (they become researched and only add more
|
||||
* active gates for their neighbors).
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(join(root, 'data', 'manifest.json'), 'utf8'));
|
||||
const files = {};
|
||||
for (const f of manifest.files) files[f.split('/').pop().replace('.json', '')] = JSON.parse(fs.readFileSync(join(root, 'data', f), 'utf8'));
|
||||
const { config } = await import(join(root, 'js', 'config', 'Config.js'));
|
||||
config.init(files);
|
||||
|
||||
const { Galaxy } = await import(join(root, 'js', 'galaxy', 'Galaxy.js'));
|
||||
const { navPoints } = await import(join(root, 'js', 'research', 'SystemCategory.js'));
|
||||
|
||||
const TETHER = config.get('tether.level1Radius', 5120);
|
||||
const DISC = config.get('game.discovery.distance', 540);
|
||||
const GATE_R = config.get('gates.size', 96);
|
||||
const STAR_R = config.get('planets.star.size', 1536) / 2;
|
||||
const homeTether = TETHER * Math.pow(config.get('tether.radiusGrowth', 2), Math.max(1, config.get('tether.homeLevel', 1)) - 1);
|
||||
|
||||
function systemOf(galaxy, id) {
|
||||
const content = galaxy.ensureContent(id);
|
||||
const rec = galaxy.byId.get(id);
|
||||
const pts = navPoints(content).map((p) => {
|
||||
let pos = { x: 0, y: 0 };
|
||||
let r = STAR_R;
|
||||
if (p.kind === 'planet') {
|
||||
const q = content.planets.find((x) => x.name === p.id);
|
||||
pos = q; r = (1024 * (config.get(`planets.classScale.${q.class}`, 1))) / 2;
|
||||
} else if (p.kind === 'gate') {
|
||||
pos = content.jumps.find((j) => j.id === p.id); r = GATE_R;
|
||||
} else if (p.kind === 'station') {
|
||||
pos = content.settlements.find((s) => s.id === p.id); r = 108;
|
||||
}
|
||||
return { id: p.id, kind: p.kind, x: pos.x, y: pos.y, r };
|
||||
});
|
||||
const gates = (content.jumps ?? []).filter((j) => typeof j.x === 'number');
|
||||
return {
|
||||
id,
|
||||
name: rec.name,
|
||||
isHome: id === galaxy.homeSystemId,
|
||||
planets: content.planets.length,
|
||||
stations: (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
|
||||
gates,
|
||||
asteroids: (content.asteroids ?? []).length,
|
||||
pts,
|
||||
neighbors: gates.map((g) => g.to),
|
||||
};
|
||||
}
|
||||
|
||||
function chartable(sys, researched) {
|
||||
const active = sys.gates.filter((g) => researched.has(g.to));
|
||||
const discs = [{ x: 0, y: 0, r: sys.isHome ? homeTether : TETHER }, ...active.map((g) => ({ x: g.x, y: g.y, r: TETHER }))];
|
||||
const missing = sys.pts.filter((p) => {
|
||||
let best = Infinity;
|
||||
for (const c of discs) {
|
||||
const d = Math.hypot(p.x - c.x, p.y - c.y) - c.r;
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best > p.r + DISC;
|
||||
});
|
||||
return { chartable: missing.length === 0, missing, activeCount: active.length };
|
||||
}
|
||||
|
||||
const seeds = process.argv.slice(2).length ? process.argv.slice(2) : ['probe1'];
|
||||
for (const seed of seeds) {
|
||||
const galaxy = Galaxy.create(seed, { systemCount: 200 });
|
||||
const all = [...galaxy.byId.keys()].map((id) => systemOf(galaxy, id));
|
||||
const byId = new Map(all.map((s) => [s.id, s]));
|
||||
|
||||
// Progression: BFS of clearable systems, researching each as it clears.
|
||||
const researched = new Set([galaxy.homeSystemId]);
|
||||
let frontier = [galaxy.homeSystemId];
|
||||
while (frontier.length) {
|
||||
const next = [];
|
||||
for (const id of frontier) {
|
||||
for (const s of all) {
|
||||
if (s.isHome || researched.has(s.id)) continue;
|
||||
if (!s.neighbors.includes(id)) continue; // entry neighbor
|
||||
const r2 = new Set(researched);
|
||||
if (!r2.has(id)) r2.add(id);
|
||||
if (chartable(s, r2).chartable) {
|
||||
researched.add(s.id);
|
||||
next.push(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
const stuck = all.filter((s) => !s.isHome && !researched.has(s.id));
|
||||
console.log(`\n=== seed ${seed}: ${researched.size - 1}/${all.length - 1} non-home systems clearable, ${stuck.length} stuck ===`);
|
||||
for (const s of stuck.slice(0, 8)) {
|
||||
// best explanation: via which neighbor, which points remain missing
|
||||
let best = null;
|
||||
for (const n of new Set(s.neighbors)) {
|
||||
if (!byId.has(n)) continue;
|
||||
const r2 = new Set(researched);
|
||||
if (!r2.has(n)) r2.add(n);
|
||||
const c = chartable(s, r2);
|
||||
if (!best || c.missing.length < best.missing.length) best = { via: n, ...c };
|
||||
}
|
||||
console.log(
|
||||
` ${s.id}: planets=${s.planets} stations=${s.stations} gates=${s.gates.length} asteroids=${s.asteroids}` +
|
||||
` → best entry via ${best?.via}: missing=[${best?.missing.map((m) => m.kind).join(',')}] activeGates=${best?.activeCount}`
|
||||
);
|
||||
}
|
||||
// Also: systems matching "1 planet, 3 gates" (the user's shape)
|
||||
const one = all.filter((s) => !s.isHome && s.planets === 1 && s.stations === 0 && s.gates.length === 3);
|
||||
console.log(` systems with 1 planet + 3 gates: ${one.map((s) => `${s.id}(stuck=${!researched.has(s.id)})`).join(' ')}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Star test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node dev/star.test.mjs
|
||||
*
|
||||
* Stubs just enough of Phaser to construct the REAL Star from
|
||||
* js/entities/Star.js against the real data/planets.json config, then
|
||||
* asserts the central-body rules (the star is the central body of every
|
||||
* NON-HOME system — see js/scenes/GameScene.js):
|
||||
* - the solid rim is half the configured star.size (1024-class worlds,
|
||||
* a bigger star: 1536 px across by default);
|
||||
* - the ship can come to shipClearance px (edge-to-edge) from the rim,
|
||||
* but never closer, and never moves through the star;
|
||||
* - a graze keeps its tangential velocity (slides along the rim);
|
||||
* - edgePoint() places a point exactly `gap` off the rim;
|
||||
* - one texture per spectral class (generated once, keyed star-<cls>).
|
||||
*/
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Phaser stub: Image + Graphics + Display ------------------------------
|
||||
class Image {
|
||||
constructor(scene, x, y, key, frame) {
|
||||
this.scene = scene; this.x = x; this.y = y;
|
||||
this.key = key; this.frame = frame;
|
||||
this.scaleX = 1; this.scaleY = 1;
|
||||
this.scale = 1;
|
||||
}
|
||||
setScale(s) { this.scaleX = s; this.scaleY = s; this.scale = s; return this; }
|
||||
}
|
||||
const Sprite = class extends Image {};
|
||||
class Graphics {
|
||||
fillStyle() { return this; }
|
||||
fillCircle() { return this; }
|
||||
generateTexture(key, w, h) { made.push({ key, w, h }); return this; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
function hexToRgbInt(v) {
|
||||
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
|
||||
return m ? parseInt(m[1], 16) : 0xffffff;
|
||||
}
|
||||
const made = []; // generateTexture calls
|
||||
globalThis.window = {
|
||||
Phaser: {
|
||||
GameObjects: { Image, Sprite },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
|
||||
},
|
||||
};
|
||||
|
||||
// --- 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);
|
||||
console.log('config loaded from data/:', Object.keys(configData).join(', '));
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const { Star } = await import(pathToFileURL(join(__dirname, '../js/entities/Star.js')).href);
|
||||
|
||||
// A fake scene: textures.exists + make.graphics + add.existing.
|
||||
const scene = {
|
||||
add: { existing: (o) => o },
|
||||
textures: { exists: (k) => made.some((t) => t.key === k) },
|
||||
make: { graphics: ({ add } = {}) => new Graphics() },
|
||||
};
|
||||
|
||||
// --- 1. Real config: solid rim + keep-out distances -----------------------
|
||||
// Off-origin on purpose: proves nothing is baked to (0, 0).
|
||||
const star = new Star(scene, 100, -50, { name: 'Kestrel', class: 'k' });
|
||||
const wantSize = config.get('planets.star.size', 1536);
|
||||
const wantRim = wantSize / 2;
|
||||
|
||||
check(`star rim is half star.size (got ${star.radius}, want ${wantRim})`, star.radius === wantRim);
|
||||
check(`default star is 1536 px across (got ${star.size})`, star.size === 1536);
|
||||
check(`texture key is per-class (got "${star.key}", want "star-k")`, star.key === 'star-k');
|
||||
check(`class is normalized to uppercase (got "${star.starClass}")`, star.starClass === 'K');
|
||||
|
||||
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
|
||||
const clearance = star.clearance;
|
||||
const minDist = star.minCenterDistance(shipRadius);
|
||||
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
|
||||
const dist = (p) => Math.hypot(p.x - star.x, p.y - star.y);
|
||||
|
||||
check(`keep-out = rim + clearance + ship radius (got ${minDist}, want ${wantRim + clearance + shipRadius})`,
|
||||
minDist === wantRim + clearance + shipRadius);
|
||||
|
||||
// --- 2. Outside the keep-out circle: untouched ----------------------------
|
||||
{
|
||||
const s = ship(star.x + minDist + 10, star.y);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('outside the keep-out circle: ship untouched',
|
||||
s.x === star.x + minDist + 10 && s.y === star.y);
|
||||
}
|
||||
|
||||
// Exactly on the boundary: allowed.
|
||||
{
|
||||
const s = ship(star.x + minDist, star.y, -100, 0); // on the line, moving in
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('on the boundary with inward velocity: position held, inward velocity removed',
|
||||
dist(s) === minDist && s.body.velocity.x >= 0);
|
||||
}
|
||||
|
||||
// Deep inside: pushed out to the rim line.
|
||||
{
|
||||
const s = ship(star.x + 10, star.y, -200, 0);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('inside the keep-out circle: pushed out to the rim line',
|
||||
Math.abs(dist(s) - minDist) < 1e-9);
|
||||
}
|
||||
|
||||
// A graze on the rim keeps its tangential velocity (slides along the rim),
|
||||
// and the inward part of any velocity is stripped.
|
||||
{
|
||||
const s = ship(star.x + minDist, star.y, -50, 300); // on the line, partly along the rim
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('graze: tangential velocity kept, inward part stripped (got vx=' + s.body.velocity.x + ', vy=' + s.body.velocity.y + ')',
|
||||
s.body.velocity.x === 0 && s.body.velocity.y === 300 && dist(s) === minDist);
|
||||
}
|
||||
|
||||
// Dead center: pushed out along +x (deterministic).
|
||||
{
|
||||
const s = ship(star.x, star.y, 0, 0);
|
||||
star.constrainShip(s, shipRadius);
|
||||
check('dead center: pushed out along +x', s.y === star.y && s.x > star.x);
|
||||
}
|
||||
|
||||
// --- 3. edgePoint / aimPoint ----------------------------------------------
|
||||
{
|
||||
const p = star.edgePoint(0.7, 150, shipRadius);
|
||||
check(`edgePoint sits rim + gap + ship off the rim (got ${dist(p).toFixed(3)})`,
|
||||
Math.abs(dist(p) - (wantRim + 150 + shipRadius)) < 1e-9);
|
||||
}
|
||||
{
|
||||
const inside = { x: star.x + 10, y: star.y };
|
||||
const out = star.aimPoint(inside.x, inside.y, shipRadius);
|
||||
check(`aimPoint projects inside points onto the rim (got ${dist(out).toFixed(3)})`,
|
||||
Math.abs(dist(out) - minDist) < 1e-9);
|
||||
const pas = star.aimPoint(star.x + minDist + 5, star.y, shipRadius);
|
||||
check('aimPoint passes outside points through unchanged',
|
||||
pas.x === star.x + minDist + 5 && pas.y === star.y);
|
||||
}
|
||||
|
||||
// --- 4. Textures: one per spectral class, generated once ------------------
|
||||
{
|
||||
const before = made.length;
|
||||
new Star(scene, 0, 0, { name: 'Sol', class: 'G' }); // star-g: fresh
|
||||
check('new class generates its texture (star-g present)', made.some((t) => t.key === 'star-g'));
|
||||
new Star(scene, 0, 0, { name: 'Betelgeuse', class: 'M' }); // star-m: fresh
|
||||
new Star(scene, 0, 0, { name: 'Kestrel', class: 'k' }); // star-k: already made
|
||||
const k = made.filter((t) => t.key === 'star-k').length;
|
||||
check('existing class reuses its texture (star-k generated exactly once, got ' + k + ')', k === 1);
|
||||
check('texture size = rim × 2 × glowFactor',
|
||||
made.filter((t) => t.key === 'star-m')[0].w === Math.ceil(wantRim * config.get('planets.star.glow.radiusFactor', 1.55)) * 2);
|
||||
check(`texture generation calls total ${before + 2} (star-g + star-m)`, made.length === before + 2);
|
||||
}
|
||||
|
||||
// Unknown spectral class: no crash, its own texture with the G fallback hue.
|
||||
{
|
||||
const s = new Star(scene, 0, 0, { name: 'X', class: 'zzz' });
|
||||
check('unknown class gets its own texture (star-zzz, G fallback hue)',
|
||||
s.key === 'star-zzz' && made.some((t) => t.key === 'star-zzz'));
|
||||
const s2 = new Star(scene, 0, 0, { name: 'Y', class: undefined });
|
||||
check('missing class defaults to G (got "' + s2.key + '")', s2.key === 'star-g');
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -39,7 +39,7 @@ const ClassStub = class {};
|
|||
const PhaserStub = {
|
||||
Scene: ClassStub,
|
||||
Physics: { Arcade: { Sprite: ClassStub } },
|
||||
GameObjects: { Sprite: ClassStub, Container: ClassStub },
|
||||
GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub },
|
||||
Geom: { Rectangle: class {} },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
Math: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev tether-l1 new-world build flow test</title>
|
||||
<base href="../" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/tether-newworld.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Dev-only: TETHER-L1 BUILD FLOW ON A NEWLY DISCOVERED WORLD — the bug
|
||||
* report: land on a world with NO tether, build Tether - Level 1, and the
|
||||
* world should actually GAIN a level-1 tether (range 5120 px anchored on
|
||||
* its center), show "Tether Level 1", and then accept Tether - Level 2
|
||||
* (which upgrades the SAME tether to 10240 px). Also checks the
|
||||
* re-materialization: a scene rebuild (jump cut / restart) that empties
|
||||
* the field to its native set must re-anchor the installed tether on its
|
||||
* world (js/scenes/GameScene.js → _rematerializeBuiltTethers).
|
||||
*
|
||||
* node dev/server.mjs 8080
|
||||
* node dev/cdp-firefox.mjs http://127.0.0.1:8080/dev/tether-newworld.html \
|
||||
* 'return window.__TETHER_NEWWORLD__;' 60000
|
||||
*/
|
||||
import Phaser from '../js/vendor/phaser.js';
|
||||
import { config } from '../js/config/Config.js';
|
||||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||||
import { createGameConfig } from '../js/config/GameConfig.js';
|
||||
import { MenuScene } from '../js/scenes/MenuScene.js';
|
||||
import { GameScene } from '../js/scenes/GameScene.js';
|
||||
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
|
||||
const errors = [];
|
||||
const origWarn = console.warn.bind(console);
|
||||
console.warn = (...a) => { errors.push(a.map(String).join(' ')); origWarn(...a); };
|
||||
const origErr = console.error.bind(console);
|
||||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
||||
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
|
||||
|
||||
const game = new Phaser.Game({ ...createGameConfig(), scene: [MenuScene, GameScene, SurfaceScene] });
|
||||
window.game = game;
|
||||
|
||||
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function waitFor(fn, tries = 200, everyMs = 100) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await sleepMs(everyMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tetherFor = (gs, name) =>
|
||||
gs.tetherField?.tethers?.find((t) => t.label === name) ?? null;
|
||||
const tetherInfo = (t) => (t ? { id: t.id, x: t.x, y: t.y, level: t.level, radius: t.radius } : null);
|
||||
|
||||
try {
|
||||
// 1) Menu → NEW GAME (the real entry).
|
||||
const menu = await waitFor(() => game.scene.getScene('MenuScene')?.newGameBtn ? game.scene.getScene('MenuScene') : null);
|
||||
if (!menu) throw new Error('menu never booted');
|
||||
menu.startNewGame();
|
||||
const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship && g?.buildState && g?.tetherField ? g : null; }, 300);
|
||||
if (!gs) throw new Error('GameScene never booted');
|
||||
await sleepMs(500);
|
||||
|
||||
// 2) Pick a NON-HOME world in the starting system (the bug's setting:
|
||||
// a newly discovered world, which has no tether of its own).
|
||||
const other = gs.systemPlanets?.[0];
|
||||
if (!other) throw new Error('starting system has no non-home planet to test on');
|
||||
const name = other.discoveryName;
|
||||
const before = tetherFor(gs, name);
|
||||
if (before) throw new Error(`world "${name}" unexpectedly has a tether at start`);
|
||||
|
||||
// 3) Land on it the game's own way, wait for the surface stage.
|
||||
gs.startLanding(gs.commsTargetFor(other));
|
||||
const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.phase === 'surface' ? c : null; }, 150, 200);
|
||||
if (!ss) throw new Error('surface stage never reached');
|
||||
await sleepMs(300);
|
||||
|
||||
// 4) The build console's own rule pass agrees: L2 is gated on L1.
|
||||
const l2GateBefore = gs.tetherLevelFor(name);
|
||||
if (l2GateBefore !== 0) throw new Error(`tetherLevelFor("${name}") = ${l2GateBefore}, want 0 before the L1 build`);
|
||||
|
||||
// 5) Build Tether - Level 1 (free + instant per data/builds.json).
|
||||
const res1 = gs.beginBuild(ss.planetName, 'tether-l1', game.loop.now);
|
||||
if (!res1.ok) throw new Error(`L1 build refused: ${res1.reason}`);
|
||||
// The surface's own tick completes it (duration 0 → 1 ms) and applies
|
||||
// the effect on the game scene; wait for the built record.
|
||||
await waitFor(() => gs.buildState.isBuilt(ss.planetName, 'tether-l1'), 60, 50);
|
||||
if (!gs.buildState.isBuilt(ss.planetName, 'tether-l1')) throw new Error('L1 build never completed');
|
||||
await sleepMs(1500); // HUD re-decode + window repaint settle
|
||||
|
||||
const l1 = tetherFor(gs, name);
|
||||
if (!l1) throw new Error(`no tether anchored on "${name}" after the L1 build (the bug)`);
|
||||
if (l1.level !== 1) throw new Error(`L1 tether level = ${l1.level}, want 1`);
|
||||
if (l1.radius !== 5120) throw new Error(`L1 tether radius = ${l1.radius}, want 5120`);
|
||||
if (l1.x !== other.x || l1.y !== other.y) throw new Error(`L1 tether anchored at (${l1.x},${l1.y}), want the world's center (${other.x},${other.y})`);
|
||||
if (gs.tetherLevelFor(name) !== 1) throw new Error(`tetherLevelFor("${name}") = ${gs.tetherLevelFor(name)}, want 1 after the L1 build`);
|
||||
if (ss.tetherLevel !== 1) throw new Error(`surface HUD level = ${ss.tetherLevel}, want 1 after the L1 build`);
|
||||
|
||||
// 6) Now L2 must be ACCEPTED on this world (research gate + minerals).
|
||||
gs.researchState.unlock('exploration', 'tether_l2');
|
||||
gs.ship.setMinerals(200);
|
||||
const res2 = gs.beginBuild(ss.planetName, 'tether-l2', game.loop.now);
|
||||
if (!res2.ok) throw new Error(`L2 build refused after L1 installed: ${res2.reason}`);
|
||||
const active2 = gs.buildState.getActive();
|
||||
if (active2) active2.startedAt -= active2.durationMs; // fast-forward the 20 s
|
||||
await waitFor(() => gs.buildState.isBuilt(ss.planetName, 'tether-l2'), 60, 50);
|
||||
if (!gs.buildState.isBuilt(ss.planetName, 'tether-l2')) throw new Error('L2 build never completed');
|
||||
await sleepMs(1500);
|
||||
|
||||
const l2 = tetherFor(gs, name);
|
||||
if (!l2) throw new Error('tether vanished after the L2 build');
|
||||
if (l2.id !== l1.id) throw new Error(`L2 upgraded a DIFFERENT tether (${l2.id} vs ${l1.id}) — want the same anchor strengthened`);
|
||||
if (l2.level !== 2) throw new Error(`L2 tether level = ${l2.level}, want 2`);
|
||||
if (l2.radius !== 10240) throw new Error(`L2 tether radius = ${l2.radius}, want 10240`);
|
||||
if (gs.tetherLevelFor(name) !== 2) throw new Error(`tetherLevelFor("${name}") = ${gs.tetherLevelFor(name)}, want 2 after the L2 build`);
|
||||
|
||||
// 7) Re-materialization: a scene rebuild empties the field to its
|
||||
// native set (home + activated gates). The installed tether is world
|
||||
// state — it must come back on its world at its installed level.
|
||||
gs.tetherField.remove(l1.id);
|
||||
if (tetherFor(gs, name)) throw new Error('removal failed (test premise)');
|
||||
gs._rematerializeBuiltTethers();
|
||||
const re = tetherFor(gs, name);
|
||||
if (!re) throw new Error('re-materialization did NOT re-anchor the installed tether');
|
||||
if (re.level !== 2) throw new Error(`re-materialized tether level = ${re.level}, want 2 (the installed max)`);
|
||||
if (re.x !== other.x || re.y !== other.y) throw new Error('re-materialized tether anchored on the wrong world');
|
||||
|
||||
const out = {
|
||||
world: name,
|
||||
l1: tetherInfo(l1),
|
||||
l2: tetherInfo(l2),
|
||||
rematerialized: tetherInfo(re),
|
||||
hudLine: ss.hudLine ? ss.hudLine.text : null,
|
||||
built: [gs.buildState.buildsOn(ss.planetName)],
|
||||
errors: errors.slice(0, 5),
|
||||
};
|
||||
window.__TETHER_NEWWORLD__ = out;
|
||||
console.info('tether-newworld:', JSON.stringify(out));
|
||||
} catch (err) {
|
||||
window.__TETHER_NEWWORLD__ = { fatal: String(err.message), errors: errors.slice(0, 5) };
|
||||
console.error('tether-newworld: fatal', err);
|
||||
}
|
||||
|
|
@ -229,9 +229,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
|
||||
`game.discovery.distance` (data/game.json, default 540 px) of an
|
||||
object's *edge* (center distance ≤ radius + distance) discovers it,
|
||||
once, per system. Home world is discovered at spawn (the ship starts
|
||||
beside it). Feedback: rim ping + "DISCOVERED — NAME · TYPE" toast
|
||||
(GameScene.celebrateDiscovery).
|
||||
once, per system. The central body is discovered at spawn in EVERY
|
||||
system: the home world (the ship starts beside it) in the starting
|
||||
system — "Home World" exists in exactly one system — and the system's
|
||||
STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one;
|
||||
both read the chart's central NAV point (id 'home'). Feedback: rim
|
||||
ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery).
|
||||
- **Compass** — `js/ui/DiscoveryCompass.js` (screen-space Container,
|
||||
scrollFactor 0). Every frame it refreshes the set of **discovered**
|
||||
objects that are **off-screen**, drawing a themed chevron arrow on the
|
||||
|
|
@ -239,9 +242,11 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
Edge-anchor geometry (`edgeAnchor`, `circleInView`, `lerpAngle`) is
|
||||
exported pure for tests. Arrow texture is generated procedurally on
|
||||
first use (`__compass_arrow`).
|
||||
- **World solids** — the ship collides with *every* system planet
|
||||
(`GameScene.solidPlanets`), not just home: you can approach a rim,
|
||||
never pass through.
|
||||
- **World solids** — the ship collides with *every* solid in the system
|
||||
(`GameScene.solids`): the central body (the home world in the starting
|
||||
system, the star elsewhere — both the 'home' NAV point), all system
|
||||
planets, asteroid clusters, stations and jump gates: you can approach
|
||||
a rim, never pass through.
|
||||
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
|
||||
state, JSON round-trip, layout band + determinism, compass geometry).
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { navChart } from '../research/SystemCategory.js';
|
|||
export const NAV_DIAG_V = 1;
|
||||
|
||||
const KIND_LABEL = {
|
||||
home: 'home world',
|
||||
home: 'central body',
|
||||
planet: 'planet',
|
||||
station: 'space station',
|
||||
gate: 'jump gate',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import Phaser from '../vendor/phaser.js';
|
||||
import { config } from '../config/Config.js';
|
||||
import { toColor } from '../utils/Color.js';
|
||||
import { Planet } from './Planet.js';
|
||||
|
||||
/**
|
||||
* A STAR — the central body of every NON-HOME system (content.star from
|
||||
* the generator: name + spectral class). The starting system's central
|
||||
* body is the player's home world (js/entities/Planet.js) and only there
|
||||
* — "Home World" exists in exactly one system in the galaxy.
|
||||
*
|
||||
* Solid like a planet: the keep-out circle is the disc rim (half the
|
||||
* configured size — the halo is visual only), the ship keeps
|
||||
* `planets.shipClearance` px (edge-to-edge) off it and can never move
|
||||
* through it (the same plain-circle rule, GameScene.solids). It is
|
||||
* discoverable (the 'home' NAV point of the chart — every system has a
|
||||
* central body) but NOT a comms target: there is no surface to land on.
|
||||
*
|
||||
* The look is procedural (data/planets.json → star): an opaque body —
|
||||
* a white core melting into the spectral class's hue — plus a soft halo,
|
||||
* band-filled with Graphics (same pattern as js/visuals/Starfield.js, so
|
||||
* no canvas API and no art asset). One texture per spectral class,
|
||||
* generated on first use.
|
||||
*/
|
||||
export class Star extends Phaser.GameObjects.Image {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x — world x of the star's center
|
||||
* @param {number} y — world y
|
||||
* @param {object} [star={}] — content.star ({ name, class })
|
||||
*/
|
||||
constructor(scene, x, y, star = {}) {
|
||||
const cls = String(star?.class ?? 'G').toUpperCase();
|
||||
const key = `star-${cls.toLowerCase()}`;
|
||||
if (!scene.textures.exists(key)) Star.makeTexture(scene, key, cls);
|
||||
super(scene, x, y, key);
|
||||
scene.add.existing(this);
|
||||
|
||||
this.starClass = cls;
|
||||
this.name = star?.name ?? ''; // display name (the scene stamps discoveryName)
|
||||
|
||||
// The solid disc: diameter from data/planets.json → star.size (the
|
||||
// halo extends past the rim but is not part of the keep-out).
|
||||
this.size = Math.max(64, Number(config.get('planets.star.size', 1536)) || 1536);
|
||||
this.setScale(1);
|
||||
this.radius = this.size / 2;
|
||||
// Edge-to-edge gap the ship may close in on the rim (never less).
|
||||
this.clearance = config.get('planets.shipClearance', 50);
|
||||
}
|
||||
|
||||
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
|
||||
minCenterDistance(shipRadius = 0) {
|
||||
return this.radius + this.clearance + shipRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* A world point `gap` px (edge-to-edge) off this star's rim at `angle`
|
||||
* radians — e.g. a spawn or approach point.
|
||||
*/
|
||||
edgePoint(angle, gap, shipRadius = 0) {
|
||||
const d = this.radius + gap + shipRadius;
|
||||
return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d };
|
||||
}
|
||||
|
||||
/**
|
||||
* A world point the ship may be sent to: a point inside the keep-out
|
||||
* circle (e.g. a click on the star itself, or through it) is projected
|
||||
* out onto the rim, along the ray from the center — so the ship always
|
||||
* has a reachable destination and is never told to go inside.
|
||||
*/
|
||||
aimPoint(wx, wy, shipRadius = 0) {
|
||||
const minDist = this.minCenterDistance(shipRadius);
|
||||
const dx = wx - this.x;
|
||||
const dy = wy - this.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist >= minDist) return { x: wx, y: wy };
|
||||
if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x
|
||||
return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a ship out of the star: same plain-circle rule as Planet —
|
||||
* push the center out to `minCenterDistance`, strip the inward part of
|
||||
* velocity and acceleration (the tangential part slides along the rim).
|
||||
*/
|
||||
constrainShip(ship, shipRadius = 0) {
|
||||
const body = ship.body;
|
||||
const r = Planet.resolve(
|
||||
this.x, this.y, this.minCenterDistance(shipRadius),
|
||||
ship.x, ship.y,
|
||||
body.velocity.x, body.velocity.y,
|
||||
body.acceleration ? body.acceleration.x : 0,
|
||||
body.acceleration ? body.acceleration.y : 0,
|
||||
);
|
||||
ship.x = r.x;
|
||||
ship.y = r.y;
|
||||
body.velocity.x = r.vx;
|
||||
body.velocity.y = r.vy;
|
||||
if (body.acceleration) {
|
||||
body.acceleration.x = r.ax;
|
||||
body.acceleration.y = r.ay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The star's texture for one spectral class: an opaque body (a white
|
||||
* core melting into the class hue, data/planets.json →
|
||||
* star.classColor) + a soft halo (star.glow). Band-filled circles,
|
||||
* outer → inner, via Graphics — no canvas API, one texture per class.
|
||||
*/
|
||||
static makeTexture(scene, key, cls) {
|
||||
const coreR = Math.max(32, Math.floor(config.get('planets.star.size', 1536) / 2));
|
||||
const glowFactor = Math.max(1, Number(config.get('planets.star.glow.radiusFactor', 1.55)) || 1.55);
|
||||
const glowAlpha = Math.min(1, Math.max(0, Number(config.get('planets.star.glow.alpha', 0.5)) || 0));
|
||||
const color = toColor(config.get(`planets.star.classColor.${cls}`, '#ffe9b0'));
|
||||
|
||||
const R = Math.ceil(coreR * glowFactor);
|
||||
const T = Math.ceil(R * 2);
|
||||
const c = T / 2;
|
||||
|
||||
// Blend the class hue toward white (t = 1 → white).
|
||||
const mix = (t) => {
|
||||
const ch = (sh) => {
|
||||
const v = (color >> sh) & 255;
|
||||
return Math.round(v + (255 - v) * t);
|
||||
};
|
||||
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
||||
};
|
||||
|
||||
const g = scene.make.graphics({ add: false });
|
||||
// Halo: rim → edge, alpha growing to the rim (0 at the edge).
|
||||
const H = 14;
|
||||
for (let i = 0; i < H; i++) {
|
||||
const t = i / (H - 1); // 0 = edge, 1 = rim
|
||||
g.fillStyle(color, glowAlpha * t * t);
|
||||
g.fillCircle(c, c, R - (R - coreR) * t);
|
||||
}
|
||||
// Body: rim → core, opaque; the hue melts into a white core.
|
||||
const B = 24;
|
||||
for (let i = 0; i < B; i++) {
|
||||
const t = i / (B - 1); // 0 = rim, 1 = core
|
||||
g.fillStyle(mix(Math.pow(t, 1.4)), 1);
|
||||
g.fillCircle(c, c, coreR * (1 - t * 0.999));
|
||||
}
|
||||
g.generateTexture(key, T, T);
|
||||
g.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,14 @@ export class Galaxy {
|
|||
this.byId = new Map();
|
||||
this.contentCache = new Map();
|
||||
this.currentSystemId = null;
|
||||
// The STARTING system — the player's home port. Set once at roster
|
||||
// build and never moves: `currentSystemId` tracks where the player IS
|
||||
// NOW (jumps + saves move it), but the home world always belongs to
|
||||
// the starting system (SystemGenerator's isHome rule, GameScene's
|
||||
// home/star split). Without this split, lazily generated content for
|
||||
// a newly arrived system would compute isHome = true (currentSystemId
|
||||
// had already moved there) and deal it a home world of its own.
|
||||
this.homeSystemId = null;
|
||||
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
|
||||
this.homeWorldFrame = null; // the starting system's home world frame
|
||||
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
|
||||
|
|
@ -150,6 +158,9 @@ export class Galaxy {
|
|||
const policy = this.params.startingSystem?.policy ?? 'center';
|
||||
this.currentSystemId =
|
||||
policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id);
|
||||
// Frozen copy of the starting system — `currentSystemId` will track
|
||||
// the player from here on (jumps, saves); the home rules must not.
|
||||
this.homeSystemId = this.currentSystemId;
|
||||
|
||||
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
|
||||
// the eventual star map).
|
||||
|
|
@ -303,6 +314,13 @@ export class Galaxy {
|
|||
return this.byId.get(this.currentSystemId) ?? this.records[0];
|
||||
}
|
||||
|
||||
/** True when the system is the player's STARTING one — the only system
|
||||
* whose central body is the home world (a star, in every other). Stable
|
||||
* across jumps, unlike a `currentSystemId` comparison. */
|
||||
isHomeSystem(id) {
|
||||
return id === this.homeSystemId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Contents (level 2, lazy)
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
// density gradient, ≤ 2), the planets fill the rest: N − stations.
|
||||
// The roll lives on dedicated forks (rollSystemComposition), so the
|
||||
// galaxy-wide frame pass can reproduce it exactly.
|
||||
const isHome = record.id === galaxy?.currentSystemId;
|
||||
const isHome = record.id === galaxy?.homeSystemId;
|
||||
const spec = attr.settlements ?? {};
|
||||
const composition = rollSystemComposition(
|
||||
galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr,
|
||||
|
|
@ -719,7 +719,7 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
|
|||
const clusterCfg = cfg.cluster ?? {};
|
||||
const dist = cfg.distribution ?? {};
|
||||
const placement = cfg.placement ?? {};
|
||||
const isHome = record.id === galaxy?.currentSystemId;
|
||||
const isHome = record.id === galaxy?.homeSystemId;
|
||||
|
||||
// --- How many clusters: the inverse of the planet count ---------------
|
||||
const target = dist.targetObjects ?? 9;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
|
|||
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
|
||||
import { Ship } from '../entities/Ship.js';
|
||||
import { Planet } from '../entities/Planet.js';
|
||||
import { Star } from '../entities/Star.js';
|
||||
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
||||
import { Station } from '../entities/Station.js';
|
||||
import { JumpGate } from '../entities/JumpGate.js';
|
||||
|
|
@ -47,7 +48,7 @@ import {
|
|||
} from '../research/SystemCategory.js';
|
||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||
import { BuildState } from '../build/BuildState.js';
|
||||
import { startingPairs } from '../build/BuildModel.js';
|
||||
import { startingPairs, defById } from '../build/BuildModel.js';
|
||||
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
||||
|
|
@ -72,9 +73,10 @@ const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in
|
|||
const HUD_AUTO_COLLAPSE_MS = 10000;
|
||||
|
||||
/**
|
||||
* The game world (v0.3: the current system — the player's home world at the
|
||||
* origin plus the system's other worlds scattered around it, in open space).
|
||||
* Click anywhere to fly there.
|
||||
* The game world (v0.3: the current system — the central body at the
|
||||
* origin (the player's home world in the starting system, the system's
|
||||
* star in every other one) plus the system's other worlds scattered
|
||||
* around it, in open space). Click anywhere to fly there.
|
||||
*
|
||||
* Discovery: come within discovery distance (data/game.json) of a world's
|
||||
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
|
||||
|
|
@ -271,31 +273,54 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const j of this.systemContent.jumps ?? []) {
|
||||
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
|
||||
}
|
||||
// The home world's name — dealt from the planet name bank by the
|
||||
// generator (the starting system is the only one with a home world), so
|
||||
// it reads as a real place rather than a generic "Terra". Fallback keeps
|
||||
// the old configured name if the bank is ever unavailable.
|
||||
this.homeWorldName = this.systemContent.homeName || config.get('planets.homeName', 'Terra');
|
||||
// The CENTRAL BODY — one per system, always at the origin, always the
|
||||
// system's 'home' NAV point (js/research/SystemCategory.js → navPoints):
|
||||
// in the STARTING system it is the player's home world (the generator
|
||||
// deals its name — content.homeName — so it reads as a real place
|
||||
// rather than a generic "Terra"; the fallback keeps the old configured
|
||||
// name if the bank is ever unavailable); in every OTHER system it is
|
||||
// the system's STAR (content.star — name + spectral class). The home
|
||||
// world is the ONLY central body that is a world: only here is it a
|
||||
// comms target (settled, landable) and only here does it read "Home
|
||||
// World" — a star is not a comms object, and never "home".
|
||||
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
|
||||
// across jumps. Comparing against galaxy.currentSystemId would be
|
||||
// wrong: that pointer tracks where the player IS NOW, so every
|
||||
// freshly-arrived system would read as home.
|
||||
this.isHomeSystem = this.galaxy.isHomeSystem(this.systemRecord.id);
|
||||
this.homeWorldName = this.isHomeSystem
|
||||
? (this.systemContent.homeName || config.get('planets.homeName', 'Terra'))
|
||||
: null;
|
||||
this.createSystemHud();
|
||||
|
||||
// The home planet — the player's Terran world, always present in the
|
||||
// system they start in. It sits at the world origin. Which Terran face
|
||||
// it shows comes from the galaxy-wide frame pass (content.homeFrame —
|
||||
// the same (class, face) spreading as the planets), falling back to a
|
||||
// seed-deterministic pool pick, so the same galaxy always yields the
|
||||
// same home world.
|
||||
const homeName = config.get('planets.homePlanet', 'terran');
|
||||
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
|
||||
const homeFrame =
|
||||
typeof this.systemContent.homeFrame === 'number'
|
||||
? this.systemContent.homeFrame
|
||||
: Planet.frameFor(homeName, homeRng);
|
||||
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
|
||||
if (this.isHomeSystem) {
|
||||
// The home planet — the player's Terran world, present ONLY in the
|
||||
// system the player starts in. It sits at the world origin. Which
|
||||
// Terran face it shows comes from the galaxy-wide frame pass
|
||||
// (content.homeFrame — the same (class, face) spreading as the
|
||||
// planets), falling back to a seed-deterministic pool pick, so the
|
||||
// same galaxy always yields the same home world.
|
||||
const homeName = config.get('planets.homePlanet', 'terran');
|
||||
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
|
||||
const homeFrame =
|
||||
typeof this.systemContent.homeFrame === 'number'
|
||||
? this.systemContent.homeFrame
|
||||
: Planet.frameFor(homeName, homeRng);
|
||||
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
|
||||
this.planet.discoveryName = this.homeWorldName;
|
||||
} else {
|
||||
// The system's star — the central body of every other system
|
||||
// (content.star from the generator: name + spectral class).
|
||||
const star = this.systemContent.star ?? {};
|
||||
this.planet = new Star(this, 0, 0, star);
|
||||
this.planet.discoveryName = star.name ?? 'Star';
|
||||
}
|
||||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||||
|
||||
// The rest of the solar system — the generated worlds, placed by the
|
||||
// generator (data/planets.json → solarSystem) on orbits around the
|
||||
// home world. Same solid-disc rules as home: fly close, never through.
|
||||
// central body. Same solid-disc rules as the worlds: fly close,
|
||||
// never through.
|
||||
this.systemPlanets = [];
|
||||
if (config.get('planets.solarSystem.enabled', true)) {
|
||||
for (const rec of this.systemContent.planets ?? []) {
|
||||
|
|
@ -369,8 +394,10 @@ export class GameScene extends Phaser.Scene {
|
|||
// Ship constraint, click-to-fly clamping and autopilot all run
|
||||
// against this list.
|
||||
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
|
||||
// The central body's discovery id is 'home' in EVERY system — it is
|
||||
// the chart's central NAV point (SystemCategory.navPoints); its NAME
|
||||
// differs: the home world in the starting system, the star elsewhere.
|
||||
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.
|
||||
|
|
@ -410,9 +437,9 @@ export class GameScene extends Phaser.Scene {
|
|||
});
|
||||
this.tetherField.add(
|
||||
config.get('tether.homeId', 'home'),
|
||||
0, 0, // home world's center (it sits at the system origin)
|
||||
0, 0, // the central body's center (home world or star — the system origin)
|
||||
config.get('tether.homeLevel', 1),
|
||||
config.get('tether.homeLabel', '') || this.homeWorldName,
|
||||
config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName),
|
||||
);
|
||||
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
|
||||
// an active gate is a TETHER ANCHOR in its own right — the room to
|
||||
|
|
@ -606,6 +633,12 @@ export class GameScene extends Phaser.Scene {
|
|||
this.applyRestore(this._pendingRestore);
|
||||
this._pendingRestore = null;
|
||||
}
|
||||
// The field's native set (home + the activated gates) is above; the
|
||||
// tethers the player INSTALLED on this system's other worlds are
|
||||
// world state (built records) — re-anchor them on their worlds, since
|
||||
// a jump cut / scene restart rebuilds the field to the native set
|
||||
// only (idempotent: existing tethers are only ever strengthened).
|
||||
this._rematerializeBuiltTethers();
|
||||
|
||||
// Entering a system charts it: the SYSTEM category's '{System} Map'
|
||||
// tech (duration 0) is owned on arrival — a new run gets the starting
|
||||
|
|
@ -1303,13 +1336,18 @@ export class GameScene extends Phaser.Scene {
|
|||
/** The discoverable objects of the system, with compass metadata. */
|
||||
discoverableObjects() {
|
||||
const out = [];
|
||||
// The central body — the player's home world in the starting system,
|
||||
// the system's star in every other one (discovery id 'home' either
|
||||
// way: the chart's central NAV point).
|
||||
out.push({
|
||||
id: 'home',
|
||||
x: this.planet.x,
|
||||
y: this.planet.y,
|
||||
radius: this.planet.radius,
|
||||
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||||
name: this.homeWorldName,
|
||||
typeLabel: this.isHomeSystem
|
||||
? config.get('planets.homeTypeLabel', 'Home World')
|
||||
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'),
|
||||
name: this.planet.discoveryName,
|
||||
});
|
||||
for (const p of this.systemPlanets) {
|
||||
out.push({
|
||||
|
|
@ -1471,7 +1509,9 @@ export class GameScene extends Phaser.Scene {
|
|||
worldObjectAt(wx, wy) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const s of [this.planet, ...this.systemPlanets, ...this.systemStations]) {
|
||||
// The central body is a comms target only when it IS the home world —
|
||||
// the star (every other system) is not a comms object (like a gate).
|
||||
for (const s of [...(this.isHomeSystem ? [this.planet] : []), ...this.systemPlanets, ...this.systemStations]) {
|
||||
const r = s.radius + (s.clearance ?? 0);
|
||||
const d = Math.hypot(wx - s.x, wy - s.y);
|
||||
if (d <= r && d < bestD) {
|
||||
|
|
@ -1711,7 +1751,7 @@ export class GameScene extends Phaser.Scene {
|
|||
let key = null;
|
||||
let settled = false;
|
||||
let kindLabel = '';
|
||||
if (obj === this.planet) {
|
||||
if (obj === this.planet && this.isHomeSystem) {
|
||||
// The home world: standing pinned at the scale's top.
|
||||
key = HOME_KEY;
|
||||
settled = true;
|
||||
|
|
@ -2318,8 +2358,20 @@ export class GameScene extends Phaser.Scene {
|
|||
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
||||
});
|
||||
}
|
||||
} else if (obj && this.tetherField) {
|
||||
// The world's FIRST tether (the tether-l1 build on a newly
|
||||
// discovered world): anchor one on the world's center — the
|
||||
// player-owned anchor model (the field is the player's, the
|
||||
// world hosts it). Label = the world's name, so
|
||||
// tetherLevelFor / the surface HUD / the build gates see it,
|
||||
// and the save's tether list carries it with the run.
|
||||
this.tetherField.add(`planet:${planetName}`, obj.x, obj.y, lvl, planetName);
|
||||
this.consoleToast(`TETHER ANCHORED — LEVEL ${lvl}`, {
|
||||
glyph: '⌖',
|
||||
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
||||
});
|
||||
} else {
|
||||
console.warn(`[orbit] build: no tether on "${planetName}" to strengthen`, spec);
|
||||
console.warn(`[orbit] build: world "${planetName}" not found — cannot anchor its tether`, spec);
|
||||
}
|
||||
} else if (type === 'capability' && typeof spec === 'string') {
|
||||
this.researchCapabilities = this.researchCapabilities ?? new Set();
|
||||
|
|
@ -2602,11 +2654,52 @@ export class GameScene extends Phaser.Scene {
|
|||
*/
|
||||
_seedStartingBuilds() {
|
||||
for (const [planet, build] of startingPairs()) {
|
||||
const name = planet === 'home' ? this.planet?.discoveryName : planet;
|
||||
// 'home' is the player's home world — the starting system's central
|
||||
// body only. In every other system the central body is the star,
|
||||
// whose origin tether is the scene's native set (added in create),
|
||||
// not a build — so the seed does not apply there.
|
||||
const name = planet === 'home'
|
||||
? (this.isHomeSystem ? this.planet?.discoveryName : null)
|
||||
: planet;
|
||||
if (name) this.buildState.markBuilt(name, build);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor the tethers the player has INSTALLED on this system's
|
||||
* worlds (a build's `effects.tether`, data/builds.json) whenever the
|
||||
* field is rebuilt to its native set — a jump cut stages an empty
|
||||
* tether list (rec.tethers = []) and the scene restart re-forms home +
|
||||
* the activated gates only, while the installed tethers (and their
|
||||
* built records) outlive the cut. Idempotent: the installed level is
|
||||
* the MAX of the world's tether builds (level-1 + level-2 ⇒ level 2);
|
||||
* an existing tether is only ever strengthened, never created twice or
|
||||
* downgraded.
|
||||
*/
|
||||
_rematerializeBuiltTethers() {
|
||||
if (!this.buildState || !this.tetherField) return;
|
||||
for (const [planetName, builds] of this.buildState.built.entries()) {
|
||||
const world =
|
||||
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
|
||||
(planetName === this.planet?.discoveryName ? this.planet : null);
|
||||
if (!world) continue; // another system's world — re-anchors on the way back
|
||||
let level = 0;
|
||||
for (const id of builds) {
|
||||
const lvl = Number(defById(id)?.effects?.tether?.level);
|
||||
if (Number.isFinite(lvl) && lvl > level) level = lvl;
|
||||
}
|
||||
if (!level) continue;
|
||||
const t = this.tetherField.tethers.find(
|
||||
(tt) => tt.label === planetName || (tt.x === world.x && tt.y === world.y),
|
||||
);
|
||||
if (!t) {
|
||||
this.tetherField.add(`planet:${planetName}`, world.x, world.y, level, planetName);
|
||||
} else if (t.level < level) {
|
||||
this.tetherField.setLevel(t.id, level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyRestore(r) {
|
||||
if (r.ship) {
|
||||
this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0);
|
||||
|
|
|
|||
|
|
@ -879,6 +879,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`);
|
||||
this.playSfx('discovery'); // the 'something new is here' voice
|
||||
this.refreshHudTetherLevel(time); // the HUD's level line is a landing snapshot
|
||||
this.buildWindow?.refresh(); // rows repaint — the world's tether level changed, so the next tier's gate may be open now
|
||||
}
|
||||
}
|
||||
this.buildWindow?.update(time);
|
||||
|
|
|
|||
Loading…
Reference in New Issue