Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig a5152590db Add SET DESTINATION route plotting with compass guidance
- New `js/galaxy/Route.js` derives the unique path between systems over the spanning-tree jump network (Dijkstra), exposing `planRoute` / `nextSystem`; pure and Node-tested in `dev/route.test.mjs`.
- Persist only the destination (`systemId`, `objectId`) in save data; the route itself is recomputed from the current system, so following it shortens automatically and a detour re-plots.
- Compass marks the route's next gate in orange (shown even while undiscovered) and suppresses that gate's ordinary cyan entry.
- On jump, resolve arrival / on-course / detour before `captureState` and queue REACHED / RE-PLOTTED notices to surface after the destination scene spawns.
- Map console SYSTEM tab now charts a charted star's system; confirming an object sets the destination and closes the map.
- Fix galaxy stars bleeding through the system chart: plate culling respects overall visibility, fog skips when no tethers anchor it, and the boot reveal finalizes to full alpha on tab switch.
- Add `assets/videos/ss-land-03.mp4` and document the route feature in PROJECT_NOTES.
2026-09-07 20:10:15 -06:00
Brian Fertig 6609df2736 Fix SET DESTINATION button overflow in shared ConfirmOverlay
- Add per-show width/height overrides so the SYSTEM tab's longer
  "SET DESTINATION" label gets a wider dialog than AUTOPILOT's ENGAGE
- Grow each button panel to fit its label (_fitBtn) and re-flow title,
  body and buttons into the bottom band (_layout) instead of using fixed
  positions that let long text run past the panel edge
- Record baseWidth so a grown button never stays wide for the next dialog
- Wire map.json systemConfirm/confirm size config through MapWindow
- Add ss-takeoff-02.mp4 asset
2026-09-07 19:23:18 -06:00
12 changed files with 685 additions and 24 deletions

Binary file not shown.

Binary file not shown.

View File

@ -48,5 +48,13 @@
"toast": "JUMP — {dest}",
"dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES",
"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 shows its NEXT point — the current system's jump gate toward the destination — in ORANGE (compassColor), replacing that gate's ordinary cyan arrow; the gate is shown even while still UNDISCOVERED (it is the thing to find). On arrival at the destination the route clears (reachedToast); a jump to any other system re-plots from the detour (replotToast). setToast fires when the destination is set ({dest}/{hops}).",
"compassColor": "#ff8c1a",
"typeLabel": "Route Gate",
"setToast": "DESTINATION SET — {dest} · {hops} HOP(S)",
"reachedToast": "DESTINATION REACHED — {dest}",
"replotToast": "ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}"
}
}

View File

@ -281,8 +281,10 @@
"label": "ENGAGE"
},
"systemConfirm": {
"_comment": "The SYSTEM tab's SET DESTINATION dialog (same shared ConfirmOverlay — the clicked object on a CHARTED star's chart; the ship isn't in that system, so no distance row / autopilot). The destination model is still being defined — CONFIRM acknowledges the pick through onSetDestination (systemId + objectId), CANCEL / scrim / ESC leave the map as it was.",
"_comment": "The SYSTEM tab's SET DESTINATION dialog (same shared ConfirmOverlay — the clicked object on a CHARTED star's chart; the ship isn't in that system, so no distance row / autopilot). The destination model is still being defined — CONFIRM acknowledges the pick through onSetDestination (systemId + objectId), CANCEL / scrim / ESC leave the map as it was. width/height: wider than the AUTOPILOT dialog — the SET DESTINATION button is a long label, so the window gives the button room and the button grows to fit its text (ConfirmOverlay.show sizes it per label).",
"title": "SET DESTINATION",
"label": "SET DESTINATION"
"label": "SET DESTINATION",
"width": 520,
"height": 176
}
}

169
dev/route.test.mjs Normal file
View File

@ -0,0 +1,169 @@
/**
* ROUTE test (dev tool, run with Node no browser):
*
* node dev/route.test.mjs
*
* Pins the pure course-planning behind SET DESTINATION (js/galaxy/Route.js,
* driven by GameScene.setDestination / routeNextGate / _routeForJump):
* - the jump network is a spanning tree (data/gates.json shortcuts:false),
* so there is EXACTLY ONE route between any two systems planRoute must
* return a valid, connected path that honours the gate edges;
* - from === to is already-there (path [id], next null, 0 hops);
* - the path starts at `from`, ends at `to`, and every step is a real gate
* link (both directions exist the tree is bidirected);
* - `next` is the first hop after `from` (what the compass points at) and is
* always a gate neighbour of `from`;
* - DETERMINISM: same seed + endpoints same route; and the tree is
* symmetric, so the fromto path reversed equals the tofrom path;
* - REACHABILITY: every ordered pair resolves (the network is strongly
* connected) no null where a route must exist.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
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);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { planRoute, nextSystem } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Route.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const SEED = 'route-test-seed';
const g = Galaxy.create(SEED);
const ids = g.records.map((r) => r.id);
const HOME = g.currentSystemId;
const adj = (id) => new Set(g.jumpNetwork.gates.get(id) ?? []);
console.log(`\ngalaxy: ${g.records.length} systems, home ${HOME}\n`);
// ----------------------------------------------------------------------
// 1. Already there — from === to
// ----------------------------------------------------------------------
{
const r = planRoute(g, HOME, HOME);
check('from===to → path [id]', Array.isArray(r?.path) && r.path.length === 1 && r.path[0] === HOME);
check('from===to → next null, 0 hops', r?.next === null && r?.hops === 0);
check('from===to → distance 0', r?.distance === 0);
}
// ----------------------------------------------------------------------
// 2. Path validity — endpoints, connectivity, gate edges
// ----------------------------------------------------------------------
{
// Pick a real destination a couple of hops out (not home, not a direct
// neighbour, so the path is non-trivial).
const homeAdj = adj(HOME);
let dest = null;
for (const id of ids) {
if (id === HOME || homeAdj.has(id)) { dest = id; break; }
}
const r = planRoute(g, HOME, dest);
check('resolves a route home → a non-neighbour system', !!r, `dest ${dest}`);
check('path starts at from', r?.path?.[0] === HOME);
check('path ends at to', r?.path?.[r.path.length - 1] === dest);
check('hops = path.length - 1', r?.hops === r.path.length - 1);
// Every step is a real gate link (tree edges run both ways).
let connected = true;
let why = '';
for (let i = 0; i + 1 < r.path.length; i++) {
const a = r.path[i];
const b = r.path[i + 1];
if (!adj(a).has(b) || !adj(b).has(a)) {
connected = false;
why = `${a} !~ ${b}`;
break;
}
}
check('every step is a bidirected gate link', connected, why);
// `next` is the first hop and a gate neighbour of from.
check('next is path[1]', r?.next === r.path[1]);
check('next is a gate neighbour of from', adj(HOME).has(r?.next));
check('distance is finite + non-negative', Number.isFinite(r?.distance) && r.distance >= 0);
}
// ----------------------------------------------------------------------
// 3. Determinism + tree symmetry (reverse path)
// ----------------------------------------------------------------------
{
const a = ids[0];
const b = ids[ids.length - 1];
const r1 = planRoute(g, a, b);
const r2 = planRoute(g, a, b);
check('deterministic: same seed ⇒ same route', JSON.stringify(r1) === JSON.stringify(r2));
const rb = planRoute(g, b, a);
const reversed = [...(r1?.path ?? [])].reverse();
check(
'tree symmetry: a→b reversed === b→a',
JSON.stringify(reversed) === JSON.stringify(rb?.path),
`${JSON.stringify(r1?.path)} vs ${JSON.stringify(rb?.path)}`,
);
}
// ----------------------------------------------------------------------
// 4. Reachability — every ordered pair resolves (strongly connected)
// ----------------------------------------------------------------------
{
let unresolved = 0;
let badNext = 0;
// Sample a slice of ordered pairs (the full N² is large; cover a wide mix
// including home and several far systems).
const sample = [HOME, ...ids.filter((id) => id !== HOME).slice(0, 24)];
for (const from of sample) {
for (const to of sample) {
if (from === to) continue;
const r = planRoute(g, from, to);
if (!r || r.path[0] !== from || r.path[r.path.length - 1] !== to) {
unresolved++;
continue;
}
// next must be a gate neighbour of from (the compass's target gate).
if (r.next !== null && !adj(from).has(r.next)) badNext++;
}
}
check('every sampled ordered pair resolves (strong connectivity)', unresolved === 0, `${unresolved} unresolved`);
check('every route\'s next is a gate neighbour of from', badNext === 0, `${badNext} bad`);
}
// ----------------------------------------------------------------------
// 5. nextSystem helper
// ----------------------------------------------------------------------
{
const dest = ids.find((id) => id !== HOME) ?? HOME;
const r = planRoute(g, HOME, dest);
check('nextSystem === planRoute.next', nextSystem(g, HOME, dest) === (r?.next ?? null));
check('nextSystem(from,from) === null', nextSystem(g, HOME, HOME) === null);
}
// ----------------------------------------------------------------------
// 6. Guard rails — unknown endpoints
// ----------------------------------------------------------------------
{
check('unknown from → null', planRoute(g, 'NOPE', HOME) === null);
check('unknown to → null', planRoute(g, HOME, 'NOPE') === null);
check('non-string → null', planRoute(g, null, HOME) === null);
// No galaxy + a real destination (from ≠ to) → no route to compute.
// (from === to is the "already there" short-circuit — valid without a
// graph, so it is asserted in section 1, not here.)
check('no galaxy + distinct endpoints → null', planRoute(null, HOME, 'S000001') === null);
check('empty galaxy + distinct endpoints → null', planRoute({ byId: new Map(), jumpNetwork: { gates: new Map() } }, 'A', 'B') === null);
}
console.log(`\n${failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'}\n`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -369,12 +369,18 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
## Galaxy map — the MAP console's GALAXY tab
The deck's MAP button opens the cartography console
(`js/ui/MapWindow.js`, `data/map.json`, depth 80). It has two lives on
the plate, switched by the tabs:
(`js/ui/MapWindow.js`, `data/map.json`, depth 80). It has three tabs on
the plate:
- **CURRENT SYSTEM** — the system chart (canvas-painted: discovered
objects, the tether union, the fog of war), hover + ENGAGE AUTOPILOT,
wheel zoom / drag pan. Unchanged by the galaxy work.
- **SYSTEM** — the same painter applied to a CHARTED star's system
(no ship/tether): pick a charted star on the GALAXY tab to arm it, then
click an object on its chart to **SET DESTINATION** (see the Route
section below). Locked ("NO SYSTEM TARGET") until a charted star is
picked. The shared `ConfirmOverlay` is label-sized to fit the dialog
(`baseWidth` + measured width, per-show `width`/`height` overrides).
- **GALAXY** (live — `tabs.galaxy.standby` is the killswitch; `true`
returns it to the old "OFFLINE" toast) — the whole-galaxy chart,
`js/ui/GalaxyView.js`, fed by `GameScene.galaxySnapshot()`
@ -431,6 +437,61 @@ alpha). The settlements' `owner: null` seam (above) +
the parallel-polygon corners, the pulse's bounds + determinism, the
archetype colors, and the save round-trip incl. the legacy default).
## Route — SET DESTINATION (the MAP console's SYSTEM tab)
The SYSTEM tab charts a CHARTED star's system (the same painter as the
CURRENT SYSTEM tab, minus the ship/tether). Clicking an object on that
chart asks **SET DESTINATION** (`MapWindow._askObject` → the shared
`ConfirmOverlay`, widened to fit the label). Confirming plots the route
and closes the map window.
**The route is between SYSTEMS, and it is DERIVED** (`js/galaxy/Route.js`,
pure + Node-tested). The run persists ONLY the destination —
`GameScene.destination = { systemId, objectId }` (registry-backed, like
`visitedSystems`). The path from wherever the ship is NOW to that system
is computed on demand by `planRoute()` (Dijkstra by travel distance over
the gate graph — the jump network is a spanning tree,
`data/gates.json → shortcuts:false`, so this is the unique route;
strong connectivity guarantees one always exists). Deriving it (instead
of storing a progress counter) makes the follow/detour rules automatic:
- **Set** (`setDestination`) — store the destination, toast the hop count
(`data/gates.json → route.setToast`), close the map. The compass then
points at the route's NEXT STEP.
- **Next step** (`routeNextGate`) — the current system's jump gate whose
`gate.to` is the route's first hop. `updateDiscovery()` marks it on the
compass in **ORANGE** (`route.compassColor`), replacing that gate's
ordinary cyan arrow and showing it even while still UNDISCOVERED (it is
the thing to find). The gate's normal compass entry is suppressed so the
two don't stack; on screen there is no arrow (the player can see it).
- **Jump** (`jumpThroughGate` → `_routeForJump`, BEFORE `captureState` so
the cleared state is what saves): entered the DESTINATION → route done,
clear `destination` (the compass orange drops) + a REACHED notice;
entered the route's NEXT → on course, silent (the derived route just
shortens); entered anything else → a DETOUR, the route re-plots from the
detour + a RE-PLOTTED notice. Because the route is derived, the re-plot
is automatic — this only announces it.
- **Notices** — a jump fires its own toast + full-screen clip, which would
swallow an immediate REACHED/RE-PLOTTED toast, so those are QUEUED in
the registry (`routeNotice`) and surfaced by the DESTINATION scene's
`create()` a beat after spawn. `resetRunState` clears it (a New Game
inherits no pending notice).
- **Save**`destination` is captured/restored/reset in
`js/save/SaveData.js` (a save predating it has no field → no route);
the route itself is re-derived at load from the saved current system.
**Tuning** — `data/gates.json → route` (the orange `compassColor`, the
`typeLabel`, the three toasts with their `{dest}`/`{hops}` placeholders).
**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
ordered pairs, guard rails). The compass orange, the map close, and the
notice queue are Phaser glue — verified in a headless browser
(`dev/shot-firefox.mjs` / geckodriver `execute/sync`):
set → orange `route:<gate>` compass entry (`#ff8c1a`); arrive → route
clears + REACHED notice queued; detour → destination kept + RE-PLOTTED
notice; clear → the orange entry drops on the next compass reconcile.
## The tether — the player's range (important)
The ship starts with **one level-1 tether** anchored on the home world

112
js/galaxy/Route.js Normal file
View File

@ -0,0 +1,112 @@
/**
* Route a plotted course between two SYSTEMS of the jump network.
*
* The player sets a destination (SYSTEM tab of the MAP console) and the
* run plots the route it will take to get there. The compass then marks
* the NEXT point of that route the jump gate in the current system that
* leads toward the destination in orange, so the player always knows
* where to fly next.
*
* THE JUMP NETWORK IS A SPANNING TREE (data/gates.json shortcuts:false),
* so there is EXACTLY ONE route between any two systems the galaxy reads
* as a maze. planRoute still computes the CHEAPEST path (Dijkstra by travel
* distance over the gate graph), which is that unique route under the
* current config and degrades gracefully to "the fastest route" if
* shortcuts are ever enabled. Deterministic: same galaxy + endpoints
* same route.
*
* ROUTE STATE IS DERIVED. The run persists only the DESTINATION
* (systemId + objectId); the path from wherever the ship is NOW to that
* destination is computed on demand. That makes the follow/detour rules
* automatic, with no progress counter to desync:
* · jump to the route's next system the remainder just shortens
* (re-plan from the new system, same destination);
* · jump anywhere else (a DETOUR) the route re-plots from the detour
* (GameScene toasts the re-plot);
* · arrive at the destination system the route is DONE and clears.
*
* Pure module (no Phaser) Node-testable (dev/route.test.mjs).
*/
/** Euclidean distance between two roster points. */
function dist(ax, ay, bx, by) {
const dx = ax - bx;
const dy = ay - by;
return Math.hypot(dx, dy);
}
/**
* Plan the route between two systems.
*
* @param {object} galaxy the run's Galaxy: needs `byId` (Map sysId
* record with x/y) and `jumpNetwork.gates` (Map sysId [sysId, ]).
* @param {string} fromId the current system.
* @param {string} toId the destination system.
* @returns {{path: string[], next: string|null, hops: number, distance: number}|null}
* `path` is [from, , to]; `next` is the FIRST hop after `from` (null
* when from === to already there); `hops` = path.length - 1. `null`
* when an endpoint is unknown or there is no route (should not happen
* the network is strongly connected by construction).
*/
export function planRoute(galaxy, fromId, toId) {
if (typeof fromId !== 'string' || typeof toId !== 'string') return null;
if (fromId === toId) return { path: [toId], next: null, hops: 0, distance: 0 };
const byId = galaxy?.byId;
const gates = galaxy?.jumpNetwork?.gates;
if (!byId || !gates || !byId.has(fromId) || !byId.has(toId)) return null;
if (typeof byId.get(fromId)?.x !== 'number' || typeof byId.get(toId)?.x !== 'number') return null;
// Dijkstra by travel distance (edge cost = the hop's Euclidean span).
// The graph is small (a few hundred systems, degree ≤ maxGates), so a
// plain O(V²) select is plenty; ties break on system id (deterministic).
const best = new Map(); // sysId → best distance seen
best.set(fromId, 0);
const prev = new Map(); // sysId → predecessor on the best path
const done = new Set();
for (;;) {
let u = null;
let du = Infinity;
for (const [id, d] of best) {
if (done.has(id)) continue;
if (d < du || (d === du && (u === null || id < u))) { u = id; du = d; }
}
if (u === null) break; // nothing left to relax
if (u === toId) break; // reached the goal at its final cost
done.add(u);
const ru = byId.get(u);
for (const v of gates.get(u) ?? []) {
if (done.has(v) || !byId.has(v)) continue;
const rv = byId.get(v);
const nd = du + dist(ru.x, ru.y, rv.x, rv.y);
if (nd < (best.get(v) ?? Infinity)) {
best.set(v, nd);
prev.set(v, u);
}
}
}
if (!best.has(toId)) return null; // unreachable — defensive (a tree can't do this)
const path = [];
let cur = toId;
for (;;) {
path.push(cur);
if (cur === fromId) break;
cur = prev.get(cur);
if (cur === undefined) return null; // no chain back — defensive
}
path.reverse();
return {
path,
next: path.length > 1 ? path[1] : null,
hops: path.length - 1,
distance: best.get(toId),
};
}
/**
* The system the route leaves NEXT from `fromId` on the way to `toId`
* (planRoute's `next`), or null when already there / no route.
*/
export function nextSystem(galaxy, fromId, toId) {
return planRoute(galaxy, fromId, toId)?.next ?? null;
}

View File

@ -123,6 +123,14 @@ export function captureState(scene, now) {
// (old saves load, the region starts small).
visitedSystems: Array.from(scene.visitedSystems ?? []),
usedGates: Array.from(scene.usedGates ?? []),
// THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/
// Route.js): { systemId, objectId } | null. Only the destination is
// stored — the route itself is derived from the current system at load
// time (a save predating it has no field → no destination).
destination:
scene.destination && typeof scene.destination.systemId === 'string'
? { systemId: scene.destination.systemId, objectId: scene.destination.objectId ?? null }
: null,
playTimeMs: Math.round(scene.playTimeMs ?? 0),
};
const err = SaveManager.validateRecord(rec);
@ -180,6 +188,14 @@ export function prepareLoad(registry, record) {
'usedGates',
new Set(Array.isArray(record.usedGates) ? record.usedGates : []),
);
// The plotted destination (a save predating it has no field → null).
// The route is derived from the current system at play time.
registry.set(
'destination',
record.destination && typeof record.destination.systemId === 'string'
? { systemId: record.destination.systemId, objectId: record.destination.objectId ?? null }
: null,
);
registry.set(PENDING_RESTORE_KEY, {
ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [],
@ -211,6 +227,8 @@ export function resetRunState(registry) {
registry.set('activatedGates', null);
registry.set('visitedSystems', null);
registry.set('usedGates', null);
registry.set('destination', null);
registry.set('routeNotice', null);
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -35,6 +35,7 @@ import { ResearchWindow } from '../ui/ResearchWindow.js';
import { MapWindow } from '../ui/MapWindow.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { planRoute } from '../galaxy/Route.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
import {
@ -320,6 +321,31 @@ export class GameScene extends Phaser.Scene {
this.usedGates = new Set();
this.registry.set('usedGates', this.usedGates);
}
// THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/
// Route.js): where the run is headed. Persisted as { systemId,
// objectId } — the ROUTE itself is derived on demand (planRoute from
// wherever the ship is now to destination.systemId), so following it
// just shortens it and a detour re-plots it (no progress counter to
// desync). Null until the player sets one; cleared on arrival.
this.destination = this.registry.get('destination') ?? null;
// A ROUTE NOTICE parked by the jump we just completed (Route._routeFor
// Jump → _queueRouteNotice): the destination-REACHED / route-RE-PLOTTED
// message. The jump's own toast + full-screen clip already played on
// the source scene, so surface this one here, a beat after spawn, so
// it reads cleanly on the destination system.
{
const notice = this.registry.get('routeNotice');
if (notice && typeof notice.text === 'string') {
this.registry.set('routeNotice', null);
this.time.delayedCall(900, () =>
this.consoleToast(notice.text, {
glyph: notice.glyph ?? '◆',
glyphColor: notice.glyphColor ?? toCss(this._routeColor()),
durationMs: notice.durationMs ?? 3400,
}),
);
}
}
this.visitedSystems.add(this.systemRecord.id); // we start here — it's charted
for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
@ -1476,16 +1502,42 @@ export class GameScene extends Phaser.Scene {
const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects);
for (const o of fresh) this.celebrateDiscovery(o);
// Which discovered objects are NOT on screen right now? (The camera
// never zooms, so the world view is scroll + canvas size.)
// Which objects are NOT on screen right now? (The camera never
// zooms, so the world view is scroll + canvas size.)
const cam = this.cameras.main;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
// The active route's NEXT POINT — the current system's jump gate
// toward the destination (routeNextGate). It claims a compass slot in
// ORANGE (data/gates.json → route.compassColor) so the player always
// knows where to fly next — and is shown EVEN while still undiscovered
// (it is the thing to find). That gate's ordinary (cyan) entry is
// suppressed so the two don't stack; when the gate is on screen there
// is no arrow at all (the player can see it) and the suppress is moot.
const routeGate = this.routeNextGate();
const routeGateId = routeGate?.discoveryId ?? null;
const routeOffscreen = !!(
routeGate && !circleInView(routeGate.x, routeGate.y, routeGate.bound, view)
);
const offscreen = [];
for (const o of objects) {
if (o.id === routeGateId) continue; // the route arrow takes this slot
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
offscreen.push(o);
}
}
if (routeOffscreen) {
offscreen.push({
id: `route:${routeGate.discoveryId}`,
x: routeGate.x,
y: routeGate.y,
radius: routeGate.bound,
typeLabel: config.get('gates.route.typeLabel', 'Route Gate'),
color: config.get('gates.route.compassColor', '#ff8c1a'),
name: routeGate.discoveryName,
});
}
// The ship's position drives the compass's FAR display (targets
// beyond game.discovery.compass.farDistance fold to their compact
// chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js).
@ -1759,6 +1811,11 @@ export class GameScene extends Phaser.Scene {
// the current config): the destination's origin (its center is empty —
// the star is invisible flavor, so this is just open space).
const destContent = this.galaxy.ensureContent(destId);
// ROUTE BOOKKEEPING — resolve what this hop means for the active route
// BEFORE captureState below snapshots the run (so a destination we've
// just reached is cleared in the saved state, not resurrected):
// arrived-at-destination → clear; on-course → silent; detour → toast.
this._routeForJump(from.id, destId);
// THE RUN'S FOOTPRINT — record the lane we just traveled + the system
// we are entering (the GALAXY tab's brighter lane + charted region;
// captured into `rec` right below, so the save carries it).
@ -2476,19 +2533,140 @@ export class GameScene extends Phaser.Scene {
* defined for now the pick is acknowledged (the seam is stable:
* systemId + objectId).
*/
/**
* SET DESTINATION (the MAP console's SYSTEM tab MapWindow._askObject):
* record the target system and plot the route to it. The route's FIRST
* STEP is what the player flies now the current system's jump gate
* toward the destination (routeNextGate) and it is marked ORANGE on
* the compass (updateDiscovery) so the player always knows where to go
* next. The MAP window closes itself once this returns (MapWindow's
* confirm handler calls this.close()).
*
* The ROUTE IS DERIVED (js/galaxy/Route.js): only the DESTINATION
* (systemId + the object within it) is stored the path from wherever
* the ship is NOW to that system is computed on demand. Following the
* route (a jump to its next system) just shortens it; a DETOUR (a jump
* anywhere else) re-plots it from the detour (_routeForJump toasts the
* re-plot); arriving at the destination CLEARS it.
*/
setDestination(systemId, objectId) {
const content = this.galaxy?.contentOf?.(systemId);
const name =
(content?.planets ?? []).find((p) => p.name === objectId)?.name ??
(content?.settlements ?? []).find((s) => s.id === objectId)?.name ??
(content?.jumps ?? []).find((j) => j.id === objectId)?.name ??
(content?.asteroids ?? []).find((c) => c.id === objectId)?.name ??
objectId;
const sys = content?.name ?? systemId;
console.info(`[orbit] set destination: ${systemId}${objectId}`);
this.consoleToast(`DESTINATION NOTED — ${String(name).toUpperCase()} · ${String(sys).toUpperCase()}`, {
const sys = this.galaxy?.byId?.get(systemId);
if (!sys) {
this.consoleToast('UNKNOWN DESTINATION — NO SUCH SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
if (systemId === this.systemRecord.id) {
this.consoleToast('ALREADY AT THAT SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
// Store the destination (the ROUTE itself is derived from it). The
// object within the destination system is carried along for the
// arrival handling (autopilot / the REACHED toast).
this.destination = { systemId, objectId: objectId ?? null };
this.registry.set('destination', this.destination);
const plan = planRoute(this.galaxy, this.systemRecord.id, systemId);
const hops = plan?.hops ?? 0;
this.consoleToast(
String(config.get('gates.route.setToast', 'DESTINATION SET — {dest} · {hops} HOP(S)'))
.replace('{dest}', String(sys.name).toUpperCase())
.replace('{hops}', String(hops)),
{ glyph: '◆', glyphColor: toCss(this._routeColor()) },
);
}
/** The route's accent — orange (data/gates.json → route.compassColor). */
_routeColor() {
return toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a);
}
/**
* The CURRENT route: the path from this system to the destination
* (planRoute), or null when no destination is set.
*/
routePlan() {
if (!this.destination?.systemId) return null;
return planRoute(this.galaxy, this.systemRecord.id, this.destination.systemId);
}
/**
* The route's NEXT POINT the jump gate in the CURRENT system that
* leads toward the destination (its gate.to === the route's next
* system). This is what the compass marks in orange and what the
* player flies to. null when there is no active route, the route is
* complete (we are at the destination), or the current system holds no
* gate toward the next system (defensive the network is connected,
* so a next system always has a gate here).
*/
routeNextGate() {
const plan = this.routePlan();
if (!plan?.next) return null;
for (const gt of this.systemGates ?? []) if (gt.gate?.to === plan.next) return gt;
return null;
}
/**
* Route bookkeeping at a JUMP (called from jumpThroughGate with the
* system we are LEAVING and the one we are ENTERING). Derives what the
* hop means for the active route and QUEUES a notice (routeNotice the
* toast must appear AFTER the jump cut, on the destination scene: the
* jump itself fires its own toast + full-screen clip, which would
* swallow an immediate one):
* · entering the DESTINATION route complete clear it (REACHED);
* · entering the route's NEXT on course (the derived route simply
* shortens no notice, the compass already pointed there);
* · entering anything else a DETOUR re-plot from the detour
* (RE-PLOTTED). Because the route is derived, the re-plot is
* automatic; this only announces it.
* The state change (clearing the destination on arrival) happens NOW
* before captureState snapshots the run so the saved state is right;
* only the player-facing message is deferred.
*/
_routeForJump(fromId, destId) {
if (!this.destination?.systemId) return; // no active route
const destId2 = this.destination.systemId;
const destName = this.galaxy?.byId?.get(destId2)?.name ?? destId2;
if (destId === destId2) {
// Arrived at the destination — the route is done. Clear the tracked
// destination (the compass orange drops, the SYSTEM tab unlocks for
// the next target), and queue the REACHED notice for the new scene.
this.destination = null;
this.registry.set('destination', null);
this._queueRouteNotice(
String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}'))
.replace('{dest}', String(destName).toUpperCase()),
);
return;
}
// Not the destination — were we on the route, or off it?
const expected = planRoute(this.galaxy, fromId, destId2)?.next ?? null;
if (expected === destId) return; // on course — the route just shortened
const plan = planRoute(this.galaxy, destId, destId2);
const hops = plan?.hops ?? 0;
this._queueRouteNotice(
String(config.get('gates.route.replotToast', 'ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}'))
.replace('{hops}', String(hops))
.replace('{dest}', String(destName).toUpperCase()),
);
}
/**
* Park a route notice (data/gates.json route.* toast) in the shared
* registry so the DESTINATION scene's create() can surface it after the
* jump cut (the jump's own toast + clip would swallow an immediate one).
* Consumed (and cleared) exactly once by the next GameScene.create().
*/
_queueRouteNotice(text) {
this.registry.set('routeNotice', {
text,
glyph: '◆',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
glyphColor: toCss(this._routeColor()),
durationMs: 3400,
});
}

View File

@ -120,6 +120,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
);
this.confirmBtn.setAlpha(0);
this.cancelBtn.setAlpha(0);
// the natural widths (show() grows a button only as wide as its label
// needs — and never leaves it grown for the NEXT dialog, which may
// have a shorter label)
this.confirmBtn.style.baseWidth = this.confirmBtn.style.width;
this.cancelBtn.style.baseWidth = this.cancelBtn.style.width;
this.add([this.cancelBtn, this.confirmBtn]);
// Hidden dialog = inert buttons (same v4 `input.enabled` rule as the
// scrim — otherwise the invisible CONFIRM/CANCEL swallow centre-
@ -130,7 +135,49 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this._titleDec = null;
this._btnsUp = false;
this._confirmUp = false;
this._layout(); // size/label land in show(); positions derive from w/h
}
/**
* Place the title, body lines and the two buttons for the CURRENT
* w/h + button sizes: the buttons sit in the panel's bottom band
* (CANCEL left, CONFIRM right each kept inside the panel edge with
* a 20px margin, whatever its label grows it to).
*/
_layout() {
this.title.setPosition(0, -this.h / 2 + 24);
this.bodyTexts.forEach((t, i) => t.setPosition(0, -this.h / 2 + 52 + i * 17));
const margin = 20;
const cw = this.cancelBtn.style.width;
const bw = this.confirmBtn.style.width;
const bh = this.confirmBtn.style.height;
const cy = this.h / 2 - margin - bh / 2;
this.cancelBtn.setPosition(-this.w / 2 + margin + cw / 2, cy);
this.confirmBtn.setPosition(this.w / 2 - margin - bw / 2, cy);
}
/**
* Grow a button's panel to FIT its label (the SET DESTINATION label is
* wider than the fixed 148px the CONFIRM button was built for the
* text used to run past the panel edge). Re-measures with the button's
* own text style, then repaints + resets the hit area.
*/
_fitBtn(btn, label, minWidth) {
const s = btn.style;
const m = this.scene.add.text(0, 0, String(label), s.textStyle);
const w = Math.max(s.baseWidth ?? s.width, minWidth, Math.ceil(m.width) + 28);
m.destroy();
if (w === s.width) return;
s.width = w;
btn.setSize(w, s.height);
btn.panel.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(-w / 2, -s.height / 2, w, s.height),
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
btn.paint('base');
}
// ------------------------------------------------------------------
// Showing
// ------------------------------------------------------------------
@ -139,6 +186,9 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
* spec: {
* title, body: string[], accent?: number,
* confirmLabel?, cancelLabel?,
* width?, height? override the dialog's size for THIS show (the
* SYSTEM tab's SET DESTINATION is wider than AUTOPILOT's ENGAGE
* its confirm label is longer); the layout re-flows for it,
* onConfirm?, onCancel? fired after the close animation lands
* time the engine time (for the open animation)
* }
@ -148,6 +198,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null;
this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null;
// Per-show size (the SET DESTINATION dialog is wider than ENGAGE).
if (Number(spec.width) > 0) this.w = Number(spec.width);
if (Number(spec.height) > 0) this.h = Number(spec.height);
this.setSize(this.w, this.h);
const setBtn = (btn, label) => {
const s = String(label ?? '').toUpperCase();
btn.labelText.setText(s);
@ -156,6 +211,12 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
};
setBtn(this.confirmBtn, spec.confirmLabel ?? 'CONFIRM');
setBtn(this.cancelBtn, spec.cancelLabel ?? 'CANCEL');
// The confirm button must FIT its label (the longest one is
// SET DESTINATION) — grow the panel, then place both in the bottom
// band, inside the panel edges.
this._fitBtn(this.confirmBtn, String(spec.confirmLabel ?? 'CONFIRM').toUpperCase(), 110);
this._fitBtn(this.cancelBtn, String(spec.cancelLabel ?? 'CANCEL').toUpperCase(), 88);
this._layout();
// Accent the confirm button's edge + hover glow.
this.confirmBtn.style.stroke = this.accent;
this.confirmBtn.style.neon = this.accent;

View File

@ -520,8 +520,15 @@ export class GalaxyView {
}
/** Stars whose centers have left the plate window are culled (their
glow/core are textures the only clean clip for them). */
glow/core are textures the only clean clip for them).
CRITICAL: this runs from setVisible() *after* the bulk hide/show, so it
must respect the view's overall visibility otherwise re-showing the
in-plate stars here resurrects them and the whole galaxy layer bleeds
through the system chart (the "galaxy stars behind it" bug). When the
view is off, every star stays off, regardless of plate culling. */
_applyCull() {
const show = this._visible;
for (const st of this._stars) {
const inPlate =
st.lx >= this.px &&
@ -529,8 +536,8 @@ export class GalaxyView {
st.ly >= this.py &&
st.ly <= this.py + this.ph;
st.culled = !inPlate;
st.glow.setVisible(inPlate);
st.core.setVisible(inPlate);
st.glow.setVisible(show && inPlate);
st.core.setVisible(show && inPlate);
st.label.setVisible(this._labelShown(st));
}
}

View File

@ -737,8 +737,16 @@ function paintChart(ctx, w, h, dpr, snap, view) {
drawLabel(ctx, j.x, ly, j.name, j.sub);
}
// 7 — FOG: dim everything the tether union doesn't cover
drawFog(ctx, w, h, dpr, snap.tethers ?? [], tf);
// 7 — FOG OF WAR: dim everything the tether union doesn't cover.
// The fog is anchored to the player's tether reach — a CURRENT-system
// concept. A CHARTED star's chart (the SYSTEM tab) has no tethers
// (ship + tethers live only in the current system), so there is
// nothing to anchor the fog to: applying it would paint the WHOLE
// plate at fogAlpha (0.55) and wash the discovered planets / gates /
// star down to ~45% — the "objects look semi-transparent" the player
// reported. Skip it, and the charted objects read at full strength.
const fogTethers = snap.tethers ?? [];
if (fogTethers.length) drawFog(ctx, w, h, dpr, fogTethers, tf);
// 8 — the tether union boundary (over the fog, sharp)
drawTetherArcs(ctx, snap.tethers ?? [], tf);
@ -1325,6 +1333,11 @@ export class MapWindow extends Phaser.GameObjects.Container {
_switchMode(id) {
const inGalaxy = id === 'galaxy';
this._mode = id;
// The player is interacting — the boot reveal is a one-shot animation
// and is done the moment they touch a tab. Finalize it so the plate
// reads as a SOLID, fully-opaque surface: never a translucent overlay
// mid-fade with the galaxy (or the game world) bleeding through.
this._finalizeReveal();
// move the plate's input + surface (the system chart ↔ the galaxy)
setInteractiveEnabled(this.mapImg, !inGalaxy);
this.mapImg.setVisible(!inGalaxy);
@ -1340,7 +1353,13 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._pollNext = 0; // poll the galaxy life immediately
} else {
// back to a system chart (CURRENT SYSTEM or the picked SYSTEM) —
// restore its stats chrome + force a fresh poll (redraw + stats)
// restore its stats chrome + force a fresh poll (redraw + stats).
// Belt-and-braces on the opacity: _finalizeReveal already pushed the
// plate to full alpha, but assert it here too so a plate shown right
// after a switch can never render translucent (the "galaxy behind
// it" the player reported). The galaxy life is off, so nothing
// legitimate sits under the plate to show through.
this.mapImg.setAlpha(1);
this.legendTexts?.forEach((t) => t.setVisible(true));
this.navLabel.setText(this._navLabel0);
this.resLabel.setText(this._resLabel0);
@ -1353,6 +1372,22 @@ export class MapWindow extends Phaser.GameObjects.Container {
this._paintTabs();
}
/** Complete the boot-reveal timeline immediately: snap every revealed
object to its final state (full alpha, rest y, unit scale) and clear
the list so the per-frame driver (update()) stops dialing their
alpha. Called on tab switch the moment the player interacts, the
one-shot open animation is over and everything is at full strength.
No-op once the reveal has already run out. */
_finalizeReveal() {
if (!this.reveal || !this.reveal.length) return;
for (const r of this.reveal) {
r.o?.setAlpha?.(1);
if (r.mode === 'rise' && r.baseY != null) r.o?.setY?.(r.baseY);
else if (r.mode === 'pop') r.o?.setScale?.(1);
}
this.reveal = [];
}
/** Build the galaxy life lazily (first tab switch). */
_ensureGalaxy() {
if (this._galaxy) return this._galaxy;
@ -2512,7 +2547,8 @@ export class MapWindow extends Phaser.GameObjects.Container {
* onSelect (the scene plots the course) and closes the console.
* SYSTEM SET DESTINATION on a charted star's object (the ship
* isn't in that system, so no distance row); CONFIRM fires
* onSetDestination (the destination model is still being defined).
* onSetDestination (the scene plots the route see the Route
* section in PROJECT_NOTES), and the map closes.
* CANCEL, the scrim, or ESC leave the map exactly as it was. While the
* dialog is up the plate is inert (the guards in _startDrag /
* _onWheelZoom / _setHover the scrim covers the whole window, so it
@ -2530,6 +2566,11 @@ export class MapWindow extends Phaser.GameObjects.Container {
body: [`DESTINATION — ${label}`, sysName ? `SYSTEM — ${sysName}` : ''].filter(Boolean),
accent: C.neon,
confirmLabel: config.get('map.systemConfirm.label', 'SET DESTINATION'),
// wider than AUTOPILOT's dialog — the SET DESTINATION label is
// long, and it gets the room (button fits the text, text fits
// the panel)
width: config.get('map.systemConfirm.width', 520),
height: config.get('map.systemConfirm.height', 176),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };
@ -2548,6 +2589,10 @@ export class MapWindow extends Phaser.GameObjects.Container {
body,
accent: C.amber,
confirmLabel: config.get('map.confirm.label', 'ENGAGE'),
// its own (narrower) size — the dialog instance is shared with the
// wider SET DESTINATION one, so each call states its own footprint
width: config.get('map.confirm.width', 440),
height: config.get('map.confirm.height', 168),
onConfirm: () => {
// the pick ripples once while the window fades out
this._flash = { x: o.x + this.geo.mapX, y: o.y + this.geo.mapY, r: o.r, t0: this.scene.time.now };