orbit/js/entities/JumpGate.js

305 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. Returns true when it actually touched the ship (contact — see Planet.constrainShip). */
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,
);
const touched =
r.x !== ship.x || r.y !== ship.y ||
r.vx !== body.velocity.x || r.vy !== body.velocity.y ||
(body.acceleration &&
(r.ax !== body.acceleration.x || r.ay !== body.acceleration.y));
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;
}
return touched;
}
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();
}
}