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