224 lines
8.3 KiB
JavaScript
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();
|
|
}
|
|
}
|