Add route-target pulse halo to gates, planets, and stations

- New shared RoutePulse entity draws a steady ring + two radar ripples
  scaled to the host's keepout radius, with configurable alpha/period
- JumpGate wears the full pulse (route.pulse) when it is the route's next
  stop; Planet and Station wear the subdued variant (route.objectPulse)
  when they are the destination object in the destination system
- GameScene marks exactly one entity per frame via setRouteTarget() and
  drives its update loop; all others are cleared
- Add data/gates.json sections for pulse and objectPulse geometry/alpha
- Rename laval-surface-02.png to lava-surface-02.png and add the
  corresponding video asset
- Extend jumpgate, planet, and station test stubs with Phaser Display.Color
  and recording graphics; add dev/route-pulse.test.mjs unit tests and
  dev/route-pulse-live.{html,mjs} browser integration checks
This commit is contained in:
Brian Fertig 2026-09-12 16:35:05 -06:00
parent fa9aa86ad1
commit 141cbdb156
13 changed files with 786 additions and 7 deletions

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

View File

@ -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}"

View File

@ -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);

View File

@ -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);

17
dev/route-pulse-live.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Route pulse live check (dev)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/route-pulse-live.mjs"></script>
</body>
</html>

215
dev/route-pulse-live.mjs Normal file
View File

@ -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 };
}

201
dev/route-pulse.test.mjs Normal file
View File

@ -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);

View File

@ -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;

View File

@ -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;

97
js/entities/RoutePulse.js Normal file
View File

@ -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?.();
}
}

View File

@ -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) {

View File

@ -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