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: 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. * * 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) — * standing at a gate is where the jump happens; that arrives with the * jump mechanic. * * ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false * — the gate is inert until the player activates it (future mechanic), * 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). * * 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. * * update(time) breathes the field and drifts the ring — driven by * GameScene.update, like the stations and asteroid clusters. */ export class JumpGate extends Phaser.GameObjects.Container { /** * @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.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 }; } /** Procedural build — the art faces +x (toward the destination). */ build() { 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); } /** The ring drifts; the field breathes. (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. 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); } // ---- 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(); } }