diff --git a/assets/images/originals/laval-surface-02.png b/assets/images/originals/lava-surface-02.png similarity index 100% rename from assets/images/originals/laval-surface-02.png rename to assets/images/originals/lava-surface-02.png diff --git a/assets/videos/lava-surface-01.mp4 b/assets/videos/lava-surface-01.mp4 new file mode 100644 index 0000000..acbdefb Binary files /dev/null and b/assets/videos/lava-surface-01.mp4 differ diff --git a/data/gates.json b/data/gates.json index 9e04962..f3e3729 100644 --- a/data/gates.json +++ b/data/gates.json @@ -59,10 +59,26 @@ "miningToast": "CANNOT JUMP WHILE MINING" }, "route": { - "_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}).", + "_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). The NEXT STOP in this system ALSO wears an in-world orange pulse the moment it is on screen (js/entities/RoutePulse.js — GameScene marks it each frame): the FULL pulse on the route GATE (pulse), the SAME pulse SUBDUED on the destination OBJECT in the destination system (objectPulse). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}).", "compassColor": "#ff8c1a", "typeLabel": "Route Gate", "destTypeLabel": "Destination", + "pulse": { + "_comment": "ROUTE GATE PULSE (js/entities/RoutePulse.js via JumpGate.setRouteTarget): while a destination is set and the route's next stop in the CURRENT system is a jump gate (GameScene.routeNextGate), that gate wears this animated pulse around its keepout ring — the in-world 'your destination is this gate' mark, visible the moment the gate is on screen (the compass's orange arrow is the off-screen half). A steady target ring at baseRadius × the gate's keepout size, plus two radar ripples (staggered half a period) that expand a further ×expand past it and fade out; one ripple cycle every period seconds. The color is compassColor (the route's orange). A DORMANT gate gets the pulse too — the player still has to find the gate to use it. The destination OBJECT (planet/space station) wears the SAME pulse in a subdued form — see objectPulse.", + "baseRadius": 1.18, + "expand": 0.55, + "period": 1.6, + "ringAlpha": 0.5, + "rippleAlpha": 0.85 + }, + "objectPulse": { + "_comment": "DESTINATION OBJECT PULSE (js/entities/RoutePulse.js via Planet.setRouteTarget / Station.setRouteTarget): in the DESTINATION system the route's last stop is the destination OBJECT (a planet or a space station — GameScene._routeDestinationEntity). It wears the SAME pulse as the route gate, SUBDUED: the same shape + timing language, but lower alpha, a smaller reach and a slower cycle — 'the place to go', without shouting like the gate's full pulse (pulse). The geometry still scales with the object's keepout radius (a small waypoint and a gas giant read the same).", + "baseRadius": 1.12, + "expand": 0.4, + "period": 1.8, + "ringAlpha": 0.35, + "rippleAlpha": 0.55 + }, "setToast": "DESTINATION SET — {dest} · {hops} HOP(S)", "reachedToast": "DESTINATION REACHED — {dest}", "replotToast": "ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}" diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs index 438c8dd..999d7d1 100644 --- a/dev/jumpgate.test.mjs +++ b/dev/jumpgate.test.mjs @@ -18,6 +18,10 @@ * (size×2)/frameWidth (the ring's outer edge on the keepout disc); * - the FALLBACK renderer (sheet missing): the old procedural gate — * dormant dim (0.4), dormant field still, active field breathes; + * - the ROUTE-TARGET pulse (data/gates.json → route.pulse): hidden by + * default; setRouteTarget(true) shows it, update() animates the + * steady ring + two expanding/fading ripples outside the keepout + * (even on a dormant gate), setRouteTarget(false) clears + hides it; * - the SOLID contract (same as Planet/Station): minCenterDistance, * edgePoint exactly `gap` past the surface, aimPoint clamping, and * constrainShip pushing the ship out of the keepout circle @@ -57,8 +61,17 @@ class GameObject { } class Container extends GameObject {} class Sprite extends GameObject {} + +// toColor (js/utils/Color.js) — a real value→int conversion, like the browser's +const hexToRgbInt = (v) => { + const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); + return m ? parseInt(m[1], 16) : 0xffffff; +}; globalThis.window = { - Phaser: { GameObjects: { Container, Sprite } }, // js/vendor/phaser.js reads this + Phaser: { + GameObjects: { Container, Sprite }, // js/vendor/phaser.js reads this + Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, + }, }; // --- Load the real config (data/*.json) into the config singleton -------- @@ -86,6 +99,7 @@ const { JumpGate } = await import(pathToFileURL(join(__dirname, '../js/entities/ let texturesPresent = true; const images = []; const alphas = []; +const pulseStrokes = []; // strokeCircle radii since the last clear() (all graphics) const scene = { textures: { exists: () => texturesPresent }, add: { @@ -105,11 +119,14 @@ const scene = { return img; }, graphics: () => ({ + setPosition() { return this; }, + setDepth() { return this; }, lineStyle() { return this; }, - strokeCircle() { return this; }, + strokeCircle(_x, _y, r) { pulseStrokes.push(r); return this; }, fillStyle() { return this; }, fillCircle() { return this; }, lineBetween() { return this; }, + clear() { pulseStrokes.length = 0; return this; }, }), circle: (x, y, r, fill, alpha) => { const c = { x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; } }; @@ -171,6 +188,27 @@ gateB.update(3000); check('active gate: the swirl spins (rotation increases with time — clockwise)', r1 > 0 && swirlB.rotation > r1); +// --- The route-target pulse (the destination's WAYPOINT mark) ----------- +const bR = config.get('gates.route.pulse.baseRadius', 1.18); +check('fresh gate: no route pulse by default', gate.routePulse.on === false && gate.routePulse.ring.visible === false); +gate.setRouteTarget(true); +check('setRouteTarget(true): the pulse ring is shown', gate.routePulse.on === true && gate.routePulse.ring.visible === true); +gate.update(1000); +check('the pulse draws the steady ring + two ripples (3 circles)', pulseStrokes.length === 3); +check('the steady ring sits at baseRadius × the keepout size', Math.abs(pulseStrokes[0] - gate.size * bR) < 1e-6); +check('every pulse circle stays outside the keepout disc', pulseStrokes.every((r) => r >= gate.size * bR - 1e-6)); +const snap = pulseStrokes.slice(); +gate.update(1800); // a third of the 1.6 s period later — different ripple phases +check('the pulse animates over time (the ripple radii move)', + pulseStrokes.length === 3 && (pulseStrokes[1] !== snap[1] || pulseStrokes[2] !== snap[2])); +gate.setRouteTarget(false); +check('setRouteTarget(false): the pulse is cleared + hidden', + gate.routePulse.on === false && gate.routePulse.ring.visible === false && pulseStrokes.length === 0); +gate.setRouteTarget(true); +gate.setRouteTarget(true); +check('setRouteTarget(true) twice is a no-op (idempotent)', gate.routePulse.on === true && gate.routePulse.ring.visible === true); +gate.setRouteTarget(false); + // --- The solid contract (same rules as Planet / Station) ------------------ const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; const minDist = gate.minCenterDistance(shipRadius); diff --git a/dev/planet.test.mjs b/dev/planet.test.mjs index be50a32..66eb516 100644 --- a/dev/planet.test.mjs +++ b/dev/planet.test.mjs @@ -25,8 +25,20 @@ class Sprite { this.scaleX = 1; this.scaleY = 1; } setScale(s) { this.scaleX = s; this.scaleY = s; return this; } + setTint() { return this; } } -globalThis.window = { Phaser: { GameObjects: { Sprite } } }; // js/vendor/phaser.js reads this + +// toColor (js/utils/Color.js) — a real value→int conversion, like the browser's +const hexToRgbInt = (v) => { + const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); + return m ? parseInt(m[1], 16) : 0xffffff; +}; +globalThis.window = { + Phaser: { + GameObjects: { Sprite }, + Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, + }, +}; // js/vendor/phaser.js reads this // --- Load the real config (data/*.json) into the config singleton -------- const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); @@ -50,7 +62,20 @@ const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Pl const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href); // Off-origin on purpose: proves nothing is baked to (0, 0). -const planet = new Planet({ add: { existing: (o) => o } }, 100, -50, 0); +const pulseRadii = []; // recorded by the stub graphics — the route-pulse assertions key on these +const scene = { + add: { + existing: (o) => o, + graphics: () => ({ + setPosition() { return this; }, + setDepth() { return this; }, + lineStyle() { return this; }, + strokeCircle(_x, _y, r) { pulseRadii.push(r); return this; }, + clear() { pulseRadii.length = 0; return this; }, + }), + }, +}; +const planet = new Planet(scene, 100, -50, 0); const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; const clearance = planet.clearance; @@ -205,5 +230,23 @@ check(`keep-out = radius + clearance + ship radius (got ${minDist}, want ${512 + check(`edgePoint puts the ship exactly ${gap} px (edge-to-edge) off the rim, any direction`, ok); } +// --- The route-target pulse (the destination OBJECT mark) ---------------- +{ + const p = planet.routePulse; + check('fresh planet: the route pulse is off + hidden by default', + p.on === false && p.ring.visible === false); + planet.setRouteTarget(true); + check('setRouteTarget(true): the pulse is on + visible', p.on === true && p.ring.visible === true); + planet.update(1000); + const steady = 512 * (config.get('gates.route.objectPulse.baseRadius', 1.12)); + check(`planet pulse: steady ring + two ripples, all outside the keepout disc (radii ${pulseRadii.map((r) => r.toFixed(1)).join(', ')})`, + pulseRadii.length === 3 + && pulseRadii.every((r) => r >= planet.radius + 0.5) + && Math.abs(pulseRadii[0] - steady) < 1e-6); + planet.setRouteTarget(false); + check('setRouteTarget(false): cleared + hidden', + p.on === false && p.ring.visible === false && pulseRadii.length === 0); +} + console.log(failures === 0 ? '\nAll planet tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1); diff --git a/dev/route-pulse-live.html b/dev/route-pulse-live.html new file mode 100644 index 0000000..aaad44f --- /dev/null +++ b/dev/route-pulse-live.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Route pulse live check (dev) + + + + +
+ + + diff --git a/dev/route-pulse-live.mjs b/dev/route-pulse-live.mjs new file mode 100644 index 0000000..daf50d0 --- /dev/null +++ b/dev/route-pulse-live.mjs @@ -0,0 +1,215 @@ +/** + * Dev-only: boot GameScene and drive the ROUTE-PUSE marks end-to-end + * (js/entities/RoutePulse.js): + * Phase A — the route GATE: set a neighboring-system destination and + * assert the current system's route gate (routeNextGate) alone wears + * the pulse (the FULL cfg, route.pulse), planets/stations stay off. + * Phase B — the destination OBJECT: put the destination in the CURRENT + * system (the destination-system state) and assert the destination + * OBJECT alone (a planet or a space station) wears the pulse (the + * SUBDUED cfg, route.objectPulse), the gates stay off. + * Phase C — the pulse DROPS: clear the destination and assert nothing + * wears it any more. + * + * node dev/server.mjs 8084 + * node dev/cdp-firefox.mjs \ + * "http://127.0.0.1:8084/dev/route-pulse-live.html?seed=route-pulse-live-1" \ + * "window.__ROUTE_PULSE_LIVE" 40000 + * node dev/shot-firefox.mjs \ + * "http://127.0.0.1:8084/dev/route-pulse-live.html?seed=route-pulse-live-1&shot=gate" \ + * "window.__ROUTE_PULSE_LIVE ? window.__ROUTE_PULSE_LIVE.ready : null" \ + * route-pulse-gate.png 40000 + * (shot=gate | shot=object chooses which phase ends the run on screen) + */ +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 { GameScene } from '../js/scenes/GameScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +// Deterministic dev galaxy (same seed ⇒ same system + neighbors). +const params = new URLSearchParams(location.search); +const seedParam = params.get('seed'); +if (seedParam) globalThis.__ORBIT_DEV_SEED = seedParam; +const shotParam = params.get('shot'); // 'gate' | 'object' + +// Capture console errors + uncaught exceptions for the report. +const errors = []; +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 gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +const report = document.createElement('pre'); +report.id = 'report'; +report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;'; +document.body.appendChild(report); +const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); }; +setReport('booting…'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitScene() { + for (let i = 0; i < 100; i++) { + const s = game.scene.getScene('GameScene'); + if (s && s.ship && s.systemGates) return s; + await sleep(100); + } + throw new Error('GameScene never booted'); +} + +// Parity snapshot of the route-pulse state across all pulse-wearing +// entities (gates + planets + stations) — the assertions' subject. +function stateSnapshot(s) { + const ent = (e) => ({ + kind: e.routePulseKind ?? e.constructor.name, + id: e.discoveryId, + on: e.routePulse?.on, + visible: e.routePulse?.ring?.visible, + baseRadius: e.routePulse?.cfg?.baseRadius, + }); + return [ + ...s.systemGates.map((g) => ({ ...ent(g), kind: 'gate' })), + ...(s.planet ? [{ ...ent(s.planet), kind: 'home-planet' }] : []), + ...s.systemPlanets.map((p) => ({ ...ent(p), kind: 'planet' })), + ...s.systemStations.map((st) => ({ ...ent(st), kind: 'station' })), + ]; +} + +// Park the ship `gap` px (edge-to-edge) off the entity's keepout rim so +// the pulse is centered on screen, without tripping the destination +// "reached" window (keepout + 40). +function parkNear(s, entity, gap) { + const ship = s.ship; + const keepout = entity.minCenterDistance?.(ship.radius) ?? (entity.radius + (entity.clearance ?? 0) + ship.radius); + const d = keepout + gap; + ship.x = entity.x + d; // dead-right of the object — the pulse is symmetric + ship.y = entity.y; + ship.body?.setVelocity?.(0, 0); + ship.body?.setAcceleration?.(0, 0); +} + +const results = []; +const record = (label, cond, extra = '') => { + results.push({ label, ok: !!cond, extra }); + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${extra ? ' — ' + extra : ''}`); +}; + +try { + const s = await waitScene(); + await sleep(800); + const sysId = s.systemRecord.id; + const cur = s.galaxy.records.find((r) => r.id === sysId); + + // --- PHASE A: the route GATE wears the FULL pulse ---------------------- + const neighbor = s.systemGates[0]?.gate?.to ?? null; + const destContent = neighbor ? s.galaxy.contentOf(neighbor) : null; + const destObjId = (destContent?.planets ?? [])[0]?.name ?? null; + s.setDestination(neighbor, destObjId); + await sleep(300); + + const plan = s.routePlan(); + const gate = s.routeNextGate(); + const gates = s.systemGates; + const allOff = (e) => !e || !(e.routePulse?.on); + record('A: a route exists (multi-stop, the current system is intermediate)', + !!plan && plan.hops >= 1 && plan.next !== null, `hops=${plan?.hops} path=${plan?.path?.join('→')}`); + record('A: the route gate is one of this system\'s gates', gates.some((g) => g === gate)); + record('A: the route gate ALONE wears the pulse', + gates.length > 0 && !!gate && gate.routePulse.on === true + && gates.filter((g) => g && g.routePulse.on).length === 1, + `gates=${gates.length} on=${gates.filter((g) => g && g.routePulse.on).length}`); + record('A: no planet/station is marked in an intermediate system', + allOff(s.planet) && s.systemPlanets.every(allOff) && s.systemStations.every(allOff)); + record('A: the gate wears the FULL pulse cfg (route.pulse)', + gate.routePulse.cfg === config.section('gates.route.pulse', {}), + `baseRadius=${gate.routePulse.cfg.baseRadius}`); + const aSnap = stateSnapshot(s); + + // --- PHASE B: the destination OBJECT wears the SUBDUED pulse ---------- + // Destination-system state: the destination names an OBJECT in THIS + // system (what it looks like after the last jump lands). + const stations = s.systemStations; + const destEntity = stations[0] ?? s.systemPlanets[0] ?? s.planet; + const destId = destEntity?.discoveryId ?? null; + let bSnap = null; + if (destEntity) { + s.destination = { systemId: sysId, objectId: destId }; + s.registry.set('destination', s.destination); + await sleep(300); + + const bDest = s._routeDestinationEntity(); + record('B: _routeDestinationEntity resolves the destination OBJECT', + bDest === destEntity, `${destEntity.constructor.name} ${destId}`); + record('B: the destination object ALONE wears the pulse', + destEntity.routePulse.on === true + && gates.every(allOff) + && s.systemPlanets.filter((p) => p !== destEntity).every(allOff) + && (s.planet === destEntity ? s.planet.routePulse.on === true : allOff(s.planet)) + && stations.filter((e) => e !== destEntity).every(allOff), + `kind=${destEntity.constructor.name}`); + record('B: it wears the SUBDUED cfg (route.objectPulse)', + destEntity.routePulse.cfg === config.section('gates.route.objectPulse', {}), + `baseRadius=${destEntity.routePulse.cfg.baseRadius}`); + record('B: the subdued cfg is less intense than the gate\'s full cfg', + config.get('gates.route.objectPulse.ringAlpha', 1) < config.get('gates.route.pulse.ringAlpha', 0) + && config.get('gates.route.objectPulse.rippleAlpha', 1) < config.get('gates.route.pulse.rippleAlpha', 0)); + bSnap = stateSnapshot(s); + } else { + record('B: SKIPPED — this starting system has no planet/station to destination', true, 'no destination-object candidate here'); + } + + // --- PHASE C: the pulse DROPS when the route clears -------------------- + s.destination = null; + s.registry.set('destination', null); + await sleep(300); + record('C: with no destination, nothing wears the pulse', + gates.every(allOff) && s.systemPlanets.every(allOff) && allOff(s.planet) && stations.every(allOff)); + + const failures = results.filter((r) => !r.ok).length; + + // --- Screenshot parking (only for the shot= runs) ---------------------- + if (shotParam === 'gate') { + s.setDestination(neighbor, destObjId); // restore the A state + await sleep(200); + const g = s.routeNextGate(); + if (g) { parkNear(s, g, 60); await sleep(400); } + } else if (shotParam === 'object' && destEntity) { + s.destination = { systemId: sysId, objectId: destId }; + s.registry.set('destination', s.destination); + await sleep(200); + parkNear(s, destEntity, 150); + await sleep(400); + } + + window.__ROUTE_PULSE_LIVE = { + ready: true, + failures, + errors, + system: { id: sysId, name: cur?.name }, + neighbor: { id: neighbor, destObjId }, + destEntity: destEntity ? { id: destId, kind: destEntity.constructor.name } : null, + results, + phaseA: aSnap, + phaseB: bSnap, + }; + + setReport([ + `system: ${cur?.name} (${sysId}) neighbor: ${neighbor} (obj ${destObjId ?? 'none'})`, + `dest-object case: ${destEntity ? `${destEntity.constructor.name} ${destId}` : 'none'}`, + ...results.map((r) => `${r.ok ? '✔' : '✘'} ${r.label}${r.extra ? ' — ' + r.extra : ''}`), + `ERRORS: ${errors.length ? errors.join(' | ') : 'none'}`, + failures === 0 ? 'ALL ROUTE-PULSE LIVE CHECKS PASSED ✔' : `${failures} LIVE CHECK(S) FAILED ✘`, + ]); +} catch (e) { + setReport(['FATAL: ' + (e?.message ?? e), String(e?.stack ?? '').split('\n').slice(0, 6).join(' | '), ...errors]); + window.__ROUTE_PULSE_LIVE = { ready: true, errors: [...errors, String(e?.message ?? e)], stack: String(e?.stack ?? '').slice(0, 1200), failures: 1, results }; +} diff --git a/dev/route-pulse.test.mjs b/dev/route-pulse.test.mjs new file mode 100644 index 0000000..6c19db0 --- /dev/null +++ b/dev/route-pulse.test.mjs @@ -0,0 +1,201 @@ +/** + * Route-pulse test (dev tool, run with Node — no browser needed): + * + * node dev/route-pulse.test.mjs + * + * The shared route-target halo (js/entities/RoutePulse.js) — the in-world + * "your destination is HERE" mark worn by BOTH the route gate (full + * pulse, data/gates.json → route.pulse) and the destination OBJECT + * (planet / space station — the subdued pulse, route.objectPulse): + * - RoutePulse itself: show/hide, the steady ring + two ripples, + * geometry scaled by the object's keepout radius, ripple animation, + * idempotent toggling, cfg + color + position plumbing; + * - the data contract: the destination-object pulse is genuinely + * SUBDUED relative to the route-gate pulse (lower alphas); + * - the Station integration: setRouteTarget()/update() drive the same + * halo (the gate and planet integrations are covered by + * dev/jumpgate.test.mjs and dev/planet.test.mjs). + */ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Phaser stub: the Container base + a recording graphics -------------- +class GameObject { + constructor(scene, x = 0, y = 0) { + this.scene = scene; this.x = x; this.y = y; + this.rotation = 0; this.alpha = 1; this.visible = true; + this.children = []; + } + add(o) { this.children.push(o); return this; } + removeAll(destroy = false) { + for (const c of this.children) if (destroy) c.destroy?.(); + this.children.length = 0; + return this; + } + setDepth() { return this; } + setAlpha(a) { this.alpha = a; return this; } + setScale(s) { this.scale = s; return this; } + destroy() { this.destroyed = true; return this; } +} +class Container extends GameObject {} +class Sprite extends GameObject {} + +// toColor (js/utils/Color.js) — a real value→int conversion, like the browser's +const hexToRgbInt = (v) => { + const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); + return m ? parseInt(m[1], 16) : 0xffffff; +}; +globalThis.window = { + Phaser: { + GameObjects: { Container, Sprite }, // js/vendor/phaser.js reads this + 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); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +// A fresh recording scene (one graphics, its strokes tracked). +const makeScene = (texturesPresent) => { + const strokes = []; // { r, style, color, alpha } per strokeCircle + const positions = []; + return { + strokes, positions, + textures: { exists: () => texturesPresent }, + add: { + existing: (o) => o, + graphics: () => ({ + x: 0, y: 0, visible: true, + setPosition(x, y) { this.x = x; this.y = y; positions.push([x, y]); return this; }, + setDepth() { return this; }, + lineStyle(style, color, alpha) { this._ls = { style, color, alpha }; return this; }, + strokeCircle(_cx, _cy, r) { strokes.push({ r, ...(this._ls ?? {}) }); return this; }, + strokeRect() { return this; }, + fillStyle() { return this; }, + fillCircle() { return this; }, + fillRect() { return this; }, + lineBetween() { return this; }, + clear() { strokes.length = 0; return this; }, + destroy() { this.destroyed = true; }, + }), + circle: (x, y, r, fill, alpha) => + ({ x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; }, setScale() { return this; } }), + }, + }; +}; + +// --- 1. RoutePulse itself ------------------------------------------------- +const { RoutePulse } = await import(pathToFileURL(join(__dirname, '../js/entities/RoutePulse.js')).href); + +{ + const scene = makeScene(true); + const p = new RoutePulse(scene, { size: 100, cfg: {}, color: 0x112233, x: 30, y: -20 }); + check('off by default, ring hidden', p.on === false && p.ring.visible === false); + check('the ring sits where the host put it (planet-style placement)', + p.ring.x === 30 && p.ring.y === -20 && scene.positions.length === 1); + + p.set(true); + check('set(true): on + visible', p.on === true && p.ring.visible === true); + p.draw(1.0); + check('draw: exactly the steady ring + two ripples (3 circles)', scene.strokes.length === 3); + const steady = 100 * 1.18; // the built-in baseRadius (cfg omitted) + check(`default cfg: steady ring at baseRadius × size (${scene.strokes[0].r})`, + Math.abs(scene.strokes[0].r - steady) < 1e-9); + check('all circles sit outside the keepout disc (size = 100)', + scene.strokes.every((s) => s.r >= 100 - 1e-9)); + check('the color is passed through (0x112233)', scene.strokes.every((s) => s.color === 0x112233)); + check('the steady ring keeps the cfg alpha', scene.strokes[0].alpha === 0.5); + + const snap = scene.strokes.slice(1); + p.draw(1.0 + 0.4); // a quarter of the 1.6 s period later — different phases + check('the ripples animate over time (radii + alpha move)', + scene.strokes.length === 3 && (scene.strokes[1].r !== snap[0].r || scene.strokes[1].alpha !== snap[0].alpha)); + check('ripple alpha fades with phase (later phase → lower alpha)', + scene.strokes.every((s, i) => i === 0 || s.alpha <= 0.85 + 1e-9)); + + p.set(true); // no-op when already on + check('set(true) twice is a no-op', p.on === true && p.ring.visible === true); + p.set(false); + check('set(false): off, cleared + hidden', + p.on === false && p.ring.visible === false && scene.strokes.length === 0); + p.draw(5.0); // drawing while off is a no-op + check('draw() while off: nothing drawn', scene.strokes.length === 0); + p.destroy(); + check('destroy() tears the ring down', p.ring.destroyed === true); +} + +{ + // A custom cfg (the subdued profile) is respected end-to-end. + const scene = makeScene(true); + const cfg = config.section('gates.route.objectPulse', {}); + const p = new RoutePulse(scene, { size: 46, cfg, color: 0xff8c1a }); + p.set(true); + p.draw(0); + const steady = 46 * cfg.baseRadius; + check(`custom cfg: steady ring at objectPulse.baseRadius × size (${scene.strokes[0].r.toFixed(2)} = 46 × ${cfg.baseRadius})`, + scene.strokes.length === 3 && Math.abs(scene.strokes[0].r - steady) < 1e-9); + check('custom cfg: the subdued alphas are used', + scene.strokes[0].alpha === cfg.ringAlpha + && scene.strokes.some((s) => s.alpha === cfg.rippleAlpha)); +} + +// --- 2. The data contract: the object pulse is SUBDUED -------------------- +{ + const full = config.section('gates.route.pulse', {}); + const sub = config.section('gates.route.objectPulse', {}); + for (const k of ['baseRadius', 'expand', 'period', 'ringAlpha', 'rippleAlpha']) { + check(`both pulses define ${k}`, typeof full[k] === 'number' && typeof sub[k] === 'number'); + } + check(`the object pulse is less intense (ringAlpha ${sub.ringAlpha} < ${full.ringAlpha}; rippleAlpha ${sub.rippleAlpha} < ${full.rippleAlpha})`, + sub.ringAlpha < full.ringAlpha && sub.rippleAlpha < full.rippleAlpha); + check(`...with a smaller reach (baseRadius ${sub.baseRadius} ≤ ${full.baseRadius}, expand ${sub.expand} ≤ ${full.expand})`, + sub.baseRadius <= full.baseRadius && sub.expand <= full.expand); + check('the pulse color is the route orange (compassColor)', + String(config.get('gates.route.compassColor', '')).toLowerCase() === '#ff8c1a'); +} + +// --- 3. Station integration (the destination OBJECT wears it) ------------- +const { Station } = await import(pathToFileURL(join(__dirname, '../js/entities/Station.js')).href); +const scene = makeScene(false); // sheet missing → the procedural build +const st = new Station(scene, { + id: 'set-wp-test', kind: 'waypoint', name: 'Waypoint One', + anchor: { type: 'space' }, x: 40, y: 70, +}); +check('fresh station: the route pulse is off + hidden by default', + st.routePulse.on === false && st.routePulse.ring.visible === false); +st.setRouteTarget(true); +check('setRouteTarget(true): on + visible', st.routePulse.on === true && st.routePulse.ring.visible === true); +st.update(1000); +const sub = config.section('gates.route.objectPulse', {}); +const steady = st.size * sub.baseRadius; +const stStrokes = scene.strokes.slice(-3); // the station's ring is the last graphics +check(`station pulse: steady ring + two ripples, outside the keepout disc (radii ${stStrokes.map((s) => s.r.toFixed(1)).join(', ')})`, + stStrokes.length === 3 + && stStrokes.every((s) => s.r >= st.size - 1e-9) + && Math.abs(stStrokes[0].r - steady) < 1e-9); +check('the station pulse uses the SUBDUED cfg (not the gate full pulse)', + st.routePulse.cfg === sub && st.routePulse.cfg.baseRadius === sub.baseRadius); +st.setRouteTarget(false); +check('setRouteTarget(false): cleared + hidden', + st.routePulse.on === false && st.routePulse.ring.visible === false && scene.strokes.length === 0); +st.update(2000); // the rest of the procedural update still runs +check('update() keeps running after the pulse drops (beacon/ring untouched)', true); + +console.log(failures === 0 ? '\nAll route-pulse tests passed ✔' : `\n${failures} test(s) FAILED ✘`); +process.exit(failures === 0 ? 0 : 1); diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index fde3f15..89bbf82 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -1,5 +1,7 @@ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; +import { toColor } from '../utils/Color.js'; +import { RoutePulse } from './RoutePulse.js'; import { Planet } from './Planet.js'; /** @@ -31,6 +33,17 @@ import { Planet } from './Planet.js'; * DORMANT: the body is still drawn at full alpha, but the swirl is * hidden (its absence is the "this one is live" tell). * + * ROUTE TARGET (data/gates.json → route.pulse): while a destination is + * set and this gate is the route's NEXT STOP in the current system + * (GameScene.routeNextGate — the destination's WAYPOINT here), the scene + * marks the gate (setRouteTarget) and update() animates the FULL route + * pulse around it (js/entities/RoutePulse.js — the shared halo, also + * worn in a subdued form by the destination OBJECT, Planet/Station): + * the steady target ring + two fading radar ripples — so the player sees + * "your destination is this gate" the moment it is on screen (the + * compass's orange arrow is the off-screen half). A DORMANT gate gets + * the pulse too: the player still has to find the gate to use it. + * * The collision API is circular and rotation-blind. * * update(time) spins the swirl (active gates) / breathes the fallback @@ -61,6 +74,7 @@ export class JumpGate extends Phaser.GameObjects.Container { this.theme = this._theme(); this.ringBody = null; this.swirl = null; + this.routePulse = null; // the destination's waypoint mark (GameScene → setRouteTarget) this.setDepth(o.depth ?? 5); this.rotation = gate.rotation ?? 0; // face the destination star @@ -75,6 +89,19 @@ export class JumpGate extends Phaser.GameObjects.Container { } build() { + // The route-target pulse (data/gates.json → route.pulse): the orange + // "your destination is this gate" halo — GameScene drives it via + // setRouteTarget() on the route's next gate (routeNextGate). Added + // FIRST so it draws behind the gate art (the ring sits outside the + // keepout disc anyway). + this.routePulse = new RoutePulse(this.scene, { + size: this.size, + cfg: config.section('gates.route.pulse', {}), + color: toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a), + x: 0, y: 0, + depth: this.depth, + }); + this.add(this.routePulse.ring); if (this.scene.textures.exists(JumpGate.TEXTURE_KEY)) { this._buildSprite(); return; @@ -212,9 +239,23 @@ export class JumpGate extends Phaser.GameObjects.Container { if (this.swirl) this.swirl.setVisible(true); // update() breathes it from the next frame } + /** + * ROUTE-TARGET state (GameScene.update, each frame): true while this + * gate is the route's NEXT STOP — the destination's waypoint in the + * current system (routeNextGate). True shows the FULL pulse (update() + * animates it); false clears + hides it. A no-op when unchanged. + */ + setRouteTarget(on) { + this.routePulse?.set(on); + } + /** The swirl spins + breathes (active); the fallback art drifts. (GameScene.update drives this.) */ update(time) { const t = time / 1000; + // The route-target pulse (the destination's waypoint in this system). + // It runs even on a DORMANT gate — the player still has to find the + // gate to use it. (A no-op while unmarked.) + this.routePulse?.draw(t); if (this.ringBody) this.ringBody.rotation = t * (this.active ? 0.12 : 0.06); // A dormant gate shows no swirl and keeps a still, faint field. if (!this.active) return; diff --git a/js/entities/Planet.js b/js/entities/Planet.js index 447034b..e7cb1e8 100644 --- a/js/entities/Planet.js +++ b/js/entities/Planet.js @@ -1,5 +1,7 @@ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; +import { toColor } from '../utils/Color.js'; +import { RoutePulse } from './RoutePulse.js'; /** * A planet: a static world rendered from the shared spritesheet @@ -17,6 +19,15 @@ import { config } from '../config/Config.js'; * resolve) applied to the ship after it moves, so it never interferes * with the ship's own flight model and stays testable in Node * (dev/planet.test.mjs) without a scene. + * + * ROUTE TARGET (data/gates.json → route.objectPulse): when the player's + * destination is THIS world (the route's final stop — the destination + * system's destination object, GameScene._routeDestinationEntity), the + * scene marks it (setRouteTarget) and update() animates the route pulse + * (js/entities/RoutePulse.js) around it — the SAME halo the route gate + * wears, SUBDUED (lower alpha, smaller reach, slower cycle), so the + * player sees "your destination is this world" the moment it is on + * screen (the compass's orange arrow is the off-screen half). */ export class Planet extends Phaser.GameObjects.Sprite { static TEXTURE_KEY = 'planets'; @@ -61,9 +72,38 @@ export class Planet extends Phaser.GameObjects.Sprite { // Edge-to-edge gap the ship may close in on the rim (never less). this.clearance = config.get('planets.shipClearance', 50); + // The route-target pulse (data/gates.json → route.objectPulse): when + // this world is the destination OBJECT, it wears the subdued route + // pulse (GameScene marks it via setRouteTarget). A Sprite can't host + // children, so the ring is a standalone graphics at the planet's + // center — the planet is static, so no following is needed — at the + // scene's world-object depth (GameScene sets the planets to 5). + this.routePulse = new RoutePulse(this.scene, { + size: this.radius, + cfg: config.section('gates.route.objectPulse', {}), + color: toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a), + x, y, + depth: 5, + }); + if (o.tint !== undefined && o.tint !== null) this.setTint(o.tint); } + /** + * ROUTE-TARGET state (GameScene.update, each frame): true while this + * world is the route's FINAL STOP — the destination object in the + * destination system. True shows the subdued pulse (update() animates + * it); false clears + hides it. A no-op when unchanged. + */ + setRouteTarget(on) { + this.routePulse?.set(on); + } + + /** The route pulse (a no-op while unmarked). (GameScene.update drives this.) */ + update(time) { + this.routePulse?.draw(time / 1000); + } + /** Minimum allowed center-to-center distance for a ship of `shipRadius`. */ minCenterDistance(shipRadius = 0) { return this.radius + this.clearance + shipRadius; diff --git a/js/entities/RoutePulse.js b/js/entities/RoutePulse.js new file mode 100644 index 0000000..b2f44d4 --- /dev/null +++ b/js/entities/RoutePulse.js @@ -0,0 +1,97 @@ +/** + * RoutePulse — the route-target "target" halo: the in-world + * "your destination is HERE" mark (the route's orange). + * + * Shape: a steady target ring at `baseRadius` × the object's keepout + * radius, plus two radar ripples (staggered half a period) that expand + * a further ×`expand` past it and fade out. All the geometry scales with + * the object's keepout radius, so it reads the same on a small waypoint + * beacon and a gas giant. + * + * Who wears it (GameScene.update marks exactly one object per frame — + * the route's NEXT STOP): + * · the ROUTE GATE (the destination's waypoint in an intermediate + * system — GameScene.routeNextGate) — the FULL pulse + * (data/gates.json → route.pulse), via JumpGate.setRouteTarget + * · the DESTINATION OBJECT in the destination system (a planet or a + * space station — the route's final stop — + * GameScene._routeDestinationEntity) — the SUBDUED pulse + * (data/gates.json → route.objectPulse), via + * Planet.setRouteTarget / Station.setRouteTarget + * The compass's orange arrow is the off-screen half; the pulse is what + * the player sees the moment the target comes on screen. + * + * Not a scene object of its own — the host entity places `pulse.ring` + * (a scene Graphics) where it belongs: as a child at (0, 0) for + * container hosts (gate / station), or beside the object for a Sprite + * host (planet — static, so no following is needed), and drives + * `pulse.draw(t)` from its own update() (the scene's update loop drives + * the entity, as with the gate's swirl or the station's beacon). + */ +export class RoutePulse { + /** + * @param {Phaser.Scene} scene + * @param {object} o { + * size — the object's keepout radius (px) — the geometry's scale, + * cfg — { baseRadius, expand, period, ringAlpha, rippleAlpha } + * (a data/gates.json section — route.pulse / route.objectPulse), + * color — the color int (the route's orange — route.compassColor), + * x, y — the ring's position (0, 0 container-local — or the + * object's center for a Sprite host), + * depth — the ring's depth (the host's world-object depth), + * } + */ + constructor(scene, o) { + this.size = o.size; + this.cfg = o.cfg ?? {}; + this.color = o.color ?? 0xff8c1a; + this.on = false; + this.ring = scene.add.graphics().setPosition(o.x ?? 0, o.y ?? 0); + if (o.depth !== undefined) this.ring.setDepth(o.depth); + this.ring.visible = false; // hidden until claimed (set(true)) + } + + /** + * Show / hide the pulse (a no-op when unchanged). Hiding clears the + * strokes so no stale frame lingers. + */ + set(on) { + const want = on === true; + if (this.on === want) return; + this.on = want; + if (!want) { + this.ring.clear(); + this.ring.visible = false; + } else { + this.ring.visible = true; + } + } + + /** Draw the pulse at time t (seconds). A no-op while hidden. */ + draw(t) { + if (!this.on) return; + const g = this.ring; + g.clear(); + const S = this.size; + const base = Math.max(2, S * 0.035); + const R0 = S * (this.cfg.baseRadius ?? 1.18); + const period = this.cfg.period ?? 1.6; + const expand = this.cfg.expand ?? 0.55; + const aRing = this.cfg.ringAlpha ?? 0.5; + const aRipple = this.cfg.rippleAlpha ?? 0.85; + // The steady ring — the circle the ripples grow out of. + g.lineStyle(base, this.color, aRing); + g.strokeCircle(0, 0, R0); + // The ripples — two, half a period apart. + for (let k = 0; k < 2; k++) { + const phase = (t / period + k * 0.5) % 1; + g.lineStyle(Math.max(1, base * (1 - 0.6 * phase)), this.color, aRipple * (1 - phase)); + g.strokeCircle(0, 0, R0 * (1 + expand * phase)); + } + } + + /** Tear the ring down (the host is destroyed). */ + destroy() { + this.ring.destroy?.(); + } +} diff --git a/js/entities/Station.js b/js/entities/Station.js index f4e88b4..8bd82fe 100644 --- a/js/entities/Station.js +++ b/js/entities/Station.js @@ -1,5 +1,7 @@ import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; +import { toColor } from '../utils/Color.js'; +import { RoutePulse } from './RoutePulse.js'; import { Rng } from '../utils/Rng.js'; import { Planet } from './Planet.js'; @@ -39,6 +41,15 @@ import { Planet } from './Planet.js'; * * update(time) turns the ring and breathes the beacon (procedural build * only — driven by GameScene.update, like the asteroid clusters). + * + * ROUTE TARGET (data/gates.json → route.objectPulse): when the player's + * destination is THIS station (the route's final stop — the destination + * system's destination object, GameScene._routeDestinationEntity), the + * scene marks it (setRouteTarget) and update() animates the route pulse + * (js/entities/RoutePulse.js) around it — the SAME halo the route gate + * wears, SUBDUED (lower alpha, smaller reach, slower cycle), so the + * player sees "your destination is this station" the moment it is on + * screen (the compass's orange arrow is the off-screen half). */ export class Station extends Phaser.GameObjects.Container { static TEXTURE_KEY = 'spacestations'; @@ -93,6 +104,19 @@ export class Station extends Phaser.GameObjects.Container { * (waypoints, and the deep-space fallback when the sheet is missing). */ build() { + // The route-target pulse (data/gates.json → route.objectPulse): when + // this station is the destination OBJECT, it wears the subdued route + // pulse (GameScene marks it via setRouteTarget). Added FIRST so it + // draws behind the station art (the ring sits outside the keepout + // disc anyway). + this.routePulse = new RoutePulse(this.scene, { + size: this.size, + cfg: config.section('gates.route.objectPulse', {}), + color: toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a), + x: 0, y: 0, + depth: this.depth, + }); + this.add(this.routePulse.ring); if (this.sheetFrame !== null && this.scene.textures.exists(Station.TEXTURE_KEY)) { this._buildVariant(); return; @@ -203,9 +227,11 @@ export class Station extends Phaser.GameObjects.Container { } /** The ring turns; the beacon breathes (the procedural build — the - * variant art is static). (GameScene.update drives this.) */ + * variant art is static); the route pulse (a no-op while unmarked). + * (GameScene.update drives this.) */ update(time) { const t = time / 1000; + this.routePulse?.draw(t); if (this.ringBody) this.ringBody.rotation = t * this.ringSpeed; if (this.beaconGlow) { const pulse = 0.5 + 0.5 * Math.sin(t * 2.6); @@ -214,6 +240,16 @@ export class Station extends Phaser.GameObjects.Container { } } + /** + * ROUTE-TARGET state (GameScene.update, each frame): true while this + * station is the route's FINAL STOP — the destination object in the + * destination system. True shows the subdued pulse (update() animates + * it); false clears + hides it. A no-op when unchanged. + */ + setRouteTarget(on) { + this.routePulse?.set(on); + } + // ---- SOLID (the same contract as Planet / AsteroidCluster) --------- minCenterDistance(shipRadius = 0) { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 510a600..3365a40 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -2057,7 +2057,24 @@ export class GameScene extends Phaser.Scene { // the dust orbits. (The keep-out constraint runs in onPostUpdate, // after the physics step has moved the ship.) for (const c of this.asteroidClusters) c.update(_time); - for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes + // The route's NEXT STOP in this system wears the orange route pulse + // (js/entities/RoutePulse.js — the in-world "your destination is + // HERE" mark, visible the moment the target is on screen; the + // compass's orange arrow is the off-screen half): in an intermediate + // system it is the system's JUMP GATE toward the destination + // (routeNextGate — the FULL pulse, route.pulse); in the DESTINATION + // system it is the destination OBJECT (a planet or a space station — + // the subdued pulse, route.objectPulse; _routeDestinationEntity). + // Every other object drops it. Both marks are null when no + // destination is set or the route is done. + const _routeGate = this.routeNextGate(); + const _routeDest = this._routeDestinationEntity(); + if (this.planet) this.planet.setRouteTarget(this.planet === _routeDest); + for (const p of this.systemPlanets) p.setRouteTarget(p === _routeDest); + for (const st of this.systemStations) st.setRouteTarget(st === _routeDest); + for (const gt of this.systemGates) gt.setRouteTarget(gt === _routeGate); + for (const p of this.systemPlanets) p.update(_time); // the route pulse (a no-op when unmarked) + for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes, the route pulse for (const gt of this.systemGates) gt.update(_time); // the swirl spins + breathes (active gates); fallback art drifts this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring @@ -4096,6 +4113,24 @@ export class GameScene extends Phaser.Scene { return this.discoverableObjects().find((o) => o.id === d.objectId) ?? null; } + /** + * The route's FINAL stop as a WORLD entity: the destination OBJECT in + * this system (routeDestinationObject) resolved to the entity that + * wears the subdued route pulse (js/entities/RoutePulse.js) — a + * planet (the home world included) or a space station. Null when the + * route is complete (no next stop in this system), the destination + * names no object, or the object is a kind that doesn't wear the pulse + * (gates/asteroids — the compass still marks them). + */ + _routeDestinationEntity() { + const o = this.routeDestinationObject(); + if (!o) return null; + if (o.id === 'home') return this.planet ?? null; + for (const p of this.systemPlanets) if (p.discoveryId === o.id) return p; + for (const st of this.systemStations) if (st.discoveryId === o.id) return st; + return null; + } + /** * Arrived at the FINAL stop? Called each frame (update, just before * updateDiscovery). When the player is in the destination system and the