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",
|
||||
"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-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": "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-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": "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-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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
their rim), discoverable (compass arrows + toast, in the gate cyan
|
||||
`#5fd4ff`), and the HUD dossier lists them ("… Gate · jump to
|
||||
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them)
|
||||
— standing at a gate is where the jump happens; the jump mechanic
|
||||
itself is the follow-up.
|
||||
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them;
|
||||
`gateAt()` is their own hit test) — a click on an ACTIVE gate is the
|
||||
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
|
||||
placements, same `active` flags. Verified: `dev/jumps.test.mjs`
|
||||
(network invariants, placement invariants, barren gates on-ray,
|
||||
`active: false` everywhere, composition buckets, determinism),
|
||||
`dev/jumpgate.test.mjs` (the entity's solid contract + dormant/active
|
||||
rendering), and the layout band in `dev/discovery.test.mjs`.
|
||||
placements, same `active` flags, same jump arrivals. Verified:
|
||||
`dev/jumps.test.mjs` (network invariants, placement invariants, barren
|
||||
gates on-ray, `active: false` everywhere, composition buckets,
|
||||
determinism), `dev/jumpgate.test.mjs` (the entity's solid contract +
|
||||
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)
|
||||
|
||||
|
|
@ -386,8 +409,9 @@ are NAMED after that system. Node ids embed the system id
|
|||
one shared category without collisions:
|
||||
- **`{System} Map`** — `duration: 0`, in `starting`: granted the moment
|
||||
the player is in the system (`GameScene._onEnterSystem()` unlocks it —
|
||||
the jump mechanic, when it lands, calls the same seam on every
|
||||
arrival). Copy: *Added the Solar System of {system} to the onboard
|
||||
create() runs it on every entry, which a jump is: the jump restarts
|
||||
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.*
|
||||
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
|
||||
**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),
|
||||
`applyActivation(content, systemId, keys)` (flip the records
|
||||
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`,
|
||||
`start`, `progress(time)`, `tick(time)` (→ array of completions),
|
||||
`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
|
||||
* circle rule, GameScene.solids), discoverable (a compass arrow + the
|
||||
* discovery toast, at the scale of its keepout, in its own cyan).
|
||||
* It is NOT a comms target (GameScene.worldObjectAt excludes it) —
|
||||
* standing at a gate is where the jump happens; that arrives with the
|
||||
* jump mechanic.
|
||||
* It is NOT a comms target (GameScene.worldObjectAt excludes it —
|
||||
* gateAt() is its own hit test): a click on an ACTIVE gate is the
|
||||
* 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
|
||||
* — 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 —
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -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 { Station } from '../entities/Station.js';
|
||||
import { JumpGate } from '../entities/JumpGate.js';
|
||||
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
||||
import { Starfield } from '../visuals/Starfield.js';
|
||||
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||
import { ActionBar } from '../ui/ActionBar.js';
|
||||
|
|
@ -23,7 +24,7 @@ import { MineralHud } from '../ui/MineralHud.js';
|
|||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||
import { SavePanel } from '../ui/SavePanel.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 { Mining } from '../mining/Mining.js';
|
||||
import { MiningPopup } from '../ui/MiningPopup.js';
|
||||
|
|
@ -177,6 +178,10 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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
|
||||
// is what drives the parallax starfield — ship flies, view trails.
|
||||
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 +
|
||||
// toast, in their own cyan), and each one faces its destination
|
||||
// star on the map (entity.rotation). They are NOT comms targets —
|
||||
// standing at a gate is where the jump happens (coming with the
|
||||
// jump mechanic); GameScene.worldObjectAt deliberately excludes
|
||||
// them, so a click falls through to fly-to-point.
|
||||
// a click on an ACTIVE gate is the JUMP (GameScene.jumpThroughGate
|
||||
// — the transport); a dormant one is the ordinary fly-here.
|
||||
// worldObjectAt deliberately excludes them (no comms panel for a
|
||||
// gate); gateAt() is their own hit test.
|
||||
this.systemGates = [];
|
||||
if (config.get('gates.enabled', true) !== false) {
|
||||
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'
|
||||
// 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
|
||||
// replaced the unlocked set), and the jump mechanic (when it lands)
|
||||
// calls _onEnterSystem() on every arrival.
|
||||
// replaced the unlocked set), and a jump (jumpThroughGate →
|
||||
// scene.restart → this create) grants the destination's.
|
||||
this._onEnterSystem();
|
||||
|
||||
// ---- 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
|
||||
// itself.
|
||||
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
|
||||
// (its scrim / cards / dialog eat the click; the world stays put).
|
||||
if (this.savePanel && this.savePanel.isOpen) return;
|
||||
|
|
@ -717,6 +726,24 @@ export class GameScene extends Phaser.Scene {
|
|||
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
|
||||
// flow above): the click opens the comms panel (the landing-request
|
||||
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
|
||||
|
|
@ -1415,6 +1442,109 @@ export class GameScene extends Phaser.Scene {
|
|||
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:
|
||||
* - 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
|
||||
* '{System} Map' tech is granted (duration 0 — never researched via the
|
||||
* console). Idempotent — create() calls it for the starting system (and
|
||||
* again after a load, whose restore replaced the unlocked set); the
|
||||
* jump mechanic (when it lands) calls it on every arrival. */
|
||||
* again after a load, whose restore replaced the unlocked set), and a
|
||||
* jump's scene restart runs it for the destination. */
|
||||
_onEnterSystem() {
|
||||
if (!this.researchState || !this.systemRecord) return;
|
||||
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
|
||||
* barren system, the whole room). The linked systems' return gates flip
|
||||
* their content record now (or on entry — the create() pass); their
|
||||
* entities + tethers materialise when the player arrives (the jump
|
||||
* mechanic, later). Idempotent throughout.
|
||||
* entities + tethers materialise when the player jumps there (the
|
||||
* JUMP re-stages the scene onto the destination — jumpThroughGate).
|
||||
* Idempotent throughout.
|
||||
*/
|
||||
_activateSystemJumpgates(sysId) {
|
||||
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
|
||||
|
|
|
|||
Loading…
Reference in New Issue