diff --git a/assets/images/originals/gasgiant-shop-03.png b/assets/images/originals/gasgiant-shop-03.png new file mode 100644 index 0000000..29408f5 Binary files /dev/null and b/assets/images/originals/gasgiant-shop-03.png differ diff --git a/assets/images/originals/gasgiant-surface-03.png b/assets/images/originals/gasgiant-surface-03.png new file mode 100644 index 0000000..d332ef5 Binary files /dev/null and b/assets/images/originals/gasgiant-surface-03.png differ diff --git a/assets/videos/gasgiant-shop-02.mp4 b/assets/videos/gasgiant-shop-02.mp4 new file mode 100644 index 0000000..b7d3c66 Binary files /dev/null and b/assets/videos/gasgiant-shop-02.mp4 differ diff --git a/data/gates.json b/data/gates.json index 1297ad5..45b138c 100644 --- a/data/gates.json +++ b/data/gates.json @@ -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" } } diff --git a/data/landing.json b/data/landing.json index 9e59105..ff458a7 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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" }, diff --git a/dev/jump-travel.test.mjs b/dev/jump-travel.test.mjs new file mode 100644 index 0000000..1fa952f --- /dev/null +++ b/dev/jump-travel.test.mjs @@ -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'); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index b0a6f9a..2afe099 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 - "). 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. + "). 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. diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 89023f8..5032ff9 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -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 diff --git a/js/galaxy/JumpTravel.js b/js/galaxy/JumpTravel.js new file mode 100644 index 0000000..1926cd0 --- /dev/null +++ b/js/galaxy/JumpTravel.js @@ -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 }; +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 9d79d0c..adfc782 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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));