diff --git a/assets/videos/ss-land-03.mp4 b/assets/videos/ss-land-03.mp4 new file mode 100644 index 0000000..0be1189 Binary files /dev/null and b/assets/videos/ss-land-03.mp4 differ diff --git a/data/gates.json b/data/gates.json index ac6bebc..3c2152f 100644 --- a/data/gates.json +++ b/data/gates.json @@ -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}" } } diff --git a/dev/route.test.mjs b/dev/route.test.mjs new file mode 100644 index 0000000..3f47afb --- /dev/null +++ b/dev/route.test.mjs @@ -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 from→to path reversed equals the to→from 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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 97ffbcb..3dd495f 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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:` 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 diff --git a/js/galaxy/Route.js b/js/galaxy/Route.js new file mode 100644 index 0000000..0b300d1 --- /dev/null +++ b/js/galaxy/Route.js @@ -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; +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index efcae0e..1a3c651 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index ac71b69..0cd709d 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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, }); } diff --git a/js/ui/GalaxyView.js b/js/ui/GalaxyView.js index 8157152..cd308a3 100644 --- a/js/ui/GalaxyView.js +++ b/js/ui/GalaxyView.js @@ -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)); } } diff --git a/js/ui/MapWindow.js b/js/ui/MapWindow.js index 1b71327..7f5add7 100644 --- a/js/ui/MapWindow.js +++ b/js/ui/MapWindow.js @@ -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