orbit/js/entities/JumpGate.js

224 lines
8.3 KiB
JavaScript

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