diff --git a/data/gates.json b/data/gates.json index 3c2152f..a0a345e 100644 --- a/data/gates.json +++ b/data/gates.json @@ -50,9 +50,10 @@ "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}).", + "_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}).", "compassColor": "#ff8c1a", "typeLabel": "Route Gate", + "destTypeLabel": "Destination", "setToast": "DESTINATION SET — {dest} · {hops} HOP(S)", "reachedToast": "DESTINATION REACHED — {dest}", "replotToast": "ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}" diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index fffae1c..b4f1c74 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -288,6 +288,8 @@ check('lerpAngle eases the short way (3→−3, no long-way sweep)', Math.abs(le check('isFarTarget: one px past ⇒ compact', isFarTarget({ x: 5121, y: 0 }, { x: 0, y: 0 }, 5120) === true); check('isFarTarget: no ship ⇒ full (standalone path)', isFarTarget({ x: 99999, y: 0 }, null, 5120) === false); check('isFarTarget: farDistance off (0) ⇒ full', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 0) === false); + check('isFarTarget: alwaysFull overrides distance (route waypoint)', isFarTarget({ x: 99999, y: 0, alwaysFull: true }, { x: 0, y: 0 }, 5120) === false); + check('isFarTarget: alwaysFull absent ⇒ distance rule holds', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 5120) === true); const fakeScene = { add: { existing() {} }, diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 3dd495f..bdc6dd2 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -457,20 +457,32 @@ 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 + points at the route's NEXT STOP. +- **Next stop** — the route's NEXT STOP is marked on the compass in + **ORANGE** (`route.compassColor`), shown even while still UNDISCOVERED + (it is the thing to find), and its ordinary arrow is suppressed so the two don't stack; on screen there is no arrow (the player can see it). + It is the CURRENT SYSTEM's jump gate toward the destination + (`routeNextGate`, `route:` id, `route.typeLabel`) in an INTERMEDIATE + system, or the DESTINATION OBJECT itself (`routeDestinationObject`, + `dest:` id, `route.destTypeLabel`) once the player is in the destination + system — the last hop is across the system to the world. Both are + `alwaysFull` (never fold to the text-less far chip) and resolvable by + `autopilotTo` (which strips the `route:`/`dest:` prefix). - **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. + the cleared state is what saves): entered the DESTINATION with a specific + OBJECT → the trip is NOT over (the last leg is across the system), so the + destination stays active and the compass now points at that object; entered + the DESTINATION with no object → 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. +- **Reached** (`_checkDestinationReached`, each frame) — when the player is + in the destination system and the ship is within the destination object's + keep-out rim, the trip is done: clear `destination` (the compass orange + drops, the SYSTEM tab unlocks) + a REACHED toast (fired directly — the + player is already in this scene, no jump cut to ride out). - **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 @@ -481,7 +493,8 @@ of storing a progress counter) makes the follow/detour rules automatic: 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). +`typeLabel` / `destTypeLabel`, 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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 0cd709d..45ad46c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1385,6 +1385,7 @@ export class GameScene extends Phaser.Scene { this.tetherField.tick(_time, delta); // glitch/pulse lifecycle this.tetherField.draw(_time); // the barrier (on-screen dots only) this.actionBar?.update(_time, delta); // the deck's living details + this._checkDestinationReached(); // last leg done? clear the destination (REACHED) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } @@ -1507,22 +1508,32 @@ export class GameScene extends Phaser.Scene { 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. + // The active route's NEXT STOP. In an intermediate system it is that + // system's jump gate toward the destination (routeNextGate); in the + // DESTINATION system it is the destination OBJECT itself (the last hop + // is across the system to the world — routeDestinationObject). 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 object's ordinary + // (cyan/green) entry is suppressed so the two don't stack; when it is on + // screen there is no arrow at all (the player can see it) and the + // suppress is moot. The destination object stays orange until the ship + // actually reaches it (_checkDestinationReached). const routeGate = this.routeNextGate(); const routeGateId = routeGate?.discoveryId ?? null; const routeOffscreen = !!( routeGate && !circleInView(routeGate.x, routeGate.y, routeGate.bound, view) ); + // The final stop: only meaningful in the destination system (no next + // gate). It is the object the player set as the destination. + const destObj = routeGate ? null : this.routeDestinationObject(); + const destObjId = destObj?.id ?? null; + const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view)); const offscreen = []; for (const o of objects) { if (o.id === routeGateId) continue; // the route arrow takes this slot + if (o.id === destObjId) continue; // the destination arrow takes this slot if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) { offscreen.push(o); } @@ -1536,6 +1547,23 @@ export class GameScene extends Phaser.Scene { typeLabel: config.get('gates.route.typeLabel', 'Route Gate'), color: config.get('gates.route.compassColor', '#ff8c1a'), name: routeGate.discoveryName, + // The "next gate" is the one thing the player must find — keep it at + // the full readout (arrow at scale 1 + type + name) no matter how far + // it sits, and resolvable by autopilotTo (the `route:` id). + alwaysFull: true, + }); + } + if (destOffscreen) { + offscreen.push({ + id: `dest:${destObj.id}`, + x: destObj.x, + y: destObj.y, + radius: destObj.radius, + typeLabel: config.get('gates.route.destTypeLabel', 'Destination'), + color: config.get('gates.route.compassColor', '#ff8c1a'), + name: destObj.name, + // The final stop — same always-on treatment as the next gate. + alwaysFull: true, }); } // The ship's position drives the compass's FAR display (targets @@ -1642,10 +1670,28 @@ export class GameScene extends Phaser.Scene { if (bar.isOpen) bar.close(); return; } - const o = this.discoverableObjects().find((v) => v.id === id); + // A ROUTE compass entry carries a prefixed id (see updateDiscovery): + // `route:` for the orange next-gate, `dest:` for the + // orange final stop. Resolve either to the object's real discovery id so + // both can be autopiloted like any other off-screen object. + let effectiveId = id; + if (typeof id === 'string') { + if (id.startsWith('route:')) effectiveId = id.slice('route:'.length); + else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length); + } + const o = + this.discoverableObjects().find((v) => v.id === effectiveId) ?? + this.discoverableObjects().find((v) => v.id === id); if (!o) return; - // The solid body behind this discovery entry (a world or a cluster). - const solid = this.solids.find((s) => s.discoveryId === id) ?? this.planet; + // The solid body behind this discovery entry (a world, a cluster, or + // for a ROUTE gate — the gate itself). Must use `effectiveId`, not the + // raw `route:` — otherwise the lookup misses and the aim falls back + // to the central body, so the ship flew at the STAR's rim in the gate's + // direction instead of to the gate (the "wrong direction" the player hit). + const solid = + this.solids.find((s) => s.discoveryId === effectiveId) ?? + this.solids.find((s) => s.discoveryId === id) ?? + this.planet; // Approach point: on the rim (clearance + hull), on the side the ship // is approaching from (center → ship direction) — it ends up facing // the object. If the object is outside the player's tether range, the @@ -2610,6 +2656,56 @@ export class GameScene extends Phaser.Scene { return null; } + /** + * The destination OBJECT in the CURRENT system — the route's FINAL stop. + * Non-null only when the player is already in the destination system AND + * the destination names a specific object (objectId) that exists here. + * This is the last hop: across the system to the world itself. The + * compass marks it in ORANGE (updateDiscovery, `dest:` id) and it stays + * orange until the ship actually reaches it (_checkDestinationReached), + * so the "where to fly next" marker persists right up to the arrival. + */ + routeDestinationObject() { + const d = this.destination; + if (!d?.objectId || d.systemId !== this.systemRecord.id) return null; + return this.discoverableObjects().find((o) => o.id === d.objectId) ?? null; + } + + /** + * Arrived at the FINAL stop? Called each frame (update, just before + * updateDiscovery). When the player is in the destination system and the + * ship is within the destination object's keep-out rim, the trip is done: + * clear the tracked destination (the compass orange drops, the SYSTEM tab + * unlocks) and fire the REACHED toast. (Fired here — not queued like a + * jump notice — because the player is already in this scene; there is no + * jump cut to ride out.) A destination with no specific object is handled + * at the jump instead (_routeForJump clears it on system entry). + */ + _checkDestinationReached() { + const d = this.destination; + if (!d?.objectId || d.systemId !== this.systemRecord.id) return; + if (!this.ship) return; + const solid = + this.solids.find((s) => s.discoveryId === d.objectId) ?? + this.discoverableObjects().find((o) => o.id === d.objectId); + if (!solid) return; + // Keep-out rim: the distance the ship rests at when autopiloted there. + const keepout = + solid.minCenterDistance?.(this.ship.radius) ?? + (solid.radius + (solid.clearance ?? 0) + this.ship.radius); + const dist = Math.hypot(this.ship.x - solid.x, this.ship.y - solid.y); + // A little grace past the rim so a settling ship counts, but not so + // much that flying by on the far side does. + if (dist > keepout + 40) return; + const destName = this.galaxy?.byId?.get(d.systemId)?.name ?? d.systemId; + this.destination = null; + this.registry.set('destination', null); + this.consoleToast( + String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}')) + .replace('{dest}', String(destName).toUpperCase()), + ); + } + /** * Route bookkeeping at a JUMP (called from jumpThroughGate with the * system we are LEAVING and the one we are ENTERING). Derives what the @@ -2632,9 +2728,17 @@ export class GameScene extends Phaser.Scene { 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. + // Arrived at the destination SYSTEM. If the destination names a + // specific OBJECT in that system, the trip is NOT over — the player + // still has to fly to it. Keep the destination active: the compass + // now points at that object (updateDiscovery) and it clears when the + // ship actually reaches it (_checkDestinationReached fires REACHED). + // No toast yet — the arrival is the last leg, not the destination. + if (this.destination.objectId) return; + // No specific object — reaching the system is the whole trip. 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( diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index e41093c..6e212d5 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -419,9 +419,15 @@ function wrapPI(a) { * FAR display rule: is the target more than `farDistance` px from the * ship? (the compass folds far targets to the compact chip + shrunken * arrow until hovered). No ship, or farDistance off (≤ 0) ⇒ never far. + * + * `t.alwaysFull` overrides the distance rule: a target flagged this way + * (the ROUTE's orange next-gate) stays at the full readout — arrow at + * scale 1, type + name text visible — no matter how far it is, so the + * "where to fly next" marker never folds into a text-less box. */ export function isFarTarget(t, ship, farDistance) { if (!t || !ship || farDistance <= 0) return false; + if (t.alwaysFull === true) return false; // always prominent (route waypoint) return Math.hypot(t.x - ship.x, t.y - ship.y) > farDistance; }