Extend route compass to mark the destination object as final stop
- In the destination system, the orange compass arrow now points at the destination OBJECT (not just the next gate), so the "where to fly next" marker persists across the last hop into the system. - Add `_checkDestinationReached` per-frame check: clears the destination and fires REACHED only when the ship is within the object's keep-out rim, replacing the old jump-time clear for destinations with a specific object. - Route compass entries (`route:`/`dest:` ids) are now resolvable by `autopilotTo`, fixing the ship aiming at the star instead of the gate. - New `alwaysFull` flag on compass targets keeps route waypoints at full readout (arrow + type + name) regardless of distance; add tests. - Add `route.destTypeLabel` config key and update docs/PROJECT_NOTES.md.
This commit is contained in:
parent
a5152590db
commit
bfd85c8e1f
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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() {} },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:<gateId>` for the orange next-gate, `dest:<objectId>` 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:<id>` — 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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue