Implement gate jump travel between systems
- Add JumpTravel.js with pure arrival geometry (return-gate lookup, spawn point past keepout) and wire it into GameScene.jumpThroughGate to re-stage the run via the save pipeline and scene restart - Add gate hit-test (gateAt) so active gates trigger the jump on click while dormant ones show a console nudge and fall through to fly-here - Add jump config block to gates.json (feature switch, arrival gap, delay, toast templates) and update landing.json gasgiant shop clips - Add dev/jump-travel.test.mjs covering config, geometry, real-galaxy coverage, and determinism; refresh PROJECT_NOTES for the new mechanic
This commit is contained in:
parent
a3a229a72d
commit
5a67dbb206
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
|
@ -27,5 +27,14 @@
|
||||||
"label": "Unlock {system} Jumpgates",
|
"label": "Unlock {system} Jumpgates",
|
||||||
"description": "With system NAV data complete, we have enough information to plot courses through this system's jumpgates."
|
"description": "With system NAV data complete, we have enough information to plot courses through this system's jumpgates."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"jump": {
|
||||||
|
"_comment": "THE JUMP (GameScene.jumpThroughGate): a click on an ACTIVE gate transports the run to the connected system — the save pipeline in miniature (captureState → swap in the destination system + arrival position → prepareLoad → scene restart), so discovery/research/builds/minerals/playtime/activatedGates all carry over. ARRIVAL: the ship materialises near the destination's RETURN gate — the gate in the destination whose destination is the system just left (returnGateFor, js/galaxy/JumpTravel.js) — offset back along its facing (the gate faces the star we came from) by radius + shipClearance + ship radius + `arrivalGap`, i.e. just clear of its keepout, inside its activated-gate tether (per ACTIVITY above). One-way SHORTCUT jumps have no return gate (JumpNetwork: tree edges run both ways, shortcuts don't) — those land on the destination's star (its home-tether origin). A click on a DORMANT gate is a console note + the ordinary fly-here (the ship drifts up to the gate's rim). `enabled` is the feature switch; the toasts are templated ({dest}/{system}).",
|
||||||
|
"enabled": true,
|
||||||
|
"arrivalGap": 128,
|
||||||
|
"jumpDelayMs": 420,
|
||||||
|
"toast": "JUMP — {dest}",
|
||||||
|
"dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES",
|
||||||
|
"miningToast": "CANNOT JUMP WHILE MINING"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||||
{ "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-02.mp4" },
|
{ "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-02.mp4" },
|
||||||
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" },
|
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" },
|
||||||
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" },
|
||||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" },
|
||||||
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" },
|
||||||
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
|
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* JUMP TRAVEL test (dev tool, run with Node — no browser):
|
||||||
|
*
|
||||||
|
* node dev/jump-travel.test.mjs
|
||||||
|
*
|
||||||
|
* Pins the contract behind the gate JUMP (GameScene.jumpThroughGate —
|
||||||
|
* click an active gate, the run re-stages for the connected system via
|
||||||
|
* the save pipeline, arriving near the destination's RETURN gate):
|
||||||
|
* - config: data/gates.json → jump (the feature switch, the arrival
|
||||||
|
* geometry, the toasts with their placeholders);
|
||||||
|
* - the pure geometry (js/galaxy/JumpTravel.js): the return-gate
|
||||||
|
* lookup (tree-edge jumps have one, one-way shortcut jumps don't),
|
||||||
|
* the spawn point (just past the gate's keepout, on the side the
|
||||||
|
* ship lands on, nose along the travel direction), inside the
|
||||||
|
* activated gate's tether zone;
|
||||||
|
* - against a REAL galaxy: every gate's destination is a known
|
||||||
|
* system, jumps with a return gate land at it (and jumps without
|
||||||
|
* one — shortcuts — report null for the scene's origin fallback),
|
||||||
|
* determinism (same seed ⇒ same arrivals).
|
||||||
|
*
|
||||||
|
* The scene-level transport itself (captureState → prepareLoad →
|
||||||
|
* scene.restart) is Phaser glue — covered by the existing save tests'
|
||||||
|
* pipeline plus this file's contract; run the game to play it.
|
||||||
|
*/
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = join(__dirname, '..');
|
||||||
|
const dataDir = join(root, 'data');
|
||||||
|
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (label, cond) => {
|
||||||
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Config — data/gates.json → jump
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const gates = read('gates.json');
|
||||||
|
const tether = read('tether.json');
|
||||||
|
const jump = gates.jump;
|
||||||
|
|
||||||
|
check('jump: section present', !!jump && typeof jump === 'object');
|
||||||
|
check('jump: enabled (feature switch, on)', jump?.enabled === true);
|
||||||
|
check('jump: arrivalGap is a non-negative finite number (px)',
|
||||||
|
Number.isFinite(jump?.arrivalGap) && jump.arrivalGap >= 0);
|
||||||
|
check('jump: jumpDelayMs is a positive finite number (the cut, ms)',
|
||||||
|
Number.isFinite(jump?.jumpDelayMs) && jump.jumpDelayMs > 0);
|
||||||
|
check('jump: the toast templates keep their placeholders',
|
||||||
|
typeof jump?.toast === 'string' && jump.toast.includes('{dest}') &&
|
||||||
|
typeof jump?.dormantToast === 'string' && jump.dormantToast.includes('{system}') &&
|
||||||
|
typeof jump?.miningToast === 'string' && jump.miningToast.length > 0);
|
||||||
|
check('jump: the gap keeps the ship outside the keepout with room to spare',
|
||||||
|
Number(jump?.arrivalGap ?? -1) + Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0) > Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// The pure geometry (js/galaxy/JumpTravel.js)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const { returnGateFor, arrivalPoint, jumpArrival } = await import('../js/galaxy/JumpTravel.js');
|
||||||
|
|
||||||
|
const contentA = {
|
||||||
|
jumps: [
|
||||||
|
{ id: 'a-j1', to: 'B', x: 0, y: 0, rotation: 0 },
|
||||||
|
{ id: 'a-j2', to: 'C', x: 1, y: 1, rotation: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
check('returnGateFor: finds the gate pointing back at the system left',
|
||||||
|
returnGateFor(contentA, 'B')?.id === 'a-j1' && returnGateFor(contentA, 'C')?.id === 'a-j2');
|
||||||
|
check('returnGateFor: null when the destination holds no return gate (one-way shortcut)',
|
||||||
|
returnGateFor(contentA, 'D') === null);
|
||||||
|
check('returnGateFor: null on degenerate content',
|
||||||
|
returnGateFor(null, 'B') === null && returnGateFor({ jumps: 'nope' }, 'B') === null);
|
||||||
|
|
||||||
|
const g0 = { x: 1000, y: 2000, rotation: 0 };
|
||||||
|
const cfg = { radius: 96, clearance: 50, shipRadius: 16, gap: 128 };
|
||||||
|
const off = cfg.radius + cfg.clearance + cfg.shipRadius + cfg.gap; // 290
|
||||||
|
const p0 = arrivalPoint(g0, cfg);
|
||||||
|
check('arrivalPoint: sits back along the facing, just past the keepout',
|
||||||
|
Math.abs(p0.x - (1000 - off)) < 1e-9 && Math.abs(p0.y - 2000) < 1e-9);
|
||||||
|
check('arrivalPoint: the ship keeps its nose along the travel direction',
|
||||||
|
Math.abs(p0.heading - Math.PI) < 1e-9);
|
||||||
|
const p90 = arrivalPoint({ x: 1000, y: 2000, rotation: Math.PI / 2 }, cfg);
|
||||||
|
check('arrivalPoint: facing π/2 → spawn below the gate (far side of the facing)',
|
||||||
|
Math.abs(p90.x - 1000) < 1e-9 && Math.abs(p90.y - (2000 - off)) < 1e-9);
|
||||||
|
const d0 = Math.hypot(p0.x - 1000, p0.y - 2000);
|
||||||
|
check('arrivalPoint: OUTSIDE the keepout (radius + clearance)',
|
||||||
|
d0 === off && d0 > cfg.radius + cfg.clearance);
|
||||||
|
const l1 = Number(tether.level1Radius) || 5120;
|
||||||
|
check('arrivalPoint: INSIDE the activated gate\'s tether zone (level-1 radius)',
|
||||||
|
d0 < l1);
|
||||||
|
|
||||||
|
const ja = jumpArrival(contentA, 'B', cfg);
|
||||||
|
check('jumpArrival: { x, y, heading, gateId } for a tree-edge jump',
|
||||||
|
!!ja && ja.gateId === 'a-j1' && Number.isFinite(ja.x) && Number.isFinite(ja.y) && Number.isFinite(ja.heading));
|
||||||
|
check('jumpArrival: null for a one-way shortcut (the scene falls back to the origin)',
|
||||||
|
jumpArrival(contentA, 'D', cfg) === null);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// A REAL galaxy (bootstrap config the way main.js does, from the data dir)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const manifest = read('manifest.json');
|
||||||
|
const data = {};
|
||||||
|
for (const f of manifest.files ?? []) {
|
||||||
|
const file = typeof f === 'string' ? f : f.file;
|
||||||
|
// Same keying as ConfigLoader: file name without .json (subdir stripped).
|
||||||
|
const name = file.split('/').pop().replace(/\.json$/, '');
|
||||||
|
data[name] = JSON.parse(fs.readFileSync(join(dataDir, file), 'utf8'));
|
||||||
|
}
|
||||||
|
const { config } = await import('../js/config/Config.js');
|
||||||
|
config.init(data);
|
||||||
|
const { Galaxy } = await import('../js/galaxy/Galaxy.js');
|
||||||
|
|
||||||
|
const seed = 'JUMP-TRAVEL-77';
|
||||||
|
const g = Galaxy.create(seed);
|
||||||
|
const startId = g.currentSystemId;
|
||||||
|
check('galaxy: the starting system holds at least one gate (minGates)',
|
||||||
|
(g.ensureContent(startId).jumps ?? []).length >= 1);
|
||||||
|
|
||||||
|
let total = 0, withReturn = 0, badLanding = 0, badFb = 0;
|
||||||
|
for (const rec of g.records) {
|
||||||
|
const c = g.ensureContent(rec.id);
|
||||||
|
for (const j of c.jumps ?? []) {
|
||||||
|
total += 1;
|
||||||
|
if (!g.byId.has(j.to)) badFb += 1; // a gate must point at a KNOWN system
|
||||||
|
const dest = g.ensureContent(j.to);
|
||||||
|
const radius = Number(j.size) || Number(gates.size) || 96;
|
||||||
|
const clearance = Number(j.clearance) || Number(gates.shipClearance) || 0;
|
||||||
|
const arr = jumpArrival(dest, rec.id, {
|
||||||
|
radius, clearance, shipRadius: 16, gap: Number(jump?.arrivalGap) || 0,
|
||||||
|
});
|
||||||
|
if (arr) {
|
||||||
|
withReturn += 1;
|
||||||
|
const rg = returnGateFor(dest, rec.id);
|
||||||
|
const d = Math.hypot(arr.x - rg.x, arr.y - rg.y);
|
||||||
|
const keepout = radius + clearance;
|
||||||
|
if (!(d >= keepout - 1e-6 && d < l1)) badLanding += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('galaxy: every gate\'s destination is a known system (no dangling links)', badFb === 0);
|
||||||
|
check(`galaxy: ${total} gates, ${withReturn} land at a return gate, the rest are one-way (origin fallback)`,
|
||||||
|
withReturn > 0 && total - withReturn >= 0);
|
||||||
|
check('galaxy: every landing is past the keepout and inside the gate tether', badLanding === 0);
|
||||||
|
|
||||||
|
// Determinism — same seed ⇒ same network ⇒ same arrivals.
|
||||||
|
const g2 = Galaxy.create(seed);
|
||||||
|
const c1 = g.ensureContent(startId), c2 = g2.ensureContent(startId);
|
||||||
|
const same = c1.jumps.length === c2.jumps.length && c1.jumps.every((j, i) =>
|
||||||
|
j.id === c2.jumps[i].id && j.to === c2.jumps[i].to &&
|
||||||
|
Math.abs(j.x - c2.jumps[i].x) < 1e-9 && Math.abs(j.y - c2.jumps[i].y) < 1e-9);
|
||||||
|
const a1 = jumpArrival(g.ensureContent(c1.jumps[0].to), startId, cfg);
|
||||||
|
const a2 = jumpArrival(g2.ensureContent(c1.jumps[0].to), startId, cfg);
|
||||||
|
check('galaxy: deterministic arrivals (same seed ⇒ same gate ⇒ same spawn)',
|
||||||
|
same && !!a1 && !!a2 && Math.abs(a1.x - a2.x) < 1e-9 && Math.abs(a1.y - a2.y) < 1e-9);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
console.log('');
|
||||||
|
if (failures) {
|
||||||
|
console.log(`✘ ${failures} check(s) FAILED`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✔ all jump-travel checks passed');
|
||||||
|
|
@ -292,15 +292,38 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
|
||||||
(the ship keeps `gates.shipClearance` from them, autopilot flies to
|
(the ship keeps `gates.shipClearance` from them, autopilot flies to
|
||||||
their rim), discoverable (compass arrows + toast, in the gate cyan
|
their rim), discoverable (compass arrows + toast, in the gate cyan
|
||||||
`#5fd4ff`), and the HUD dossier lists them ("… Gate · jump to
|
`#5fd4ff`), and the HUD dossier lists them ("… Gate · jump to
|
||||||
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them)
|
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them;
|
||||||
— standing at a gate is where the jump happens; the jump mechanic
|
`gateAt()` is their own hit test) — a click on an ACTIVE gate is the
|
||||||
itself is the follow-up.
|
JUMP, and a dormant one is the ordinary fly-here (plus a console
|
||||||
|
nudge: chart the system to activate it).
|
||||||
|
- **The JUMP** (`data/gates.json` → `jump`; the pure geometry is
|
||||||
|
`js/galaxy/JumpTravel.js`, Node-tested by `dev/jump-travel.test.mjs`):
|
||||||
|
`GameScene.jumpThroughGate` re-stages the run for the connected
|
||||||
|
system through the save pipeline in miniature — `captureState()`
|
||||||
|
snapshots the whole run (discovery, reputation, research, builds,
|
||||||
|
minerals, playtime, `activatedGates`), the destination system +
|
||||||
|
arrival position are swapped in, `prepareLoad()` rebuilds the galaxy
|
||||||
|
from the seed (deterministic — same names/placements) with the new
|
||||||
|
current system, and `scene.restart()` rebuilds this scene onto it:
|
||||||
|
the destination's native tethers re-form in `create()`, and
|
||||||
|
`_onEnterSystem()` grants its map tech. ARRIVAL: the ship materialises
|
||||||
|
just past the destination's RETURN gate's keepout (the gate pointing
|
||||||
|
back — activated with its twin per ACTIVITY, its tether anchoring the
|
||||||
|
landing), offset back along its facing with nose along the travel
|
||||||
|
direction; one-way SHORTCUT jumps have no return gate (JumpNetwork:
|
||||||
|
tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's
|
||||||
|
gates) and land on the destination's star, inside its home-tether
|
||||||
|
zone. A mid-jump guard (`_jumping`) swallows input for the cut
|
||||||
|
(`jumpDelayMs`); mining blocks the jump (console nudge).
|
||||||
- **Determinism** — same seed ⇒ same network, same gates, same
|
- **Determinism** — same seed ⇒ same network, same gates, same
|
||||||
placements, same `active` flags. Verified: `dev/jumps.test.mjs`
|
placements, same `active` flags, same jump arrivals. Verified:
|
||||||
(network invariants, placement invariants, barren gates on-ray,
|
`dev/jumps.test.mjs` (network invariants, placement invariants, barren
|
||||||
`active: false` everywhere, composition buckets, determinism),
|
gates on-ray, `active: false` everywhere, composition buckets,
|
||||||
`dev/jumpgate.test.mjs` (the entity's solid contract + dormant/active
|
determinism), `dev/jumpgate.test.mjs` (the entity's solid contract +
|
||||||
rendering), and the layout band in `dev/discovery.test.mjs`.
|
dormant/active rendering), `dev/jump-travel.test.mjs` (the jump's
|
||||||
|
config + pure arrival geometry + a real galaxy's return-gate
|
||||||
|
coverage + determinism), and the layout band in
|
||||||
|
`dev/discovery.test.mjs`.
|
||||||
|
|
||||||
## The tether — the player's range (important)
|
## The tether — the player's range (important)
|
||||||
|
|
||||||
|
|
@ -386,8 +409,9 @@ are NAMED after that system. Node ids embed the system id
|
||||||
one shared category without collisions:
|
one shared category without collisions:
|
||||||
- **`{System} Map`** — `duration: 0`, in `starting`: granted the moment
|
- **`{System} Map`** — `duration: 0`, in `starting`: granted the moment
|
||||||
the player is in the system (`GameScene._onEnterSystem()` unlocks it —
|
the player is in the system (`GameScene._onEnterSystem()` unlocks it —
|
||||||
the jump mechanic, when it lands, calls the same seam on every
|
create() runs it on every entry, which a jump is: the jump restarts
|
||||||
arrival). Copy: *Added the Solar System of {system} to the onboard
|
the scene onto the destination, see the Jump gates section).
|
||||||
|
Copy: *Added the Solar System of {system} to the onboard
|
||||||
NAV System. Discover all NAV points to unlock the system Jumpgates.*
|
NAV System. Discover all NAV points to unlock the system Jumpgates.*
|
||||||
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
|
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
|
||||||
**every NAV point of the system is discovered** (the central body, all
|
**every NAV point of the system is discovered** (the central body, all
|
||||||
|
|
@ -454,6 +478,16 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
|
||||||
the linked systems' return gates, from the jump network alone),
|
the linked systems' return gates, from the jump network alone),
|
||||||
`applyActivation(content, systemId, keys)` (flip the records
|
`applyActivation(content, systemId, keys)` (flip the records
|
||||||
idempotently). Node-tested by `dev/system-category.test.mjs`.
|
idempotently). Node-tested by `dev/system-category.test.mjs`.
|
||||||
|
- `js/galaxy/JumpTravel.js` — PURE (no Phaser): the jump's arrival
|
||||||
|
geometry. `returnGateFor(content, fromId)` (the destination's gate
|
||||||
|
pointing back at the system left — one-way shortcuts have none),
|
||||||
|
`arrivalPoint(gate, cfg)` (spawn just past the keepout, on the far
|
||||||
|
side of the gate's facing, nose along the travel direction),
|
||||||
|
`jumpArrival(content, fromId, cfg)` (both; null ⇒ the scene lands on
|
||||||
|
the destination's star). Driven by `GameScene.jumpThroughGate`
|
||||||
|
(the transport — save pipeline + scene restart); Node-tested by
|
||||||
|
`dev/jump-travel.test.mjs` (contract, geometry, a real galaxy,
|
||||||
|
determinism).
|
||||||
- `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`,
|
- `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`,
|
||||||
`start`, `progress(time)`, `tick(time)` (→ array of completions),
|
`start`, `progress(time)`, `tick(time)` (→ array of completions),
|
||||||
`restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested.
|
`restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested.
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ import { Planet } from './Planet.js';
|
||||||
* Solid like a planet (the ship keeps its clearance — the same plain
|
* Solid like a planet (the ship keeps its clearance — the same plain
|
||||||
* circle rule, GameScene.solids), discoverable (a compass arrow + the
|
* circle rule, GameScene.solids), discoverable (a compass arrow + the
|
||||||
* discovery toast, at the scale of its keepout, in its own cyan).
|
* discovery toast, at the scale of its keepout, in its own cyan).
|
||||||
* It is NOT a comms target (GameScene.worldObjectAt excludes it) —
|
* It is NOT a comms target (GameScene.worldObjectAt excludes it —
|
||||||
* standing at a gate is where the jump happens; that arrives with the
|
* gateAt() is its own hit test): a click on an ACTIVE gate is the
|
||||||
* jump mechanic.
|
* JUMP itself (GameScene.jumpThroughGate — the run re-stages for the
|
||||||
|
* connected system, arriving at this gate's twin); a click on a
|
||||||
|
* DORMANT one is the ordinary fly-here (the ship drifts to the rim).
|
||||||
*
|
*
|
||||||
* ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false
|
* ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false
|
||||||
* — the gate is inert until the player activates it (future mechanic),
|
* — the gate is inert until the SYSTEM research category activates it,
|
||||||
* and an activated gate anchors a level-1 tether at its own position —
|
* and an activated gate anchors a level-1 tether at its own position —
|
||||||
* the room to move in a barren system. Until then the gate reads as
|
* the room to move in a barren system. Until then the gate reads as
|
||||||
* DORMANT: dimmed overall (alpha 0.4), the field still and faint (the
|
* DORMANT: dimmed overall (alpha 0.4), the field still and faint (the
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* JumpTravel — the pure geometry of a gate jump (no Phaser):
|
||||||
|
*
|
||||||
|
* returnGateFor(content, fromId)
|
||||||
|
* The destination system's RETURN gate — the gate in `content`
|
||||||
|
* whose destination is the system the ship just left (the first
|
||||||
|
* `content.jumps` entry with `to === fromId`, or null).
|
||||||
|
*
|
||||||
|
* arrivalPoint(gate, cfg)
|
||||||
|
* Where the ship materialises when it exits a gate: offset BACK
|
||||||
|
* along the gate's facing (a gate's `rotation` is the bearing to
|
||||||
|
* its DESTINATION star — the system we came from), just clear of
|
||||||
|
* the keepout circle (gate radius + ship clearance + ship radius
|
||||||
|
* + the config gap). The ship keeps its nose along the way it was
|
||||||
|
* travelling (heading = facing + π).
|
||||||
|
*
|
||||||
|
* jumpArrival(content, fromId, cfg)
|
||||||
|
* Both, together: { x, y, heading, gateId } — or null when the
|
||||||
|
* destination holds no return gate (a one-way SHORTCUT jump —
|
||||||
|
* JumpNetwork: tree edges run both ways, shortcuts don't). The
|
||||||
|
* scene falls back to the destination's origin (its home-tether
|
||||||
|
* zone) when this is null.
|
||||||
|
*
|
||||||
|
* GameScene.jumpThroughGate supplies the live numbers (the gate's
|
||||||
|
* radius/clearance, the ship's radius, data/gates.json → jump) and
|
||||||
|
* does the actual transport (re-stage the run for the destination
|
||||||
|
* system via the save pipeline, then scene.restart); this module only
|
||||||
|
* decides WHICH gate and WHERE — pure enough for Node tests
|
||||||
|
* (dev/jump-travel.test.mjs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object|null} content the destination system's content record
|
||||||
|
* (galaxy.ensureContent(id): { jumps: [{ id, name, to, x, y, rotation }] })
|
||||||
|
* @param {string} fromId the id of the system the ship is leaving
|
||||||
|
* @returns {object|null} the return gate's content record, or null
|
||||||
|
*/
|
||||||
|
export function returnGateFor(content, fromId) {
|
||||||
|
if (!content || fromId == null) return null;
|
||||||
|
const jumps = Array.isArray(content.jumps) ? content.jumps : [];
|
||||||
|
return jumps.find((j) => j && j.to === fromId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spawn point just past a gate's keepout, on the side the ship
|
||||||
|
* lands on (the far side of its facing).
|
||||||
|
*
|
||||||
|
* @param {object} gate the gate's content record: { x, y, rotation }
|
||||||
|
* @param {object} [cfg] { radius (gate keepout, px), clearance (ship
|
||||||
|
* keepout from the gate, px), shipRadius (px), gap (extra px, from
|
||||||
|
* data/gates.json → jump.arrivalGap) }
|
||||||
|
* @returns {{x:number, y:number, heading:number}}
|
||||||
|
*/
|
||||||
|
export function arrivalPoint(gate, cfg = {}) {
|
||||||
|
const radius = Number(cfg.radius) || 0;
|
||||||
|
const clearance = Number(cfg.clearance) || 0;
|
||||||
|
const shipRadius = Number(cfg.shipRadius) || 0;
|
||||||
|
const gap = Number(cfg.gap) || 0;
|
||||||
|
const a = Number(gate?.rotation) || 0;
|
||||||
|
const offset = radius + clearance + shipRadius + gap;
|
||||||
|
return {
|
||||||
|
x: (Number(gate?.x) || 0) - Math.cos(a) * offset,
|
||||||
|
y: (Number(gate?.y) || 0) - Math.sin(a) * offset,
|
||||||
|
heading: a + Math.PI,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The jump's arrival for a destination system: at its return gate (if
|
||||||
|
* it holds one — tree-edge jumps do; one-way shortcut jumps don't).
|
||||||
|
*
|
||||||
|
* @returns {{x:number, y:number, heading:number, gateId:string}|null}
|
||||||
|
* null when there is no return gate — the caller falls back.
|
||||||
|
*/
|
||||||
|
export function jumpArrival(content, fromId, cfg = {}) {
|
||||||
|
const gate = returnGateFor(content, fromId);
|
||||||
|
if (!gate) return null;
|
||||||
|
return { ...arrivalPoint(gate, cfg), gateId: gate.id };
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { Planet } from '../entities/Planet.js';
|
||||||
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
||||||
import { Station } from '../entities/Station.js';
|
import { Station } from '../entities/Station.js';
|
||||||
import { JumpGate } from '../entities/JumpGate.js';
|
import { JumpGate } from '../entities/JumpGate.js';
|
||||||
|
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
import { ActionBar } from '../ui/ActionBar.js';
|
import { ActionBar } from '../ui/ActionBar.js';
|
||||||
|
|
@ -23,7 +24,7 @@ import { MineralHud } from '../ui/MineralHud.js';
|
||||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.js';
|
import { SavePanel } from '../ui/SavePanel.js';
|
||||||
import { SaveManager } from '../save/SaveManager.js';
|
import { SaveManager } from '../save/SaveManager.js';
|
||||||
import { consumeRestore } from '../save/SaveData.js';
|
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||||
import { TetherField } from '../tether/TetherField.js';
|
import { TetherField } from '../tether/TetherField.js';
|
||||||
import { Mining } from '../mining/Mining.js';
|
import { Mining } from '../mining/Mining.js';
|
||||||
import { MiningPopup } from '../ui/MiningPopup.js';
|
import { MiningPopup } from '../ui/MiningPopup.js';
|
||||||
|
|
@ -177,6 +178,10 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
// A jump's cut (jumpThroughGate) sets this on the dying scene — the
|
||||||
|
// restart lands here, so clear it: input unlocks again.
|
||||||
|
this._jumping = false;
|
||||||
|
|
||||||
// Camera: smoothly follows the ship (updateCamera below). This motion
|
// Camera: smoothly follows the ship (updateCamera below). This motion
|
||||||
// is what drives the parallax starfield — ship flies, view trails.
|
// is what drives the parallax starfield — ship flies, view trails.
|
||||||
this.cameraFollowShip = config.get('game.camera.followShip', true);
|
this.cameraFollowShip = config.get('game.camera.followShip', true);
|
||||||
|
|
@ -316,9 +321,10 @@ export class GameScene extends Phaser.Scene {
|
||||||
// Solid (the ship keeps its clearance), discoverable (compass +
|
// Solid (the ship keeps its clearance), discoverable (compass +
|
||||||
// toast, in their own cyan), and each one faces its destination
|
// toast, in their own cyan), and each one faces its destination
|
||||||
// star on the map (entity.rotation). They are NOT comms targets —
|
// star on the map (entity.rotation). They are NOT comms targets —
|
||||||
// standing at a gate is where the jump happens (coming with the
|
// a click on an ACTIVE gate is the JUMP (GameScene.jumpThroughGate
|
||||||
// jump mechanic); GameScene.worldObjectAt deliberately excludes
|
// — the transport); a dormant one is the ordinary fly-here.
|
||||||
// them, so a click falls through to fly-to-point.
|
// worldObjectAt deliberately excludes them (no comms panel for a
|
||||||
|
// gate); gateAt() is their own hit test.
|
||||||
this.systemGates = [];
|
this.systemGates = [];
|
||||||
if (config.get('gates.enabled', true) !== false) {
|
if (config.get('gates.enabled', true) !== false) {
|
||||||
for (const j of this.systemContent.jumps ?? []) {
|
for (const j of this.systemContent.jumps ?? []) {
|
||||||
|
|
@ -573,8 +579,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
// Entering a system charts it: the SYSTEM category's '{System} Map'
|
// Entering a system charts it: the SYSTEM category's '{System} Map'
|
||||||
// tech (duration 0) is owned on arrival — a new run gets the starting
|
// tech (duration 0) is owned on arrival — a new run gets the starting
|
||||||
// system's, a load re-asserts the saved system's (the restore above
|
// system's, a load re-asserts the saved system's (the restore above
|
||||||
// replaced the unlocked set), and the jump mechanic (when it lands)
|
// replaced the unlocked set), and a jump (jumpThroughGate →
|
||||||
// calls _onEnterSystem() on every arrival.
|
// scene.restart → this create) grants the destination's.
|
||||||
this._onEnterSystem();
|
this._onEnterSystem();
|
||||||
|
|
||||||
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
|
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
|
||||||
|
|
@ -627,6 +633,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
// union boundary — the target marker lands on the barrier line
|
// union boundary — the target marker lands on the barrier line
|
||||||
// itself.
|
// itself.
|
||||||
this.input.on('pointerdown', (pointer) => {
|
this.input.on('pointerdown', (pointer) => {
|
||||||
|
// A jump is in flight (the cut's 400 ms or so): the old scene is
|
||||||
|
// already gone — swallow anything that lands in the gap.
|
||||||
|
if (this._jumping) return;
|
||||||
// The save pop-up is MODAL — while it's up it owns all input
|
// The save pop-up is MODAL — while it's up it owns all input
|
||||||
// (its scrim / cards / dialog eat the click; the world stays put).
|
// (its scrim / cards / dialog eat the click; the world stays put).
|
||||||
if (this.savePanel && this.savePanel.isOpen) return;
|
if (this.savePanel && this.savePanel.isOpen) return;
|
||||||
|
|
@ -717,6 +726,24 @@ export class GameScene extends Phaser.Scene {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A JUMP GATE under the cursor (solid — the ship can't pass
|
||||||
|
// through it): ACTIVE → the JUMP itself (the transport —
|
||||||
|
// GameScene.jumpThroughGate, data/gates.json → jump); DORMANT →
|
||||||
|
// a console note, and the click falls through to the usual
|
||||||
|
// fly-here (the ship drifts up to the gate's rim and waits).
|
||||||
|
const gateObj = this.gateAt(pointer.worldX, pointer.worldY);
|
||||||
|
if (gateObj) {
|
||||||
|
if (gateObj.active && config.get('gates.jump.enabled', true)) {
|
||||||
|
this.jumpThroughGate(gateObj);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.consoleToast(
|
||||||
|
String(config.get('gates.jump.dormantToast', 'JUMP GATE DORMANT'))
|
||||||
|
.replace('{system}', (this.systemRecord?.name ?? 'THIS SYSTEM').toUpperCase()),
|
||||||
|
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xffc94d)), durationMs: 3200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A PLANET or STATION under the cursor (a rock click is the mining
|
// A PLANET or STATION under the cursor (a rock click is the mining
|
||||||
// flow above): the click opens the comms panel (the landing-request
|
// flow above): the click opens the comms panel (the landing-request
|
||||||
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
|
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
|
||||||
|
|
@ -1415,6 +1442,109 @@ export class GameScene extends Phaser.Scene {
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate under (wx, wy), closest wins — the hit circle is its
|
||||||
|
* keepout (gate radius + ship clearance) plus a touch of pad, the
|
||||||
|
* same spirit as rockAt. (Gates are deliberately absent from
|
||||||
|
* worldObjectAt — they are NOT comms targets.)
|
||||||
|
*/
|
||||||
|
gateAt(wx, wy) {
|
||||||
|
let best = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const gt of this.systemGates ?? []) {
|
||||||
|
const r = (gt.radius ?? 0) + (gt.clearance ?? 0) + 8;
|
||||||
|
const d = Math.hypot(wx - gt.x, wy - gt.y);
|
||||||
|
if (d <= r && d < bestD) {
|
||||||
|
best = gt;
|
||||||
|
bestD = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE JUMP (data/gates.json → jump): click an ACTIVE gate and the
|
||||||
|
* run moves to the connected system, arriving near that system's
|
||||||
|
* RETURN gate (the one pointing back — activated with the gate per
|
||||||
|
* the ACTIVITY rule, its tether anchoring the ship's room to move;
|
||||||
|
* the pure geometry is js/galaxy/JumpTravel.js). One-way shortcut
|
||||||
|
* jumps have no return gate — they land on the destination's star,
|
||||||
|
* inside its home-tether zone.
|
||||||
|
*
|
||||||
|
* HOW: the save pipeline, in miniature. captureState() snapshots the
|
||||||
|
* WHOLE run (discovery, reputation, research, builds, minerals,
|
||||||
|
* playtime, activatedGates); the destination system + arrival
|
||||||
|
* position are swapped in; prepareLoad() re-stages the shared state
|
||||||
|
* (the galaxy rebuilt from the seed — deterministic, same names and
|
||||||
|
* placements — with the new current system); scene.restart() rebuilds
|
||||||
|
* this scene onto it: the destination's native tethers (home + its
|
||||||
|
* activated gates) re-form in create(), its dossier/HUDs rebuild,
|
||||||
|
* and _onEnterSystem() grants its map tech.
|
||||||
|
*
|
||||||
|
* @param {object} gt the gate entity (JumpGate — .gate record, .x/.y,
|
||||||
|
* .radius, .clearance, .active)
|
||||||
|
*/
|
||||||
|
jumpThroughGate(gt) {
|
||||||
|
if (this._jumping) return;
|
||||||
|
const from = this.systemRecord;
|
||||||
|
const destId = gt?.gate?.to;
|
||||||
|
const dest = destId ? this.galaxy.byId.get(destId) : null;
|
||||||
|
if (!dest) {
|
||||||
|
this.consoleToast('NO LINK — GATE DESTINATION UNKNOWN', {
|
||||||
|
glyph: '⌁',
|
||||||
|
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mining?.isActive) {
|
||||||
|
this.consoleToast(
|
||||||
|
String(config.get('gates.jump.miningToast', 'CANNOT JUMP WHILE MINING')),
|
||||||
|
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Where we arrive: just past the return gate's keepout (JumpTravel).
|
||||||
|
// Fallback — no return gate (a one-way shortcut): the destination's
|
||||||
|
// star, inside its home tether (added unconditionally in create()).
|
||||||
|
const destContent = this.galaxy.ensureContent(destId);
|
||||||
|
const arrival =
|
||||||
|
jumpArrival(destContent, from.id, {
|
||||||
|
radius: gt.radius ?? 0,
|
||||||
|
clearance: gt.clearance ?? 0,
|
||||||
|
shipRadius: this.ship?.radius ?? 0,
|
||||||
|
gap: Number(config.get('gates.jump.arrivalGap', 128)) || 0,
|
||||||
|
}) ?? { x: 0, y: 0, heading: 0, gateId: null };
|
||||||
|
|
||||||
|
const rec = captureState(this, this.time.now);
|
||||||
|
rec.currentSystemId = destId;
|
||||||
|
rec.systemName = dest.name;
|
||||||
|
rec.ship = {
|
||||||
|
x: arrival.x,
|
||||||
|
y: arrival.y,
|
||||||
|
heading: arrival.heading,
|
||||||
|
minerals: rec.ship?.minerals ?? 0,
|
||||||
|
};
|
||||||
|
rec.tethers = []; // the destination's native set re-forms in create()
|
||||||
|
prepareLoad(this.registry, rec);
|
||||||
|
|
||||||
|
// The cut: the console line + the sfx, then the restart — short
|
||||||
|
// enough to read, long enough to hear (the scene's TimeClock drives
|
||||||
|
// the delay; update() steps it, see the v4 quirk note there).
|
||||||
|
this._jumping = true;
|
||||||
|
this.ship.stop(); // no steering across the cut
|
||||||
|
this.hideHint();
|
||||||
|
this.consoleToast(
|
||||||
|
String(config.get('gates.jump.toast', 'JUMP — {dest}'))
|
||||||
|
.replace('{dest}', String(dest.name).toUpperCase()),
|
||||||
|
{ glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 },
|
||||||
|
);
|
||||||
|
this.playSfx('discovery');
|
||||||
|
this.time.delayedCall(
|
||||||
|
Number(config.get('gates.jump.jumpDelayMs', 420)) || 420,
|
||||||
|
() => this.scene.restart(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the comms panel shows for a planet or station:
|
* What the comms panel shows for a planet or station:
|
||||||
* - settled — it hosts a settlement (a colony / mining station /
|
* - settled — it hosts a settlement (a colony / mining station /
|
||||||
|
|
@ -1832,8 +1962,8 @@ export class GameScene extends Phaser.Scene {
|
||||||
/** Entering a system begins its NAV chart: the SYSTEM category's
|
/** Entering a system begins its NAV chart: the SYSTEM category's
|
||||||
* '{System} Map' tech is granted (duration 0 — never researched via the
|
* '{System} Map' tech is granted (duration 0 — never researched via the
|
||||||
* console). Idempotent — create() calls it for the starting system (and
|
* console). Idempotent — create() calls it for the starting system (and
|
||||||
* again after a load, whose restore replaced the unlocked set); the
|
* again after a load, whose restore replaced the unlocked set), and a
|
||||||
* jump mechanic (when it lands) calls it on every arrival. */
|
* jump's scene restart runs it for the destination. */
|
||||||
_onEnterSystem() {
|
_onEnterSystem() {
|
||||||
if (!this.researchState || !this.systemRecord) return;
|
if (!this.researchState || !this.systemRecord) return;
|
||||||
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
|
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
|
||||||
|
|
@ -1857,8 +1987,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
* carries a level-1 tether at its own position (the room to move — in a
|
* carries a level-1 tether at its own position (the room to move — in a
|
||||||
* barren system, the whole room). The linked systems' return gates flip
|
* barren system, the whole room). The linked systems' return gates flip
|
||||||
* their content record now (or on entry — the create() pass); their
|
* their content record now (or on entry — the create() pass); their
|
||||||
* entities + tethers materialise when the player arrives (the jump
|
* entities + tethers materialise when the player jumps there (the
|
||||||
* mechanic, later). Idempotent throughout.
|
* JUMP re-stages the scene onto the destination — jumpThroughGate).
|
||||||
|
* Idempotent throughout.
|
||||||
*/
|
*/
|
||||||
_activateSystemJumpgates(sysId) {
|
_activateSystemJumpgates(sysId) {
|
||||||
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
|
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue