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