Add gate comm window and sprite-based jump gate rendering

- Replace direct-click-to-jump with a shared comms panel variant that shows LINK ACTIVE/DORMANT status, REQUEST JUMP (disabled while dormant), and CANCEL
- Render gates from the new 256x256 spritesheet (frame 0 body, frame 1 active swirl) with procedural fallback when the texture is missing
- Active gate swirl spins clockwise and breathes alpha between configurable bounds; dormant gates keep the body at full alpha with the swirl hidden
- Wire CommsPanel to a dynamic primary-button action id so the same panel serves world and gate variants
- Add dev probe page (dev/gate-comms.html/.mjs) and update jumpgate/jump-travel tests for the new sprite path, fallback renderer, and comm-window flow
This commit is contained in:
Brian Fertig 2026-09-06 20:26:06 -06:00
parent 0950b143f5
commit d78b7ad505
12 changed files with 710 additions and 78 deletions

BIN
assets/images/jumpgate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
assets/images/jumpgate.psd Normal file

Binary file not shown.

Binary file not shown.

View File

@ -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",

35
dev/gate-comms.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<!-- Headless probe page for the GATE comm-window test (dev only —
not part of the game build). See dev/gate-comms.mjs. -->
<html>
<head>
<meta charset="utf-8" />
<base href="../" />
<title>orbit — gate comm-window test</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #04060d;
overflow: hidden;
}
#game {
display: grid;
place-items: center;
}
</style>
</head>
<body>
<div id="game"></div>
<script>
window.__BOOT_ERRORS__ = [];
window.addEventListener('error', (e) =>
window.__BOOT_ERRORS__.push(String(e.message) + ' @ ' + (e.filename || '') + ':' + e.lineno));
window.addEventListener('unhandledrejection', (e) =>
window.__BOOT_ERRORS__.push('reject: ' + ((e.reason && e.reason.stack) || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
<script type="module" src="dev/gate-comms.mjs"></script>
</body>
</html>

339
dev/gate-comms.mjs Normal file
View File

@ -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()));

View File

@ -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

View File

@ -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 records 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);

View File

@ -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
<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).
`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).

View File

@ -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) --

View File

@ -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;

View File

@ -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. Topbottom (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;