Add route/destination visualization to map console

- Draw the active SET DESTINATION path in orange on galaxy lanes and circle the destination star with a breathing ring
- On the current-system chart, circle the destination object when it is in this system, or show the route exit gate plus a dashed ship-to-gate line when the destination is elsewhere
- Carry `route` edge keys and `destinationId` through `buildGalaxySnapshot`, `mapChartSnapshot`, and `systemChartSnapshotFor` so views can render the course
- Add dev screenshot harnesses (route-shot, exit-gate-shot) and extend galaxy-map tests to cover route/destination flags
- Document the new visualization in PROJECT_NOTES.md and add a takeoff video asset
This commit is contained in:
Brian Fertig 2026-09-07 21:48:50 -06:00
parent bfd85c8e1f
commit 568b1feaf2
13 changed files with 561 additions and 7 deletions

Binary file not shown.

View File

@ -172,11 +172,19 @@
"void": { "speed": 0.2, "amp": 0.3 }
},
"edges": {
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. width = plate-px at 1× (0.751.25× with zoom).",
"_comment": "The jump-lane web — three reads of the same thin line. used = a lane the run TRAVELED (bright glow pass + the flow packet); frontier = exactly one end charted (the next step); unexplored = the faint maze. route = a lane on the active SET DESTINATION path (the \"where am I going\" line, data/gates.json → route.compassColor orange) — drawn above used/frontier so the course reads clearly. width = plate-px at 1× (0.751.25× with zoom).",
"route": { "color": "#ff8c1a", "width": 2.4, "alpha": 0.95 },
"used": { "color": "#8df3ff", "width": 1.8, "alpha": 0.95 },
"frontier": { "color": "#3fb9d8", "width": 1.1, "alpha": 0.62 },
"unexplored": { "color": "#2a4f70", "width": 0.8, "alpha": 0.3 }
},
"destination": {
"_comment": "The DESTINATION star's ring on the GALAXY tab — the route's final stop, circled so the target is unmistakable. color = data/gates.json → route.compassColor (orange); width/radiusMul = the ring's plate-px width and its radius as a multiple of the star's glow radius; alpha = the ring's opacity. Shown only while a destination is set (snapshot.destinationId).",
"color": "#ff8c1a",
"width": 2.5,
"radiusMul": 2.1,
"alpha": 0.9
},
"hull": {
"_comment": "The CHARTED REGION — the convex hull of the visited systems, inflated by `padding` (a fraction of the plate width at 1× zoom — stable under zoom). A soft fill + a subtle double outline (the discovered-area shading). FACTIONS (planned): per-faction territories reuse this exact pass, colored per faction.",
"enabled": true,

17
dev/exit-gate-shot.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Exit gate shot (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/exit-gate-shot.mjs"></script>
</body>
</html>

130
dev/exit-gate-shot.mjs Normal file
View File

@ -0,0 +1,130 @@
/**
* Dev-only: boot GameScene, set a DESTINATION (a far star), and open the
* MAP console's CURRENT SYSTEM tab so a headless screenshot shows:
* · the ROUTE EXIT GATE circled in orange (the gate the player should
* path out of the system from to reach the destination),
* · an orange line from the ship to that gate ("path out of the system").
*
* node dev/server.mjs 8084
* node dev/shot-firefox.mjs \
* "http://127.0.0.1:8084/dev/exit-gate-shot.html" \
* "window.__EXIT_GATE ? window.__EXIT_GATE.ready : null" \
* EXIT_GATE.png 40000
*
* The report (top-left) lists console errors + the exit-gate state.
*/
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);
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.mapWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
try {
const s = await waitScene();
await sleep(800);
// Pick a FAR destination (a multi-hop route, so the exit gate is the
// route's next gate in the CURRENT system).
const sysId = s.systemRecord.id;
const records = s.galaxy.records;
const cur = records.find((r) => r.id === sysId);
const others = records.filter((r) => r.id !== sysId);
others.sort((a, b) => {
const da = Math.hypot(a.x - cur.x, a.y - cur.y);
const db = Math.hypot(b.x - cur.x, b.y - cur.y);
return db - da;
});
const dest = others[2] ?? others[0];
if (!dest) throw new Error('no destination candidate');
const destContent = s.galaxy.contentOf(dest.id);
const destPlanet = (destContent.planets ?? [])[0];
const destObjId = destPlanet?.name ?? null;
// Set the destination.
s.setDestination(dest.id, destObjId);
await sleep(400);
// Discover the exit gate (the route's next gate in the CURRENT system)
// so it's drawn on the chart.
const exitGate = s.routeNextGate();
if (exitGate) {
let known = s.discovery.bySystem.get(sysId);
if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); }
s.discovery.check(sysId, exitGate.x, exitGate.y, [{ id: exitGate.discoveryId, x: exitGate.x, y: exitGate.y, radius: 100 }]);
}
await sleep(300);
// Open the MAP console's CURRENT SYSTEM tab (the default mode).
s.mapWindow._switchMode('currentSystem');
s.mapWindow.open();
await sleep(1600);
// Collect the state for the report.
const chartSnap = s.mapChartSnapshot();
const exitInfo = chartSnap?.routeExitGate;
const exitObj = (chartSnap?.objects ?? []).find((o) => o?.id === exitInfo?.id);
window.__EXIT_GATE = {
ready: true,
errors,
destSystem: dest.id,
destName: dest.name,
exitGateId: exitInfo?.id,
exitGateName: exitInfo?.name,
exitGateDiscovered: exitObj?.discovered,
exitGateX: exitInfo?.x,
exitGateY: exitInfo?.y,
shipX: chartSnap?.ship?.x,
shipY: chartSnap?.ship?.y,
destinationId: chartSnap?.destinationId,
};
const lines = [
`DESTINATION: ${dest.name} (${dest.id})`,
`EXIT GATE: ${exitInfo?.name ?? 'null'} (${exitInfo?.id ?? 'null'})`,
`EXIT GATE DISCOVERED: ${exitObj?.discovered}`,
`EXIT GATE POS: (${exitInfo?.x?.toFixed(0)}, ${exitInfo?.y?.toFixed(0)})`,
`SHIP POS: (${chartSnap?.ship?.x?.toFixed(0)}, ${chartSnap?.ship?.y?.toFixed(0)})`,
`DESTINATION IN CURRENT SYS: ${chartSnap?.destinationId ?? 'no (route exit gate shown)'}`,
`ERRORS: ${errors.length ? errors.join(' | ') : 'none'}`,
];
setReport(lines);
console.log('EXIT GATE READY', JSON.stringify(window.__EXIT_GATE, null, 2));
} catch (e) {
const lines = ['FATAL: ' + (e?.message ?? e), ...errors];
setReport(lines);
window.__EXIT_GATE = { ready: true, errors: [...errors, String(e?.message ?? e)] };
}

View File

@ -126,6 +126,28 @@ const b = others[1];
const vb = snap2.systems.find((s) => s.id === e.b).visited;
return va !== vb;
}).length);
// ROUTE + DESTINATION: the active SET DESTINATION path (its lanes marked
// `route: true`) and the destination star (`isDestination: true`).
const routeLane = edgeKey(homeId, a.id);
const snapR = buildGalaxySnapshot({
galaxy,
visited: [homeId],
used: [],
live: new Set(),
currentSystemId: homeId,
route: [routeLane],
destinationId: a.id,
});
check('snapR: destinationId carried', snapR.destinationId === a.id);
check('snapR: destination star flagged', snapR.systems.find((s) => s.id === a.id)?.isDestination === true);
check('snapR: non-destination stars unflagged', snapR.systems.find((s) => s.id === b.id)?.isDestination === false);
check('snapR: route lane flagged', snapR.edges.find((e) => e.key === routeLane)?.route === true);
check('snapR: non-route lanes unflagged', snapR.edges.every((e) => e.route === (e.key === routeLane)));
// No route / no destination → everything unflagged (the default).
const snapNR = buildGalaxySnapshot({ galaxy, visited: [homeId], currentSystemId: homeId });
check('snapNR: no destination → all unflagged', snapNR.destinationId === null && snapNR.systems.every((s) => s.isDestination === false));
check('snapNR: no route → no route edges', snapNR.edges.every((e) => e.route === false));
}
// ---------------------------------------------------------------------------

17
dev/route-shot.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Route shot (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-shot.mjs"></script>
</body>
</html>

154
dev/route-shot.mjs Normal file
View File

@ -0,0 +1,154 @@
/**
* Dev-only: boot GameScene, set a DESTINATION (a far star's first planet),
* and open the MAP console's GALAXY tab so a headless screenshot shows:
* · the route (the active SET DESTINATION path) in ORANGE on the lanes,
* · the destination star circled in orange,
* · the compass's route gate (orange, full-sized).
*
* node dev/server.mjs 8083 # static server
* node dev/shot-firefox.mjs \
* "http://127.0.0.1:8083/dev/route-shot.html" \
* "window.__ROUTE_SHOT ? window.__ROUTE_SHOT.ready : null" \
* ROUTE_SHOT.png 40000
*
* The report (top-left) lists console errors + the route state.
*/
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);
// 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;
// The report lives OUTSIDE the canvas — a DOM <pre> the screenshot can
// always read (headless canvases don't have to cooperate).
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.mapWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
try {
const s = await waitScene();
await sleep(800); // let the boot settle
// Pick a FAR destination: the furthest star from the current system
// (a multi-hop route, so the orange path is visible on several lanes).
const sysId = s.systemRecord.id;
const records = s.galaxy.records;
const cur = records.find((r) => r.id === sysId);
const others = records.filter((r) => r.id !== sysId);
others.sort((a, b) => {
const da = Math.hypot(a.x - cur.x, a.y - cur.y);
const db = Math.hypot(b.x - cur.x, b.y - cur.y);
return db - da; // farthest first
});
// Use the 3rd-furthest (leave some margin so the route is a real path,
// not a single hop).
const dest = others[2] ?? others[0];
if (!dest) throw new Error('no destination candidate');
// Discover the destination's first planet (so it has an objectId).
const destContent = s.galaxy.contentOf(dest.id);
const destPlanet = (destContent.planets ?? [])[0];
const destObjId = destPlanet?.name ?? null;
// Set the destination (the route is derived from it).
s.setDestination(dest.id, destObjId);
await sleep(400);
// Open the MAP console's GALAXY tab (the route + destination ring are
// drawn there). Use the window's own open() (reveal tween + state).
s.mapWindow._switchMode('galaxy');
s.mapWindow.open();
await sleep(1600); // let the reveal tween + ripple settle + the route paint
// Collect the route state for the report.
const plan = s.routePlan();
const routeKeys = s.routeEdgeKeys();
const snap = s.galaxySnapshot();
const destStar = snap?.systems?.find((x) => x.isDestination);
const routeEdges = (snap?.edges ?? []).filter((e) => e.route);
// --- Now switch to the SYSTEM tab and open the destination system's chart.
// --- Discover the destination object first (so it's drawn on the chart),
// --- then the destination ring should appear around it.
const destSysId = dest.id;
if (destPlanet) {
// Discover the destination planet (so it's drawn on the chart).
let known = s.discovery.bySystem.get(destSysId);
if (!known) { known = new Set(); s.discovery.bySystem.set(destSysId, known); }
s.discovery.check(destSysId, destPlanet.x, destPlanet.y, [{ id: destPlanet.name, x: destPlanet.x, y: destPlanet.y, radius: 100 }]);
}
await sleep(300);
// Switch to the SYSTEM tab and open the destination system's chart.
s.mapWindow._switchMode('system');
s.mapWindow._openSystem(destSysId);
await sleep(1200); // let the chart repaint
// Collect the chart state for the report.
const chartSnap = s.systemChartSnapshotFor(destSysId);
const destInChart = chartSnap?.objects?.find((o) => o.id === chartSnap.destinationId);
window.__ROUTE_SHOT = {
ready: true,
errors,
destSystem: dest.id,
destName: dest.name,
destObjId,
plan: { path: plan?.path, hops: plan?.hops },
routeKeys,
destStar: destStar ? { id: destStar.id, name: destStar.name } : null,
routeEdgeCount: routeEdges.length,
destinationId: snap?.destinationId,
chartDestinationId: chartSnap?.destinationId,
chartDestDiscovered: destInChart?.discovered,
chartDestName: destInChart?.name,
};
const lines = [
`DESTINATION: ${dest.name} (${dest.id})`,
`OBJECT: ${destObjId}`,
`ROUTE: ${plan?.path?.join(' → ') ?? 'null'}`,
`HOPS: ${plan?.hops}`,
`ROUTE EDGES: ${routeEdges.length}`,
`DEST STAR: ${destStar ? `${destStar.name} (${destStar.id})` : 'null'}`,
`CHART DEST: ${chartSnap?.destinationId ?? 'null'}`,
`CHART DEST DISCOVERED: ${destInChart?.discovered}`,
`CHART DEST NAME: ${destInChart?.name}`,
`ERRORS: ${errors.length ? errors.join(' | ') : 'none'}`,
];
setReport(lines);
console.log('ROUTE SHOT READY', JSON.stringify(window.__ROUTE_SHOT, null, 2));
} catch (e) {
const lines = ['FATAL: ' + (e?.message ?? e), ...errors];
setReport(lines);
window.__ROUTE_SHOT = { ready: true, errors: [...errors, String(e?.message ?? e)] };
}

View File

@ -495,6 +495,39 @@ of storing a progress counter) makes the follow/detour rules automatic:
**Tuning** — `data/gates.json → route` (the orange `compassColor`, the
`typeLabel` / `destTypeLabel`, the three toasts with their
`{dest}`/`{hops}` placeholders).
**Visualization** — the active route is drawn on the MAP console:
- **GALAXY tab** — the route's lanes are drawn in **ORANGE**
(`data/map.json → galaxy.edges.route`, color = `route.compassColor`)
above the used/frontier/unexplored reads, and the DESTINATION STAR is
circled in orange (`data/map.json → galaxy.destination`) — the
"where am I going" path + target across the whole map. The route's
edge keys are computed by `GameScene.routeEdgeKeys()` (the consecutive
system pairs of `planRoute`'s path) and passed to
`buildGalaxySnapshot` (which marks `route: true` on those edges and
`isDestination: true` on the destination star). `GalaxyView._redrawEdges`
draws the route lanes (three-pass glow, like traveled) and
`GalaxyView._drawMarkers` draws the destination ring (a breathing
orange circle, shown only while a destination is set).
- **CURRENT SYSTEM tab** — two cases:
- **Destination is IN this system** — the destination object is circled
in orange (the route's final stop). `GameScene.mapChartSnapshot`
sets `destinationId` on the snapshot; `MapWindow.paintChart` draws the
ring (step 8b) over the object (shown only when the object is
discovered — a ring over a ghost would be noise).
- **Destination is OUTSIDE this system** — the ROUTE EXIT GATE (the jump
gate the player should path out of the system from, `routeNextGate`) is
circled in orange + a dashed orange line is drawn from the ship to it
("path out of the system"). `GameScene.mapChartSnapshot` sets
`routeExitGate` on the snapshot (the gate's id/x/y/name); `MapWindow.
paintChart` draws the ring + line (step 8c). The gate may be
undiscovered (the player hasn't seen it yet) — the ring + line still
show WHERE to go, which is the point of the route.
- **SYSTEM tab** — the destination object is circled in orange when
viewing the destination system's chart. `GameScene.systemChartSnapshotFor`
passes `destinationId` to `systemChartSnapshot` (which carries it on
the snapshot); `MapWindow.paintChart` draws the ring (same step 8b).
**Tests** — `dev/route.test.mjs` (already-there short-circuit, path
validity over real gate edges, `next` is a gate neighbour, determinism +
tree symmetry (a→b reversed === b→a), strong connectivity across sampled

View File

@ -49,14 +49,21 @@ export function edgeKey(a, b) {
* (`"${from}>${to}"`, GameScene.activatedGates) the lanes a JUMP
* can currently be confirmed from the current system
* @param {string|null} [o.currentSystemId]
* @param {Iterable<string>} [o.route] edge keys (edgeKey) on the active
* route to the destination (GameScene.routeEdgeKeys) the lanes the
* player will fly; marked `route: true` so the plate draws them orange.
* @param {string|null} [o.destinationId] the destination SYSTEM id its
* star is marked `isDestination: true` so the plate circles it.
* @returns {{name:string, seed:string, homeSystemId:string|null,
* currentSystemId:string|null, systems:Array<object>, edges:Array<object>,
* currentSystemId:string|null, destinationId:string|null,
* systems:Array<object>, edges:Array<object>,
* stats:{systems:number, visited:number, lanes:number, lanesUsed:number}}}
*/
export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null } = {}) {
export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live = null, currentSystemId = null, route = null, destinationId = null } = {}) {
const visitedSet = new Set(visited ?? []);
const usedSet = new Set(used ?? []);
const liveSet = new Set(live ?? []);
const routeSet = new Set(route ?? []);
const network = galaxy?.jumpNetwork ?? null;
const homeId = galaxy?.homeSystemId ?? null;
@ -72,6 +79,9 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
visited: visitedSet.has(s.id),
isHome: s.id === homeId,
isCurrent: s.id === currentSystemId,
// The DESTINATION star — the route's final stop. The plate circles
// it (GalaxyView) so the "where am I going" target is unmistakable.
isDestination: s.id === destinationId,
gates: network?.gates?.get?.(s.id)?.length ?? 0,
// FACTIONS (planned — not yet implemented): the system's faction
// id + the player's relation tier (Neutral / Friendly / Aligned /
@ -100,6 +110,10 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
b: hi,
key,
used,
// ROUTE — a lane on the active route to the destination: the
// plate's "where am I going" path, drawn in the route's orange
// (data/gates.json → route.compassColor), above used/frontier.
route: routeSet.has(key),
// FRONTIER — a lane leaving the charted region (exactly one end
// visited): the "next step" of the maze, drawn brighter than the
// unexplored web.
@ -124,6 +138,7 @@ export function buildGalaxySnapshot({ galaxy, visited = null, used = null, live
seed: galaxy?.seed ?? '',
homeSystemId: homeId,
currentSystemId,
destinationId,
systems,
edges,
stats,

View File

@ -159,9 +159,14 @@ export function resourceStats(discovery, systemId, content) {
* absent nothing discovered (the safe default for a REMOTE system
* the stats share the same footing)
* @param {object} [o.discovery] the Discovery state (for the stats)
* @param {string|null} [o.destinationId] the DESTINATION object's id, if
* this system is the destination and the destination names an object
* (GameScene.systemChartSnapshotFor passes it). The chart circles it in
* the route's orange (data/gates.json route.compassColor) so the final
* stop is unmistakable. Null/absent no destination ring.
* @returns {?{systemId:string, systemName:string, isHome:false,
* central:object, objects:object[], tethers:number[],
* ship:null, stats:{nav:object, res:object}}}
* ship:null, destinationId:string|null, stats:{nav:object, res:object}}}
* (null when the inputs are missing)
*/
export function systemChartSnapshot(systemId, content, o = {}) {
@ -258,6 +263,9 @@ export function systemChartSnapshot(systemId, content, o = {}) {
objects,
tethers: [], // the player's tethers live in the CURRENT system only
ship: null, // the ship lives in the CURRENT system only
// The DESTINATION object (if this system is the destination and the
// destination names one) — the chart circles it (MapWindow.paintChart).
destinationId: typeof o.destinationId === 'string' ? o.destinationId : null,
stats: {
nav: navDiscoveryStats(o.discovery, systemId, content),
res: resourceStats(o.discovery, systemId, content),

View File

@ -2426,6 +2426,11 @@ export class GameScene extends Phaser.Scene {
used: this.usedGates,
live: this.activatedGates,
currentSystemId: this.systemRecord.id,
// The active route (data/gates.json → route): its lanes drawn orange
// and the destination star circled (js/ui/GalaxyView.js). Absent when
// no destination is set (the route is derived — routePlan is null).
route: this.routeEdgeKeys(),
destinationId: this.destination?.systemId ?? null,
});
}
@ -2545,6 +2550,33 @@ export class GameScene extends Phaser.Scene {
objects,
tethers,
ship: this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null,
// The DESTINATION object (if the player is in the destination system
// and the destination names one) — the chart circles it in the route's
// orange (data/gates.json → route.compassColor) so the final stop is
// unmistakable. Null otherwise (no destination, or the destination is
// in another system — then the route is shown on the GALAXY tab).
destinationId: this.destination?.objectId && this.destination.systemId === sysId
? this.destination.objectId
: null,
// The ROUTE EXIT GATE — the jump gate in THIS system that leads toward
// the destination (routeNextGate). Shown when the destination is OUTSIDE
// the current system (the player needs to leave this system to reach
// it): the chart circles it in orange + draws an orange line from the
// ship to it ("path out of the system"). Null when the destination is
// in this system (then destinationId is set instead) or there is no
// active route.
routeExitGate: (() => {
const d = this.destination;
if (!d?.systemId || d.systemId === sysId) return null;
const gate = this.routeNextGate();
if (!gate) return null;
return {
id: gate.discoveryId,
x: gate.x,
y: gate.y,
name: gate.discoveryName ?? 'Gate',
};
})(),
stats: {
nav: navDiscoveryStats(disc, sysId, content),
res: resourceStats(disc, sysId, content),
@ -2569,6 +2601,11 @@ export class GameScene extends Phaser.Scene {
return systemChartSnapshot(systemId, this.galaxy.contentOf(systemId), {
discovery: this.discovery,
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
// The DESTINATION object (if this is the destination system and the
// destination names one) — the chart circles it in the route's orange.
destinationId: this.destination?.objectId && this.destination.systemId === systemId
? this.destination.objectId
: null,
});
}
@ -2640,6 +2677,21 @@ export class GameScene extends Phaser.Scene {
return planRoute(this.galaxy, this.systemRecord.id, this.destination.systemId);
}
/**
* The lanes on the active route the `edgeKey`s of the segments of the
* planRoute path (consecutive system pairs). The galaxy plate uses this
* to draw the route in orange (data/gates.json route.compassColor):
* the "where am I going" path across the map. Empty when no destination.
* @returns {Array<string>} edge keys (js/galaxy/GalaxyChart.js edgeKey)
*/
routeEdgeKeys() {
const plan = this.routePlan();
if (!plan?.path || plan.path.length < 2) return [];
const out = [];
for (let i = 0; i < plan.path.length - 1; i++) out.push(edgeKey(plan.path[i], plan.path[i + 1]));
return out;
}
/**
* The route's NEXT POINT the jump gate in the CURRENT system that
* leads toward the destination (its gate.to === the route's next

View File

@ -180,6 +180,7 @@ export class GalaxyView {
zoom: config.get('map.galaxy.zoom', {}),
reveal: config.get('map.galaxy.reveal', {}),
dialog: config.get('map.galaxy.dialog', {}),
destination: config.get('map.galaxy.destination', {}),
};
this._snap = null;
@ -587,12 +588,15 @@ export class GalaxyView {
const seg = clipLineToRect(a.lx, a.ly, b.lx, b.ly, this._plateRect);
if (!seg) continue;
const [ax, ay, bx, by] = seg;
const st = e.used ? ec.used : e.frontier ? ec.frontier : ec.unexplored;
// ROUTE lanes (the active SET DESTINATION path) read first — the
// "where am I going" line in orange (data/map.json → galaxy.edges.route,
// color = data/gates.json → route.compassColor) — above used/frontier.
const st = e.route ? ec.route : e.used ? ec.used : e.frontier ? ec.frontier : ec.unexplored;
const color = toColor(st.color);
const alpha = st.alpha ?? 0.5;
const width = Math.max(0.6, (st.width ?? 1) * (0.75 + 0.25 * k));
if (e.used) {
// the TRAVELED lane — three passes: halo, body, bright core
if (e.route || e.used) {
// the ROUTE / TRAVELED lane — three passes: halo, body, bright core
g.lineStyle(width + 5, color, alpha * 0.14);
g.lineBetween(ax, ay, bx, by);
g.lineStyle(width + 1.5, color, alpha * 0.42);
@ -907,6 +911,26 @@ export class GalaxyView {
this._strokeClippedRing(g, cur.lx, cur.ly, dot * (1.6 + 2.2 * u), 1.6, toColor(C.neon), 0.85 * (1 - u));
this._strokeClippedRing(g, cur.lx, cur.ly, dot * 1.35, 1, toColor(C.neon), 0.55);
}
// The DESTINATION star — the route's final stop. A solid orange ring
// (data/map.json → galaxy.destination, color = data/gates.json →
// route.compassColor) so the "where am I going" target is unmistakable.
// Shown only while a destination is set (snapshot.destinationId); the
// star may still be undiscovered (the target is the thing to find).
const destId = this._snap.destinationId;
if (destId) {
const d = this._starById.get(destId);
if (d) {
const dc = this._cfg.destination ?? {};
const color = toColor(dc.color ?? '#ff8c1a');
const base = Math.max(12, dot * (dc.radiusMul ?? 2.1));
const w = Math.max(1.5, Number(dc.width) || 2);
const a = Number(dc.alpha) ?? 0.9;
// a gentle breathing pulse so it reads as "active target"
const breathe = 0.5 + 0.5 * Math.sin(time / 700);
this._strokeClippedRing(g, d.lx, d.ly, base + breathe * 4, w, color, a * (0.7 + 0.3 * breathe));
this._strokeClippedRing(g, d.lx, d.ly, base + 5 + breathe * 4, 1, color, a * 0.35);
}
}
}
/** Reveal ripple out of the home system (the galaxy resolves). */

View File

@ -751,6 +751,80 @@ function paintChart(ctx, w, h, dpr, snap, view) {
// 8 — the tether union boundary (over the fog, sharp)
drawTetherArcs(ctx, snap.tethers ?? [], tf);
// 8b — the DESTINATION object's ring (the route's final stop): a solid
// orange circle (data/gates.json → route.compassColor) over the
// object so the "where am I going" target is unmistakable. Shown
// only when the snapshot names one (destinationId) and that object
// is drawn (discovered) — the undiscovered stays hidden, so a
// ring over a ghost would be noise.
if (snap.destinationId) {
const dest = (snap.objects ?? []).find((o) => o?.id === snap.destinationId);
if (dest?.discovered) {
const x = tf.toX(dest.x);
const y = tf.toY(dest.y);
const rpx = Math.max(6, (dest.radius ?? 60) * tf.scale);
const color = toCss(config.get('gates.route.compassColor', '#ff8c1a'));
ctx.save();
ctx.strokeStyle = color;
ctx.globalAlpha = 0.9;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, rpx + 8, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 0.35;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, rpx + 14, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
// 8c — the ROUTE EXIT GATE (the jump gate the player should path out of
// this system from to reach the destination): an orange circle over
// the gate + an orange line from the ship to it ("path out of the
// system"). Shown when the destination is OUTSIDE the current system
// and the snapshot names an exit gate (routeExitGate). The gate may
// be undiscovered (the player hasn't seen it yet) — the ring + line
// still show WHERE to go, which is the point of the route.
if (snap.routeExitGate) {
const ge = snap.routeExitGate;
const gx = tf.toX(ge.x);
const gy = tf.toY(ge.y);
const color = toCss(config.get('gates.route.compassColor', '#ff8c1a'));
// the gate's drawn radius (look it up in the objects list for its size)
const gateObj = (snap.objects ?? []).find((o) => o?.id === ge.id);
const rpx = gateObj ? Math.max(6, (gateObj.radius ?? 96) * tf.scale) : Math.max(8, 96 * tf.scale);
ctx.save();
// the orange line from the ship to the gate (the "path")
if (snap.ship) {
const sx = tf.toX(snap.ship.x);
const sy = tf.toY(snap.ship.y);
ctx.strokeStyle = color;
ctx.globalAlpha = 0.55;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(gx, gy);
ctx.stroke();
ctx.setLineDash([]);
}
// the orange circle over the gate (the "target")
ctx.strokeStyle = color;
ctx.globalAlpha = 0.9;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(gx, gy, rpx + 10, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 0.35;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(gx, gy, rpx + 16, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 9 — chrome + vignette
drawChrome(ctx, w, h, bounds, tf);
drawVignette(ctx, w, h);