diff --git a/assets/images/jumpgate.png b/assets/images/jumpgate.png
new file mode 100644
index 0000000..68f1e51
Binary files /dev/null and b/assets/images/jumpgate.png differ
diff --git a/assets/images/jumpgate.psd b/assets/images/jumpgate.psd
new file mode 100644
index 0000000..0f00aa0
Binary files /dev/null and b/assets/images/jumpgate.psd differ
diff --git a/assets/videos/rocky-takeoff-01.mp4 b/assets/videos/rocky-takeoff-01.mp4
new file mode 100644
index 0000000..2ae0ba0
Binary files /dev/null and b/assets/videos/rocky-takeoff-01.mp4 differ
diff --git a/data/gates.json b/data/gates.json
index 69fb43a..d83b59f 100644
--- a/data/gates.json
+++ b/data/gates.json
@@ -1,11 +1,14 @@
{
- "_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets, no unreachable stars), every jump is to a nearby star, and no system ever holds more than maxGates gates. Single-gate systems (e.g. the barren ones) read as dead-end corridors — enter from a neighbor, exit through the gate — which is fine as long as they are connected to the main network, which strong connectivity guarantees. PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the gate sits ON the ray from the star toward its destination, `barrenDistance` px from the star (stepped outward within the minRadius..maxRadius band if two gates would otherwise violate the gap), facing its destination. ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label.",
+ "_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets, no unreachable stars), every jump is to a nearby star, and no system ever holds more than maxGates gates. Single-gate systems (e.g. the barren ones) read as dead-end corridors — enter from a neighbor, exit through the gate — which is fine as long as they are connected to the main network, which strong connectivity guarantees. PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the gate sits ON the ray from the star toward its destination, `barrenDistance` px from the star (stepped outward within the minRadius..maxRadius band if two gates would otherwise violate the gap), facing its destination. ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
"enabled": true,
"minGates": 1,
"maxGates": 3,
"neighborPool": 8,
"shortcuts": true,
"size": 96,
+ "texture": "assets/images/jumpgate.png",
+ "frameWidth": 256,
+ "frameHeight": 256,
"shipClearance": 50,
"clearance": 256,
"gateGap": 192,
@@ -15,6 +18,13 @@
"barrenDistance": 8192,
"typeLabel": "Jump Gate",
"theme": { "color": "#5fd4ff" },
+ "swirl": {
+ "_comment": "ACTIVE-GATE SWIRL — frame 1 of `texture` (the swirling energy disc that fills the gate's mouth). Shown only when the gate is active (activation below): it spins slowly CLOCKWISE at spinSpeed (rad/s — one revolution every 2π/spinSpeed s) and its alpha breathes between alphaMin and alphaMax, one breath cycle every 2π/breathRate s. Dormant gates show only frame 0 — the body is always drawn at full alpha, and the swirl's absence is the 'this one is live' tell.",
+ "spinSpeed": 0.3,
+ "alphaMin": 0.6,
+ "alphaMax": 0.9,
+ "breathRate": 1.8
+ },
"activation": {
"_comment": "ACTIVATION (the SYSTEM research category — js/research/SystemCategory.js builds the per-system tree from this): '{system} Map' is granted the moment the player is in the system (duration 0 — 'starting'), and 'Unlock {system} Jumpgates' (researchDuration, in research.timeUnit seconds) becomes researchable once EVERY NAV point of the system is discovered (the central body, all planets, all space stations, all jump gates — the scene's discoverable set minus the asteroid clusters; data/game.json → discovery.distance). Completing it activates the system's gates AND the return gates in the systems they connect to — and per the ACTIVITY rule above, each activated gate anchors a level-`tetherLevel` tether at its own position (a TETHER ANCHOR in its own right; that tether saves/restores with the run's tether list). The label/description copy is templated: `{system}` is replaced with the system name.",
"researchDuration": 45,
@@ -29,7 +39,7 @@
}
},
"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). The jump plays a full-screen one-shot clip (`video`, cover-scaled over the theme background) between the two systems — the destination is already staged behind it, so the scene restarts the moment the clip ends (or errors, or stalls); `videoVolume` is 0..1 (0 = silent). A DOUBLE-CLICK (two quick presses) skips the rest of the clip, same as the landing/takeoff clips. If `video` is empty/missing the jump falls back to the short `jumpDelayMs` cut. `enabled` is the feature switch; the toasts are templated ({dest}/{system}).",
+ "_comment": "THE JUMP (GameScene.jumpThroughGate): CLICK FLOW — a click on ANY gate opens the GATE COMM WINDOW (GameScene.openGateCommsPanel → the shared comms panel, js/ui/CommsPanel.js, in its gate variant: the LINK line reads ACTIVE/DORMANT, CANCEL just closes it) and REQUEST JUMP — a grayed ghost while the gate is dormant or with `enabled` off — 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). The jump plays a full-screen one-shot clip (`video`, cover-scaled over the theme background) between the two systems — the destination is already staged behind it, so the scene restarts the moment the clip ends (or errors, or stalls); `videoVolume` is 0..1 (0 = silent). A DOUBLE-CLICK (two quick presses) skips the rest of the clip, same as the landing/takeoff clips. If `video` is empty/missing the jump falls back to the short `jumpDelayMs` cut. `enabled` is the feature switch; dormantToast is the dormant-state copy (dev/jump-travel.test.mjs pins its {system} placeholder); the toasts are templated ({dest}/{system}).",
"enabled": true,
"arrivalGap": 128,
"video": "assets/videos/jump.mp4",
diff --git a/dev/gate-comms.html b/dev/gate-comms.html
new file mode 100644
index 0000000..3da865b
--- /dev/null
+++ b/dev/gate-comms.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ orbit — gate comm-window test
+
+
+
+
+
+
+
+
+
diff --git a/dev/gate-comms.mjs b/dev/gate-comms.mjs
new file mode 100644
index 0000000..8c239b1
--- /dev/null
+++ b/dev/gate-comms.mjs
@@ -0,0 +1,339 @@
+/**
+ * Dev: real-input test for the GATE comm window (v0.5):
+ *
+ * clicking a gate NO LONGER jumps (or fly-heres) — it opens the
+ * shared comms panel in its gate variant (js/ui/CommsPanel.js):
+ *
+ * 1. click a DORMANT gate → window opens: LINK … DORMANT,
+ * REQUEST JUMP grayed (disabled), CANCEL;
+ * the ship does NOT fly to the gate
+ * 2. click CANCEL → closes, no jump seam fires
+ * 3. click an ACTIVE gate → LINK … ACTIVE, REQUEST JUMP enabled
+ * 4. click REQUEST JUMP → the jump seam fires with the gate
+ * entity (jumpThroughGate) + closes
+ * 5. ESC → closes
+ * 6. gate window open, click ANOTHER gate → the window re-anchors
+ * on that gate (name + position)
+ *
+ * Real DOM mouse events hit the canvas (Phaser's input manager), so
+ * this exercises the full path: scene pointerdown → gateAt →
+ * openGateCommsPanel, and the panel's own button dispatch
+ * (commsAction).
+ *
+ * node dev/server.mjs 8099
+ * node dev/cdp-firefox.mjs http://localhost:8099/dev/gate-comms.html \
+ * 'return window.__GATE_COMMS__; ' 90000
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+const __fonts = globalThis.document?.fonts;
+if (__fonts && typeof __fonts.load === 'function') {
+ const fams = ['header', 'body']
+ .map((k) => config.get(`theme.fonts.${k}.family`))
+ .filter((f) => typeof f === 'string' && f.length > 0);
+ if (fams.length > 0) {
+ await Promise.race([
+ Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))),
+ new Promise((r) => setTimeout(r, 2000)),
+ ]);
+ }
+}
+
+// 'VESTRA's starting system holds three (dormant) gates.
+globalThis.__ORBIT_DEV_SEED = 'VESTRA';
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene]; // GameScene boots first
+if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
+
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+/* ---- helpers ---------------------------------------------------------- */
+
+const scene = () => {
+ const g = window.game;
+ if (!g) return null;
+ const s = g.scene.getScene('GameScene');
+ return s && s.sys?.isActive && s.commsPanel ? s : null;
+};
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/** Pump the (rAF-frozen) loop until `ok()` or the budget elapses. */
+async function pump(s, ok, ms = 4000) {
+ const t0 = Date.now();
+ while (!ok() && Date.now() - t0 < ms) {
+ s.game.loop.step(performance.now());
+ await sleep(0);
+ }
+ return ok();
+}
+
+async function ready() {
+ const t0 = Date.now();
+ for (;;) {
+ const s = scene();
+ if (s && s.ship && (s.systemGates?.length ?? 0) >= 2) return s;
+ if (Date.now() - t0 > 45000) throw new Error('game never became ready');
+ await sleep(50);
+ }
+}
+
+/** A real mouse click at viewport (client) coordinates (v4's input
+ * manager listens for classic mouse events on the canvas). A leading
+ * mousemove refreshes the pointer so the mousedown's world coords are
+ * current (Phaser caches them per frame).
+ * NOTE: camera placement must use cam.setScroll() — plain .scrollX
+ * assignment does not stick — and the scene's follow must be off first.
+ */
+function clickAtClient(clientX, clientY) {
+ const canvas = document.querySelector('canvas');
+ const mk = (type, buttons) =>
+ new MouseEvent(type, {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ view: window,
+ clientX,
+ clientY,
+ button: 0,
+ buttons,
+ });
+ canvas.dispatchEvent(mk('mousemove', 0));
+ window.game.loop.step(performance.now());
+ canvas.dispatchEvent(mk('mousedown', 1));
+ canvas.dispatchEvent(mk('mouseup', 0));
+}
+
+/** ESC (Phaser's keyboard plugin reads the legacy keyCode). */
+function pressEscape() {
+ const ev = new KeyboardEvent('keydown', {
+ key: 'Escape',
+ code: 'Escape',
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ });
+ Object.defineProperty(ev, 'keyCode', { get: () => 27 });
+ Object.defineProperty(ev, 'which', { get: () => 27 });
+ window.dispatchEvent(ev);
+}
+
+/** World → client coords through the camera + the CSS-scaled canvas rect. */
+function worldToClient(s, wx, wy) {
+ const cam = s.cameras.main;
+ const rect = document.querySelector('canvas').getBoundingClientRect();
+ const scaleX = rect.width / s.scale.width;
+ const scaleY = rect.height / s.scale.height;
+ return {
+ clientX: rect.left + (wx - cam.scrollX) * scaleX,
+ clientY: rect.top + (wy - cam.scrollY) * scaleY,
+ };
+}
+
+/* ---- the test --------------------------------------------------------- */
+
+window.__GATE_COMMS_TEST__ = async () => {
+ const out = { steps: [] };
+ const s = await ready();
+ const step = (name, pass, extra = {}) => out.steps.push({ name, pass, ...extra });
+
+ s.cameraFollowShip = false;
+ s.hideHint?.();
+ s.ship.stop();
+
+ const [gateA, gateB] = s.systemGates; // both dormant by default
+
+ // Keep the ship OUT of both gates' keepouts (solid — the ship can't
+ // overlap them), well off-screen from the framings below. (A click on
+ // a gate must not set the ship's target — that's the fly-here tell.)
+ s.ship.x = gateA.x + 2500;
+ s.ship.y = gateA.y + 2000;
+
+ const frame = (wx, wy, sx, sy) => {
+ s.cameras.main.setScroll(wx - sx, wy - sy);
+ s.cameraFollowShip = false;
+ };
+ const center = (wx, wy) => ({ sx: 640, sy: 300, wx, wy });
+
+ // The jump seam — stubbed (this harness registers ONLY GameScene;
+ // the real jumpThroughGate would restart the scene mid-test).
+ let jumped = null;
+ const origJump = s.jumpThroughGate;
+ s.jumpThroughGate = (gt) => { jumped = gt; };
+
+ // Action-dispatch log (which button id the panel fired, in order).
+ const actionLog = [];
+ const origCA = s.commsAction;
+ s.commsAction = (id, t) => { actionLog.push(id); origCA.call(s, id, t); };
+
+ // Button centers: panel-local y of btn1/btn2 (the gate layout is
+ // shorter than the settled one — read them, don't assume).
+ const btnPoint = (which) => {
+ const b = s.commsPanel[which];
+ return worldToClient(s, s.commsPanel.x, s.commsPanel.y + b.y);
+ };
+
+ // ---- 1. Real click ON the dormant gate → window opens, no fly ------
+ const f1 = center(gateA.x, gateA.y);
+ frame(gateA.x, gateA.y, f1.sx, f1.sy);
+ await pump(s, () => !s.commsPanel.isOpen, 1000);
+ const c1 = worldToClient(s, gateA.x, gateA.y);
+ clickAtClient(c1.clientX, c1.clientY);
+ await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000);
+
+ const p = s.commsPanel;
+ step(
+ 'click DORMANT gate → window opens + ship does NOT fly',
+ p.isOpen &&
+ p._name === gateA.discoveryName.toUpperCase() &&
+ p.btn1.disabled === true &&
+ s.ship.target === null,
+ {
+ panelName: p._name,
+ gateName: gateA.discoveryName,
+ btn1: p.btn1.text.text,
+ btn1Disabled: p.btn1.disabled,
+ shipTarget: s.ship.target,
+ },
+ );
+ step(
+ 'dormant window reads LINK … DORMANT + REQUEST JUMP ghost',
+ p.repLabel.text === 'LINK' &&
+ p.repValue.text === 'DORMANT' &&
+ p.btn1.text.text === 'REQUEST JUMP' &&
+ p.btn2.text.text === 'CANCEL' &&
+ p.marksG.visible === false,
+ { link: p.repLabel.text, status: p.repValue.text, btn1: p.btn1.text.text, btn2: p.btn2.text.text },
+ );
+
+ // ---- 2. Click CANCEL → closes, no jump ------------------------------
+ const b2 = btnPoint('btn2');
+ clickAtClient(b2.clientX, b2.clientY);
+ await pump(s, () => !s.commsPanel.isOpen, 2500);
+ step('click CANCEL → closes, no jump', !s.commsPanel.isOpen && jumped === null, { jumped });
+
+ // ---- 3. Click the ACTIVE gate → enabled REQUEST JUMP -----------------
+ gateA.active = true; // (the research tech would do this — same flag)
+ await pump(s, () => true, 200);
+ const f3 = center(gateA.x, gateA.y);
+ frame(gateA.x, gateA.y, f3.sx, f3.sy);
+ const c3 = worldToClient(s, gateA.x, gateA.y);
+ clickAtClient(c3.clientX, c3.clientY);
+ await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000);
+ step(
+ 'click ACTIVE gate → LINK … ACTIVE + REQUEST JUMP enabled',
+ s.commsPanel.isOpen &&
+ s.commsPanel.repValue.text === 'ACTIVE' &&
+ s.commsPanel.btn1.disabled === false &&
+ s.commsPanel.btn1.text.text === 'REQUEST JUMP',
+ { status: s.commsPanel.repValue.text, btn1Disabled: s.commsPanel.btn1.disabled },
+ );
+
+ // ---- 4. Click REQUEST JUMP → the seam fires with the gate + closes --
+ jumped = null;
+ actionLog.length = 0;
+ const b4 = btnPoint('btn1');
+ clickAtClient(b4.clientX, b4.clientY);
+ await pump(s, () => !s.commsPanel.isOpen, 2500);
+ step(
+ 'click REQUEST JUMP → jumpThroughGate(gate) fires + closes',
+ !s.commsPanel.isOpen && jumped === gateA,
+ {
+ jumpedGate: jumped?.discoveryName ?? null,
+ isGateA: jumped === gateA,
+ actionLog: [...actionLog],
+ btn1LocalY: s.commsPanel.btn1.y,
+ clickAt: [b4.clientX, b4.clientY],
+ panelAt: [s.commsPanel.x, s.commsPanel.y],
+ },
+ );
+
+ // ---- 5. ESC closes the gate window -----------------------------------
+ const c5 = worldToClient(s, gateA.x, gateA.y);
+ clickAtClient(c5.clientX, c5.clientY);
+ await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000);
+ pressEscape();
+ await pump(s, () => !s.commsPanel.isOpen, 2500);
+ step('ESC → closes', !s.commsPanel.isOpen);
+
+ // ---- 6. Gate window open → click ANOTHER gate → re-anchors -----------
+ const c6 = worldToClient(s, gateA.x, gateA.y);
+ clickAtClient(c6.clientX, c6.clientY);
+ await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000);
+ // Re-frame on gate B (the camera still shows gate A), then click it.
+ // The panel stays anchored at gate A (world) and never covers gate B
+ // (gate spacing ≥ 2·size + gateGap = 384 px > the panel's 156 px reach).
+ frame(gateB.x, gateB.y, 640, 300);
+ // gateA is ACTIVE now, gateB dormant — the re-anchor must re-read the
+ // target gate's state (enabled button → ghost again).
+ const c6b = worldToClient(s, gateB.x, gateB.y);
+ clickAtClient(c6b.clientX, c6b.clientY);
+ await pump(s, () => s.commsPanel.nameDec === null && s.commsPanel._name === gateB.discoveryName.toUpperCase(), 4000);
+ // The panel anchors on a SIDE of the click (pickSideAndPlace): its
+ // center sits H/2 + LIFT (vertical) or W/2 + LIFT (horizontal) away
+ // from the gate — check the adjacency, not the exact center.
+ const dAB = Math.hypot(s.commsPanel.x - gateB.x, s.commsPanel.y - gateB.y);
+ const adjOK =
+ Math.abs(dAB - (s.commsPanel.H / 2 + 14)) < 4 ||
+ Math.abs(dAB - (s.commsPanel.W / 2 + 14)) < 4;
+ step(
+ 'gate window open → click another gate → re-anchors (name + state)',
+ s.commsPanel.isOpen &&
+ s.commsPanel._name === gateB.discoveryName.toUpperCase() &&
+ adjOK &&
+ s.commsPanel.repValue.text === 'DORMANT' &&
+ s.commsPanel.btn1.disabled === true,
+ {
+ panelName: s.commsPanel._name,
+ panelAt: [Math.round(s.commsPanel.x), Math.round(s.commsPanel.y)],
+ gateBAt: [Math.round(gateB.x), Math.round(gateB.y)],
+ dist: Math.round(dAB),
+ status: s.commsPanel.repValue.text,
+ btn1Disabled: s.commsPanel.btn1.disabled,
+ },
+ );
+
+ s.commsPanel.close();
+ s.jumpThroughGate = origJump;
+ s.commsAction = origCA;
+ out.bootErrors = window.__BOOT_ERRORS__ ?? [];
+ out.allPass = out.steps.every((x) => x.pass) && out.bootErrors.length === 0;
+ return out;
+};
+
+/* ---- auto-run (the CDP runner polls window.__GATE_COMMS__) ------------- */
+
+let done = false;
+game.events.once('ready', async () => {
+ try {
+ window.__GATE_COMMS__ = await window.__GATE_COMMS_TEST__();
+ } catch (err) {
+ console.error(err);
+ window.__GATE_COMMS__ = { allPass: false, steps: [], error: String(err?.stack || err) };
+ }
+ done = true;
+ const r = window.__GATE_COMMS__;
+ console.log(`GATE-COMMS ${r.allPass ? 'PASS' : 'FAIL'}`);
+});
+
+// Hard stop: if the flow never finishes, the runner will report the
+// failure (same pattern as dev/saves-ui-test.mjs).
+const hardStop = (t0) => {
+ if (performance.now() - t0 >= 240000) {
+ if (!done) {
+ window.__GATE_COMMS__ = { allPass: false, steps: [], error: 'TIMED OUT (240s)' };
+ console.log('GATE-COMMS FAIL (timed out)');
+ }
+ return;
+ }
+ requestAnimationFrame(() => hardStop(t0));
+};
+requestAnimationFrame(() => hardStop(performance.now()));
diff --git a/dev/jump-travel.test.mjs b/dev/jump-travel.test.mjs
index 1fa952f..78318b3 100644
--- a/dev/jump-travel.test.mjs
+++ b/dev/jump-travel.test.mjs
@@ -4,8 +4,9 @@
* 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):
+ * the gate comm window's REQUEST JUMP, 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
diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs
index f6ccf44..438c8dd 100644
--- a/dev/jumpgate.test.mjs
+++ b/dev/jumpgate.test.mjs
@@ -10,15 +10,19 @@
* - discovery identity: discoveryId/discoveryName from the record,
* size from data/gates.json, bound = size (compass/toast scale),
* clearance = gates.shipClearance, rotation = the record's bearing;
- * - ACTIVITY (data/gates.json → ACTIVITY): gate.active mirrors the
- * record (false by default — the gate is dormant: dimmed, field
- * still), an active:true gate breathes its field;
+ * - the SPRITE renderer (data/gates.json → texture): frame 0 = the
+ * gate body (static, always full alpha — even dormant), frame 1 =
+ * the active swirl — hidden while dormant, revealed when active and
+ * then spinning clockwise with its alpha breathing EXACTLY between
+ * swirl.alphaMin and swirl.alphaMax; sprite scale =
+ * (size×2)/frameWidth (the ring's outer edge on the keepout disc);
+ * - the FALLBACK renderer (sheet missing): the old procedural gate —
+ * dormant dim (0.4), dormant field still, active field breathes;
* - the SOLID contract (same as Planet/Station): minCenterDistance,
* edgePoint exactly `gap` past the surface, aimPoint clamping, and
* constrainShip pushing the ship out of the keepout circle
* (through the shared Planet.resolve);
- * - update() breathes the field (active) or keeps it still (dormant)
- * without throwing;
+ * - update() runs without throwing (both renderers);
* - destroy() tears the children down.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
@@ -76,12 +80,30 @@ const check = (label, cond) => {
const { JumpGate } = await import(pathToFileURL(join(__dirname, '../js/entities/JumpGate.js')).href);
-// A scene stub: add.existing (v4 quirk), add.graphics (recording no-op),
-// add.circle (the field discs — setAlpha is driven by update()).
+// A scene stub: textures.exists (flip per renderer pass), add.image
+// (recording the sprite frames), add.graphics (recording no-op) and
+// add.circle (the fallback field discs — setAlpha is driven by update()).
+let texturesPresent = true;
+const images = [];
const alphas = [];
const scene = {
+ textures: { exists: () => texturesPresent },
add: {
existing: (o) => o,
+ image: (x, y, key, frame) => {
+ const img = {
+ x, y, key, frame,
+ alpha: 1,
+ visible: true,
+ rotation: 0,
+ scale: 1,
+ setScale(s) { this.scale = s; return this; },
+ setAlpha(a) { this.alpha = a; return this; },
+ setVisible(v) { this.visible = v; return this; },
+ };
+ images.push(img);
+ return img;
+ },
graphics: () => ({
lineStyle() { return this; },
strokeCircle() { return this; },
@@ -104,7 +126,14 @@ const rec = g.currentSystem();
const content = g.ensureContent(rec.id);
const gateRec = content.jumps[0];
+const aMin = config.get('gates.swirl.alphaMin', 0.6);
+const aMax = config.get('gates.swirl.alphaMax', 0.9);
+const expectedScale = (config.get('gates.size', 96) * 2) / config.get('gates.frameWidth', 256);
+
+// --- The SPRITE renderer (the default — the sheet is configured) --------
+texturesPresent = true;
const gate = new JumpGate(scene, gateRec, { depth: 5 });
+const [body, swirl] = images.slice(-2);
check('discoveryId / discoveryName come from the record', gate.discoveryId === gateRec.id && gate.discoveryName === gateRec.name);
check('position comes from the record', gate.x === gateRec.x && gate.y === gateRec.y);
@@ -112,23 +141,35 @@ check('size comes from data/gates.json', gate.size === config.get('gates.size',
check('clearance comes from gates.shipClearance', gate.clearance === config.get('gates.shipClearance', 50));
check('rotation = the record’s bearing (facing the destination star)', gate.rotation === gateRec.rotation);
check('active mirrors the record (false by default — dormant)', gateRec.active === false && gate.active === false);
-check('a dormant gate renders dim (alpha 0.4)', Math.abs(gate.alpha - 0.4) < 1e-9);
+check('sprite path: both frames drawn (body = frame 0, swirl = frame 1)', body.frame === 0 && swirl.frame === 1);
+check('sprite scale = (size×2)/frameWidth (the ring edge on the keepout disc)',
+ Math.abs(body.scale - expectedScale) < 1e-9 && Math.abs(swirl.scale - expectedScale) < 1e-9);
+check('a dormant gate keeps the body at full alpha (1.0)', gate.alpha === 1);
+check('a dormant gate hides the swirl', swirl.visible === false);
+gate.update(1000);
+gate.update(2000);
+check('dormant gate: update() keeps the swirl hidden (no breathing)', swirl.visible === false);
-// --- ACTIVITY: a dormant gate's field stays still; an active one breathes
-{
- const before = alphas.map((c) => c.alpha);
- gate.update(1000);
- gate.update(2000);
- check('dormant gate: update() leaves the field still (no breathing)', alphas.every((c, i) => c.alpha === before[i]));
- const alphasB = alphas.length;
- const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
- const alphas2 = alphas.slice(alphasB);
- gateB.update(0);
- const a0 = alphas2.map((c) => c.alpha);
- gateB.update(873); // t ≈ π/2 of the pulse → maximum breathing
- check('active gate: the field breathes (alpha changes over time)', alphas2.some((c, i) => Math.abs(c.alpha - a0[i]) > 1e-6));
- check('an active gate is not dimmed', gateB.alpha === 1);
+// --- ACTIVITY: activation reveals the swirl; it spins + breaths ---------
+const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
+const swirlB = images[images.length - 1];
+check('an active gate is not dimmed', gateB.alpha === 1);
+check('an active gate reveals the swirl', swirlB.visible === true);
+gateB.update(0);
+const a0 = swirlB.alpha;
+gateB.update(873); // t ≈ π/2 of the breath → near-maximum alpha
+check('active gate: the swirl breathes (alpha changes over time)', Math.abs(swirlB.alpha - a0) > 1e-6);
+let inRange = true;
+for (let ms = 0; ms <= 4000; ms += 250) {
+ gateB.update(ms);
+ if (swirlB.alpha < aMin - 1e-9 || swirlB.alpha > aMax + 1e-9) inRange = false;
}
+check('active gate: the swirl alpha stays within [alphaMin, alphaMax]', inRange);
+gateB.update(1000);
+const r1 = swirlB.rotation;
+gateB.update(3000);
+check('active gate: the swirl spins (rotation increases with time — clockwise)',
+ r1 > 0 && swirlB.rotation > r1);
// --- The solid contract (same rules as Planet / Station) ------------------
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
@@ -162,9 +203,26 @@ check('minCenterDistance = radius + clearance + shipRadius', Math.abs(minDist -
check('constrainShip leaves a ship outside the keepout untouched', s2.x === gate.x + minDist * 2 && s2.body.velocity.x === 10);
}
-// --- Animation + teardown --------------------------------------------------
-gate.update(1000); // must not throw; a dormant gate keeps the field still
-check('update() runs without throwing (dormant gate)', alphas.slice(0, 2).every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5));
+// --- The FALLBACK renderer (sheet missing) — the procedural gate ---------
+texturesPresent = false;
+const gateC = new JumpGate(scene, gateRec, { depth: 5 });
+const circlesC = alphas.slice(-2); // the fallback field discs
+check('fallback: a dormant gate renders dim (alpha 0.4)', Math.abs(gateC.alpha - 0.4) < 1e-9);
+check('fallback: the procedural field is built (two discs)', circlesC.length === 2);
+const before = circlesC.map((c) => c.alpha);
+gateC.update(1000);
+gateC.update(2000);
+check('fallback: dormant gate — update() leaves the field still', circlesC.every((c, i) => c.alpha === before[i]));
+const gateD = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
+const circlesD = alphas.slice(-2);
+check('fallback: an active gate is not dimmed', gateD.alpha === 1);
+const d0 = circlesD.map((c) => c.alpha);
+gateD.update(873); // t ≈ π/2 of the pulse → maximum breathing
+check('fallback: an active gate breathes its field', circlesD.some((c, i) => Math.abs(c.alpha - d0[i]) > 1e-6));
+
+// --- Teardown -------------------------------------------------------------
+gate.update(1000); // must not throw (sprite path, dormant)
+check('sprite gate: update() runs without throwing', true);
const childCount = gate.children.length;
gate.destroy();
check('destroy() clears the children', gate.children.length === 0 && childCount > 0);
diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md
index 42c5a4f..0623d08 100644
--- a/docs/PROJECT_NOTES.md
+++ b/docs/PROJECT_NOTES.md
@@ -298,9 +298,11 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
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;
- `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).
+ `gateAt()` is their own hit test) — a click on ANY gate opens the
+ GATE COMM WINDOW (the shared comms panel, js/ui/CommsPanel.js, in its
+ gate variant — the LINK line reads ACTIVE / DORMANT, REQUEST JUMP
+ initiates the jump and is a grayed ghost while dormant, CANCEL
+ closes it) — never the ordinary fly-here.
- **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
@@ -840,6 +842,20 @@ The player holds a REPUTATION (standing) on each planet and space station:
scale = (size×scale)/frameWidth keeps the 46 px world size (and 23 px
collision radius) regardless of frame size. Missing/failed sheet →
built-in procedural dart (facing east), with a console note.
+- **Jump gate art** — the gate is now a spritesheet frame pair:
+ `data/gates.json → texture` (assets/images/jumpgate.png, 256×256
+ frames). Frame 0 = the gate body (ring + pylons), drawn static at full
+ alpha in every state (the swirl's absence — not a dim — is the
+ dormant tell); frame 1 = the ACTIVE swirl — a swirling energy disc
+ that fills the mouth, shown only when the gate is active (activation
+ per
+ `data/gates.json → activation`), spinning slowly clockwise and
+ breathing its alpha between 0.6 and 0.9 (tunables in `gates.swirl`:
+ spinSpeed rad/s, alphaMin/Max, breathRate). Sprite
+ scale = (size×2)/frameWidth keeps the ring's outer edge on the 96 px
+ keepout disc (the ship hovers `shipClearance` px outside it)
+ regardless of frame size. Missing/failed sheet → built-in procedural
+ gate, with a console note.
- To upgrade: replace the vendored file + note the version here (and re-check
the quirks above — they may go away).
diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js
index 5032ff9..c73fe1f 100644
--- a/js/entities/JumpGate.js
+++ b/js/entities/JumpGate.js
@@ -5,10 +5,15 @@ import { Planet } from './Planet.js';
/**
* A JUMP GATE — the system's exit (SystemGenerator: `content.jumps`;
* the destination network in js/galaxy/JumpNetwork.js, config in
- * data/gates.json). Rendered as a world object: a twin-pylon portal
- * whose mouth and chevrons point at the DESTINATION STAR (`rotation`
- * is the bearing from the gate to that star), a slow-spinning outer
- * ring, and a breathing energy field.
+ * data/gates.json). Rendered as a world object from the spritesheet in
+ * data/gates.json → texture (assets/images/jumpgate.png): FRAME 0 is the
+ * gate body (ring + pylons), drawn static at FULL alpha — even while
+ * dormant; FRAME 1 is the ACTIVE SWIRL — a swirling energy disc that
+ * fills the mouth, shown only when the gate is active, spinning slowly
+ * clockwise and breathing its alpha
+ * between 0.6 and 0.9 (data/gates.json → swirl). The container's
+ * `rotation` (the bearing from the gate to the DESTINATION STAR) still
+ * applies to the whole art.
*
* Solid like a planet (the ship keeps its clearance — the same plain
* circle rule, GameScene.solids), discoverable (a compass arrow + the
@@ -23,17 +28,17 @@ import { Planet } from './Planet.js';
* — 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
- * pulse is the "this one is live" tell).
+ * DORMANT: the body is still drawn at full alpha, but the swirl is
+ * hidden (its absence is the "this one is live" tell).
*
- * The container itself carries `rotation` (the art is drawn facing
- * +x), so the whole gate — pylons, mouth, chevrons — faces its
- * destination. The collision API is circular and rotation-blind.
+ * The collision API is circular and rotation-blind.
*
- * update(time) breathes the field and drifts the ring — driven by
- * GameScene.update, like the stations and asteroid clusters.
+ * update(time) spins the swirl (active gates) / breathes the fallback
+ * art — driven by GameScene.update, like the stations and asteroid
+ * clusters.
*/
export class JumpGate extends Phaser.GameObjects.Container {
+ static TEXTURE_KEY = '__jumpgate';
/**
* @param {Phaser.Scene} scene
* @param {object} gate — the content record: { id, name, to, toName,
@@ -55,6 +60,7 @@ export class JumpGate extends Phaser.GameObjects.Container {
this.clearance = config.get('gates.shipClearance', 50);
this.theme = this._theme();
this.ringBody = null;
+ this.swirl = null;
this.setDepth(o.depth ?? 5);
this.rotation = gate.rotation ?? 0; // face the destination star
@@ -68,8 +74,48 @@ export class JumpGate extends Phaser.GameObjects.Container {
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
- /** Procedural build — the art faces +x (toward the destination). */
build() {
+ if (this.scene.textures.exists(JumpGate.TEXTURE_KEY)) {
+ this._buildSprite();
+ return;
+ }
+ console.warn(
+ '[orbit] jumpgate spritesheet did not load — using the built-in procedural gate.',
+ );
+ this._buildFallback();
+ }
+
+ /**
+ * Sprite build — frame 0 is the gate body (always full alpha, even
+ * dormant), frame 1 the active swirl (both centered in their frames,
+ * drawn facing +x like the fallback).
+ */
+ _buildSprite() {
+ const scene = this.scene;
+ // `size` is the keepout RADIUS (GameScene.solids). The art fills its
+ // square frame, so scale the frame to 2×size: the ring's outer edge
+ // lands on the keepout disc and the ship hovers `shipClearance` px
+ // outside it (96 px keepout + 50 px clearance at the defaults).
+ const scale = (this.size * 2) / config.get('gates.frameWidth', 256);
+ // Frame 0 — the gate body (ring + pylons), static.
+ const body = scene.add.image(0, 0, JumpGate.TEXTURE_KEY, 0);
+ body.setScale(scale);
+ this.add(body);
+ // Frame 1 — the swirl: hidden while DORMANT (or revealed at birth if
+ // a save already activated this gate); activate()/update() drive it.
+ this.swirl = scene.add.image(0, 0, JumpGate.TEXTURE_KEY, 1);
+ this.swirl.setScale(scale);
+ this.swirl.setAlpha(
+ (config.get('gates.swirl.alphaMin', 0.6) + config.get('gates.swirl.alphaMax', 0.9)) / 2,
+ );
+ this.swirl.setVisible(this.active);
+ this.add(this.swirl);
+ // Frame 0 stays at full alpha in every state — the swirl's presence
+ // (not a dim) is the "this one is live" tell.
+ }
+
+ /** Procedural fallback — the art faces +x (toward the destination). */
+ _buildFallback() {
const scene = this.scene;
const S = this.size;
const { r, g, b } = this.theme;
@@ -162,18 +208,31 @@ export class JumpGate extends Phaser.GameObjects.Container {
activate() {
if (this.active) return;
this.active = true;
- this.setAlpha(1);
+ this.setAlpha(1); // the fallback art lifts its dormant dim
+ if (this.swirl) this.swirl.setVisible(true); // update() breathes it from the next frame
}
- /** The ring drifts; the field breathes. (GameScene.update drives this.) */
+ /** The swirl spins + breathes (active); the fallback art drifts. (GameScene.update drives this.) */
update(time) {
const t = time / 1000;
if (this.ringBody) this.ringBody.rotation = t * (this.active ? 0.12 : 0.06);
- // A dormant gate keeps a still, faint field — no breathing.
+ // A dormant gate shows no swirl and keeps a still, faint field.
if (!this.active) return;
- const pulse = 0.5 + 0.5 * Math.sin(t * 1.8);
- if (this.field) this.field.setAlpha(0.1 + 0.14 * pulse);
- if (this.fieldCore) this.fieldCore.setAlpha(0.14 + 0.2 * pulse);
+ if (this.field) {
+ const pulse = 0.5 + 0.5 * Math.sin(t * 1.8);
+ this.field.setAlpha(0.1 + 0.14 * pulse);
+ this.fieldCore.setAlpha(0.14 + 0.2 * pulse);
+ }
+ if (this.swirl) {
+ // Frame 1 (data/gates.json → swirl): a slow CLOCKWISE spin — positive
+ // rotation is clockwise in the y-down plane — and a breathing alpha
+ // that oscillates exactly between alphaMin and alphaMax.
+ this.swirl.rotation = t * config.get('gates.swirl.spinSpeed', 0.3);
+ const aMin = config.get('gates.swirl.alphaMin', 0.6);
+ const aMax = config.get('gates.swirl.alphaMax', 0.9);
+ const rate = config.get('gates.swirl.breathRate', 1.8);
+ this.swirl.setAlpha(((aMin + aMax) / 2 + ((aMax - aMin) / 2) * Math.sin(t * rate)));
+ }
}
// ---- SOLID (the same contract as Planet / Station / AsteroidCluster) --
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 5599212..95f12eb 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -135,6 +135,22 @@ export class GameScene extends Phaser.Scene {
);
}
+ // The jump gate spritesheet (data/gates.json → texture): frame 0 =
+ // the gate body, frame 1 = the active swirl. If it isn't configured
+ // or the file is missing, JumpGate falls back to its built-in
+ // procedural gate (a console note says so in build()).
+ const gateTexture = config.get('gates.texture', '');
+ if (gateTexture) {
+ this.load.spritesheet(
+ JumpGate.TEXTURE_KEY,
+ gateTexture,
+ {
+ frameWidth: config.get('gates.frameWidth', 256),
+ frameHeight: config.get('gates.frameHeight', 256),
+ },
+ );
+ }
+
// The research console's archive feed (data/research.json → video):
// a muted 2:3 loop behind the RESEARCH window. A missing file just
// leaves the window's NO SIGNAL plate up — the console still works.
@@ -376,11 +392,12 @@ export class GameScene extends Phaser.Scene {
// gate network, js/galaxy/JumpNetwork.js): the system's exits.
// 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 —
- // 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.
+ // star on the map (entity.rotation). A click on a gate opens the
+ // GATE COMM WINDOW (openGateCommsPanel — the shared comms panel with
+ // a LINK status line; REQUEST JUMP initiates
+ // GameScene.jumpThroughGate, the transport) — never a fly-here.
+ // worldObjectAt deliberately excludes them (the panel opens via
+ // gateAt(), the gates' own hit test, not the comms target set).
this.systemGates = [];
if (config.get('gates.enabled', true) !== false) {
for (const j of this.systemContent.jumps ?? []) {
@@ -774,6 +791,13 @@ export class GameScene extends Phaser.Scene {
this.openCommsPanel(obj, pointer);
return;
}
+ // A gate click re-anchors the gate window on that gate (the same
+ // "another world moves the panel" contract).
+ const gt = this.gateAt(pointer.worldX, pointer.worldY);
+ if (gt) {
+ this.openGateCommsPanel(gt, pointer);
+ return;
+ }
this.commsPanel.close();
return;
}
@@ -800,21 +824,17 @@ export class GameScene extends Phaser.Scene {
}
// 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).
+ // through it): the click opens the GATE COMM WINDOW
+ // (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT,
+ // REQUEST JUMP initiates the transport (GameScene.jumpThroughGate,
+ // data/gates.json → jump; a grayed ghost while the gate is
+ // dormant) and CANCEL closes the window. Like a planet click it
+ // is NOT a fly-here — the ship stays put.
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 },
- );
+ this.hideHint();
+ this.openGateCommsPanel(gateObj, pointer);
+ return;
}
// A PLANET or STATION under the cursor (a rock click is the mining
@@ -1193,7 +1213,7 @@ export class GameScene extends Phaser.Scene {
// after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time);
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes
- for (const gt of this.systemGates) gt.update(_time); // the field breathes, the ring drifts
+ for (const gt of this.systemGates) gt.update(_time); // the swirl spins + breathes (active gates); fallback art drifts
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
@@ -1810,9 +1830,31 @@ export class GameScene extends Phaser.Scene {
});
}
+ /**
+ * A JUMP GATE was clicked: the comm window opens at the click — the
+ * name decodes in, the LINK line reads ACTIVE / DORMANT, and the
+ * buttons wait (REQUEST JUMP is a grayed ghost while the gate is
+ * dormant or gates.jump.enabled is off). Same contract as a planet
+ * click: NOT a fly-here (the ship stays put), world-anchored at the
+ * click, side-picked to stay on screen (CommsPanel.pickSideAndPlace).
+ */
+ openGateCommsPanel(gt, pointer) {
+ this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
+ name: gt.discoveryName,
+ kindLabel: String(config.get('gates.typeLabel', 'Jump Gate')),
+ gate: {
+ active: gt.active,
+ jumpEnabled: config.get('gates.jump.enabled', true) !== false,
+ // The JumpGate entity — commsAction hands it to jumpThroughGate.
+ entity: gt,
+ },
+ });
+ }
+
/**
* The panel's button presses (CommsPanel → onAction). The panel is
* already closed by the time this runs:
+ * 'request-jump' — the gate window, REQUEST JUMP → the JUMP
* 'request-landing' — a settled world, standing > −4 → land
* 'land' — an unsettled world → land
* 'cancel' — just close (the console log is the ack)
@@ -1820,6 +1862,14 @@ export class GameScene extends Phaser.Scene {
commsAction(id, target) {
this.commsPanel.close(); // the buttons close the panel (like miningAction)
if (!target) return;
+ if (id === 'request-jump') {
+ // The panel only offers this when the gate is active and
+ // gates.jump.enabled — jumpThroughGate keeps its own guards
+ // (mining in progress, a missing destination).
+ const gt = target.gate?.entity;
+ if (gt) this.jumpThroughGate(gt);
+ return;
+ }
if ((id === 'request-landing' || id === 'land') && target.isPlanet) {
this.startLanding(target);
return;
diff --git a/js/ui/CommsPanel.js b/js/ui/CommsPanel.js
index eb6f277..142d6dc 100644
--- a/js/ui/CommsPanel.js
+++ b/js/ui/CommsPanel.js
@@ -186,6 +186,16 @@ class TermButton extends Phaser.GameObjects.Container {
* skip the reputation block
* and read LAND / CANCEL
*
+ * JUMP GATE VARIANT (GameScene.openGateCommsPanel — a gate click): the
+ * same case; the reputation block is replaced by one status line —
+ *
+ * LINK ACTIVE (lit, the gate can jump)
+ * LINK DORMANT (amber, the gate is inert)
+ *
+ * — and the primary button reads REQUEST JUMP (a grayed ghost while
+ * the gate is dormant, or when gates.jump.enabled is off). CANCEL is
+ * the same in both variants.
+ *
* WORLD-ANCHORED (like the mining menu): the panel lives at the click
* point in the world and stays with it as the camera trails. It opens
* on the side (up/down/left/right) that keeps it fully on screen — the
@@ -203,12 +213,16 @@ class TermButton extends Phaser.GameObjects.Container {
* cp.open(wx, wy, screenX, screenY, {
* name, settled, reputation, litMarks, canLand, key, kindLabel,
* });
+ * // …or the gate variant (the two are mutually exclusive):
+ * cp.open(wx, wy, screenX, screenY, {
+ * name, kindLabel, gate: { active, jumpEnabled },
+ * });
* cp.close(); cp.contains(wx, wy); cp.isOpen; cp.update(time); cp.destroy();
*/
export class CommsPanel extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
- * @param {object} [o] { onAction?: (id: 'request-landing'|'land'|'cancel', target) => void }
+ * @param {object} [o] { onAction?: (id: 'request-landing'|'land'|'request-jump'|'cancel', target) => void }
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
@@ -244,6 +258,12 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.framePad * 2 + this.padTop + this.nameH + this.kindH +
this.gapSettled + this.repLabelH + this.repGap + this.marksH + this.gapBtns +
this.btnH + this.btnGap + this.btnH + this.padBottom;
+ // The gate variant: the LINK line where the reputation block sits,
+ // no marks row (see open() / layout()).
+ this.H_GATE =
+ this.framePad * 2 + this.padTop + this.nameH + this.kindH +
+ this.gapSettled + this.repLabelH + this.gapBtns +
+ this.btnH + this.btnGap + this.btnH + this.padBottom;
this.H_OPEN =
this.framePad * 2 + this.padTop + this.nameH + this.kindH +
this.gapOpen + this.btnH + this.btnGap + this.btnH + this.padBottom;
@@ -277,7 +297,11 @@ export class CommsPanel extends Phaser.GameObjects.Container {
letterSpacing: 1,
})
.setOrigin(1, 0.5);
- this.btn1 = new TermButton(scene, 'REQUEST LANDING', this.btnW, this.btnH, () => this.fire('request-landing'));
+ // btn1's action id is DYNAMIC — the world variants read
+ // REQUEST LANDING / LAND, the gate variant reads REQUEST JUMP and
+ // must fire 'request-jump' (open() switches this).
+ this.btn1Id = 'request-landing';
+ this.btn1 = new TermButton(scene, 'REQUEST LANDING', this.btnW, this.btnH, () => this.fire(this.btn1Id));
this.btn2 = new TermButton(scene, 'CANCEL', this.btnW, this.btnH, () => this.fire('cancel'));
// The phosphor flicker sits ON TOP (screen glass) — it is never
// interactive, so the buttons under it still catch their clicks.
@@ -306,6 +330,9 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.repRevealT0 = null;
this.repFinalLit = 0;
this.settled = false;
+ this.isGate = false;
+ this.gateActive = false;
+ this.gateJumpEnabled = true;
this.H = this.H_OPEN;
this.layout();
this.drawFrame();
@@ -322,7 +349,8 @@ export class CommsPanel extends Phaser.GameObjects.Container {
* Open at the click point.
* @param {number} wx, wy — the click, WORLD coords (the panel anchors here)
* @param {number} sx, sy — the same click in SCREEN coords (decides the side)
- * @param {object} o — { name, settled, reputation, litMarks?, canLand?, key?, kindLabel? }
+ * @param {object} o — { name, settled, reputation, litMarks?, canLand?, key?, kindLabel?,
+ * gate?: { active, jumpEnabled } } (gate = the gate variant)
*/
open(wx, wy, sx, sy, o = {}) {
if (this.scene === null || this.active === false) return;
@@ -334,6 +362,9 @@ export class CommsPanel extends Phaser.GameObjects.Container {
// the casing there makes the surface read its own home world as
// "nothing installed, no tether".
const display = String(o.name ?? 'UNKNOWN').toUpperCase();
+ this.isGate = !!o.gate;
+ this.gateActive = this.isGate && o.gate.active !== false;
+ this.gateJumpEnabled = this.isGate ? o.gate.jumpEnabled !== false : true;
this.settled = !!o.settled;
this.rep = Math.round(o.reputation ?? 0);
this.canLand = o.canLand !== false;
@@ -356,7 +387,29 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.nameDec = new ScrambleDecode(display, this.scene.time.now + 160, decodeDur(display.length));
this.kindText.setText(o.kindLabel ? `· ${String(o.kindLabel).toUpperCase()} ·` : '');
- if (this.settled) {
+ // The status value's color baseline (the gate variant tints it);
+ // world variants always read neutral phosphor green. Same for the
+ // primary button's action id (the gate variant switches it).
+ this.repValue.setColor(toCss(GRN_MID));
+ this.btn1Id = 'request-landing';
+
+ if (this.isGate) {
+ // JUMP GATE — one LINK status line (ACTIVE lit / DORMANT amber) in
+ // place of the reputation block, and the primary button is the
+ // jump itself (a grayed ghost while the gate is dormant, or with
+ // gates.jump.enabled off). CANCEL is the same in both.
+ this.repRevealT0 = null;
+ this.repFinalLit = 0;
+ this.marksG.setVisible(false);
+ this.repLabel.setVisible(true);
+ this.repValue.setVisible(true);
+ this.repLabel.setText('LINK');
+ this.repValue.setText(this.gateActive ? 'ACTIVE' : 'DORMANT');
+ this.repValue.setColor(toCss(this.gateActive ? GRN_BRIGHT : AMBER));
+ this.btn1Id = 'request-jump';
+ this.btn1.setLabel('REQUEST JUMP');
+ this.btn1.setDisabled(!this.gateActive || !this.gateJumpEnabled);
+ } else if (this.settled) {
// …then the bar draws itself to the standing (update() drives it).
const repMin = config.get('reputation.min', -20);
this.repFinalLit =
@@ -382,7 +435,11 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.btn2.setLabel('CANCEL');
this.btn2.setDisabled(false);
- this.H = this.settled ? this.H_SETTLED : this.H_OPEN;
+ this.H = this.isGate
+ ? this.H_GATE
+ : this.settled
+ ? this.H_SETTLED
+ : this.H_OPEN;
this.layout();
this.drawFrame();
this.pickSideAndPlace(wx, wy, sx, sy);
@@ -473,7 +530,8 @@ export class CommsPanel extends Phaser.GameObjects.Container {
/**
* Lay the children out for the current height. Top→bottom (always):
- * name · kind · [REPUTATION + the bar] · button 1 · CANCEL.
+ * name · kind · [REPUTATION + the bar | LINK status line] · button 1
+ * · CANCEL.
*/
layout() {
const F = this.framePad;
@@ -486,12 +544,18 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.kindText.setPosition(lx, st + this.padTop + this.nameH + this.kindH / 2);
let next = st + this.padTop + this.nameH + this.kindH;
- if (this.settled) {
+ if (this.settled || this.isGate) {
+ // The label line — REPUTATION (world) or LINK (gate).
const labelY = next + this.gapSettled + this.repLabelH / 2;
this.repLabel.setPosition(lx, labelY);
this.repValue.setPosition(this.W / 2 - F - this.innerPad, labelY);
- this.marksY = labelY + this.repLabelH / 2 + this.repGap + this.marksH / 2;
- next = this.marksY + this.marksH / 2 + this.gapBtns;
+ if (this.settled) {
+ this.marksY = labelY + this.repLabelH / 2 + this.repGap + this.marksH / 2;
+ next = this.marksY + this.marksH / 2 + this.gapBtns;
+ } else {
+ this.marksY = -1e9; // the gate has no marks row
+ next = labelY + this.repLabelH / 2 + this.gapBtns;
+ }
} else {
this.marksY = -1e9; // unused (marks hidden)
next += this.gapOpen;