299 lines
12 KiB
JavaScript
299 lines
12 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 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();
|
||
}
|
||
}
|