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:
parent
0950b143f5
commit
d78b7ad505
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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()));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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) --
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue