import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; 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 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 * discovery toast, at the scale of its keepout, in its own cyan). * It is NOT a comms target (GameScene.worldObjectAt excludes it — * gateAt() is its own hit test): a click on an ACTIVE gate is the * JUMP itself (GameScene.jumpThroughGate — the run re-stages for the * connected system, arriving at this gate's twin); a click on a * DORMANT one is the ordinary fly-here (the ship drifts to the rim). * * ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false * — the gate is inert until the 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: the body is still drawn at full alpha, but the swirl is * hidden (its absence is the "this one is live" tell). * * The collision API is circular and rotation-blind. * * 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, * x, y, size, rotation } * @param {object} [o] { depth } */ constructor(scene, gate, o = {}) { super(scene, gate.x, gate.y); this.scene.add.existing(this); // v4: new'd containers are not on the display list this.gate = gate; // Inert until activated (data/gates.json → ACTIVITY) — see the header. this.active = gate?.active === true; this.discoveryId = gate.id; this.discoveryName = gate.name; this.size = gate.size ?? config.get('gates.size', 96); this.radius = this.size; // the keepout circle's radius this.bound = this.size; // the discovery radius (the compass/toast scale) 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 this.build(); } _theme() { const hex = config.get('gates.theme.color', '#5fd4ff'); let v = parseInt(hex.replace('#', ''), 16); if (Number.isNaN(v)) v = 0x5fd4ff; return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 }; } 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; const rgb = (mulR = 1, mulG = 1, mulB = 1) => (Math.min(255, Math.round(r * mulR)) << 16) | (Math.min(255, Math.round(g * mulG)) << 8) | Math.min(255, Math.round(b * mulB)); const gph = scene.add.graphics(); this.add(gph); const RING = S * 0.8; const FIELD = S * 0.55; // The outer ring — its own container so update() can drift it. this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0); const rg = scene.add.graphics(); rg.lineStyle(S * 0.055, rgb(0.55, 0.6, 0.7), 0.95); rg.strokeCircle(0, 0, RING); rg.lineStyle(2, rgb(0.75, 0.8, 0.9), 0.8); rg.strokeCircle(0, 0, RING + S * 0.045); // Six hub marks on the ring (the drift makes them read as motion). for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; rg.fillStyle(rgb(0.8, 0.85, 1), 1); rg.fillCircle(Math.cos(a) * RING, Math.sin(a) * RING, S * 0.035); } this.ringBody.add(rg); this.add(this.ringBody); // The pylons — one on each side, off the axis, weathered frames. for (const dir of [-1, 1]) { const py = dir * (RING + S * 0.02); gph.fillStyle(0x57493a, 1); gph.fillCircle(0, py, S * 0.085); gph.lineStyle(2, 0x2c2118, 1); gph.strokeCircle(0, py, S * 0.085); gph.fillStyle(0x3a2f26, 1); gph.fillCircle(0, py, S * 0.045); gph.fillStyle(rgb(0.9, 0.95, 1), 0.9); gph.fillCircle(S * 0.05, py, S * 0.018); // the pylon light } // The mouth — a bright arc on the destination side (+x), so the gate // reads as an opening that way. (Polyline arc — plain Graphics API.) const arc = (radius, a0, a1, lw, alpha, color) => { gph.lineStyle(lw, color, alpha); const SEG = 14; let px = Math.cos(a0) * radius; let py = Math.sin(a0) * radius; for (let i = 1; i <= SEG; i++) { const a = a0 + ((a1 - a0) * i) / SEG; const nx = Math.cos(a) * radius; const ny = Math.sin(a) * radius; gph.lineBetween(px, py, nx, ny); px = nx; py = ny; } }; arc(FIELD * 0.82, -0.85, 0.85, S * 0.09, 0.95, rgb(1, 1, 1.05)); arc(FIELD * 0.82, -0.5, 0.5, S * 0.035, 1, rgb(1.4, 1.6, 2)); // The energy field (the breathing discs — alpha driven in update()). this.field = scene.add.circle(0, 0, FIELD, rgb(0.5, 0.7, 1), 0.16); this.fieldCore = scene.add.circle(0, 0, FIELD * 0.5, rgb(0.75, 0.9, 1), 0.22); this.add([this.field, this.fieldCore]); // The chevrons — pointing at the destination star (+x), over the field. const top = scene.add.graphics(); const chev = (x0, w, lw, alpha) => { top.lineStyle(lw, rgb(1.1, 1.25, 1.6), alpha); top.lineBetween(x0 - w, -w * 0.75, x0, 0); top.lineBetween(x0, 0, x0 - w, w * 0.75); }; chev(S * 0.24, S * 0.2, S * 0.05, 0.95); chev(S * 0.48, S * 0.2, S * 0.04, 0.6); this.add(top); // DORMANT look — inactive gates are dim (the pulse below is the live tell). if (!this.active) this.setAlpha(0.4); } /** * Flip to ACTIVE — the SYSTEM category's jumpgate research completes * (js/research/SystemCategory.js; the scene's seam wakes every gate of * the system plus the linked systems' return gates). The dormant * dimming lifts and update() starts the field breathing. (Per * data/gates.json → ACTIVITY the anchor tether comes from the scene — * this entity only carries the live look.) */ activate() { if (this.active) return; this.active = true; 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 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 shows no swirl and keeps a still, faint field. if (!this.active) return; 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) -- minCenterDistance(shipRadius = 0) { return this.radius + this.clearance + shipRadius; } /** A point `gap` past the surface toward (wx, wy) — the ship's approach stop. */ edgePoint(angle, gap, shipRadius = 0) { const d = this.radius + gap + shipRadius; return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d, }; } /** Clamp a target to at least clearance outside the surface. */ aimPoint(wx, wy, shipRadius = 0) { const minDist = this.minCenterDistance(shipRadius); const dx = wx - this.x; const dy = wy - this.y; const dist = Math.hypot(dx, dy); if (dist >= minDist) return { x: wx, y: wy }; if (dist === 0) return { x: this.x + minDist, y: this.y }; return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist, }; } /** Hard constraint — push the ship outside the keepout circle. */ constrainShip(ship, shipRadius = 0) { const body = ship.body; const r = Planet.resolve( this.x, this.y, this.minCenterDistance(shipRadius), ship.x, ship.y, body.velocity.x, body.velocity.y, body.acceleration ? body.acceleration.x : 0, body.acceleration ? body.acceleration.y : 0, ); ship.x = r.x; ship.y = r.y; body.velocity.x = r.vx; body.velocity.y = r.vy; if (body.acceleration) { body.acceleration.x = r.ax; body.acceleration.y = r.ay; } } destroy() { // v4: Container no longer has removeChildren() — removeAll(true) is // the equivalent (detach the art and destroy it); v4's super.destroy() // would do the same for whatever was still attached. this.removeAll(true); super.destroy(); } }