orbit/js/entities/Station.js

301 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 { toColor } from '../utils/Color.js';
import { RoutePulse } from './RoutePulse.js';
import { Rng } from '../utils/Rng.js';
import { Planet } from './Planet.js';
/**
* A SPACE STATION — a free-space settlement (SystemGenerator:
* `anchor.type === 'space'`, kind `deepSpaceStation` — the only
* free-space kind) rendered as a world object:
*
* A variant from the shared spritesheet (data/stations.json →
* texture: spacestations.png, 256×256 frames; `variants` lists the
* frames in use, today 0..2 — the first three frames). Which variant
* it wears comes from the galaxy-wide spread pass
* (js/galaxy/StationFrames.js, stamped as `settlement.stationFrame`):
* the frame avoids what the NEAREST stars already wear, so the same
* station type is spread across the galaxy instead of clustering. The
* SAME frame picks its landing/take-off clips (data/landing.json →
* stationVideos) — a station always lands with the clip matching the
* art it is drawn with.
*
* 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), and a comms target —
* click it and the comms panel opens there (the click is NOT a
* fly-here — the ship stays put; GameScene.openCommsPanel). A station
* is a PORT: REQUEST LANDING launches the surface sequence
* (GameScene.startLanding → SurfaceScene, the station's clips).
*
* The deep-space station's art fills its frame, so it is scaled to 2×size
* (size = the keepout RADIUS — data/stations.json → kinds.<kind>.size):
* the art's outer edge lands on the keepout disc and the ship hovers
* `shipClearance` px outside it. If the sheet didn't load the station
* falls back to worn procedural hardware (hub + turning ring + solar
* wings), exactly as before.
*
* update(time) turns the ring and breathes the beacon (procedural build
* only — driven by GameScene.update, like the asteroid clusters).
*
* ROUTE TARGET (data/gates.json → route.objectPulse): when the player's
* destination is THIS station (the route's final stop — the destination
* system's destination object, GameScene._routeDestinationEntity), the
* scene marks it (setRouteTarget) and update() animates the route pulse
* (js/entities/RoutePulse.js) around it — the SAME halo the route gate
* wears, SUBDUED (lower alpha, smaller reach, slower cycle), so the
* player sees "your destination is this station" the moment it is on
* screen (the compass's orange arrow is the off-screen half).
*/
export class Station extends Phaser.GameObjects.Container {
static TEXTURE_KEY = 'spacestations';
/**
* @param {Phaser.Scene} scene
* @param {object} settlement — the content record: { id, kind, name,
* anchor: { type: 'space' }, x, y, population, owner,
* stationFrame? } (stationFrame = the spacestations.png frame, from
* the galaxy-wide spread pass — js/galaxy/StationFrames.js)
* @param {object} [o] { depth }
*/
constructor(scene, settlement, o = {}) {
super(scene, settlement.x, settlement.y);
this.scene.add.existing(this); // v4: new'd containers are not on the display list
this.settlement = settlement;
this.kind = settlement.kind ?? 'deepSpaceStation';
// Discovery bookkeeping (id = the settlement id — the rep key).
this.discoveryId = settlement.id;
this.discoveryName = settlement.name;
this.size = config.get(`stations.kinds.${this.kind}.size`, 108);
this.radius = this.size; // the keepout circle's radius
this.bound = this.size; // the discovery radius (the compass/toast scale)
this.clearance = config.get('stations.shipClearance', 50);
this.ringSpeed = config.get(`stations.kinds.${this.kind}.ringSpeed`, 0.08);
this.ringBody = null;
this.sprite = null;
// The spacestations.png frame this station wears (null = no pool yet).
const v = settlement.stationFrame;
if (Number.isInteger(v) && v >= 0) {
this.sheetFrame = v; // the spread pass's pick (data/stations.json → variants)
} else {
// Fallback for content that predates the pass (dev tools, old
// fixtures): a deterministic pool pick keyed on the settlement's
// own (seed-derived) id — same galaxy ⇒ same face.
const pool = config.get('stations.variants');
const list = Array.isArray(pool) && pool.length > 0 ? pool : [0];
this.sheetFrame = Rng.derive('orbit-station-variant', settlement.id).pick(list) ?? 0;
}
this.setDepth(o.depth ?? 5);
this.build();
}
/**
* The variant art — or the procedural hardware fallback (sheet missing).
*/
build() {
// The route-target pulse (data/gates.json → route.objectPulse): when
// this station is the destination OBJECT, it wears the subdued route
// pulse (GameScene marks it via setRouteTarget). Added FIRST so it
// draws behind the station art (the ring sits outside the keepout
// disc anyway).
this.routePulse = new RoutePulse(this.scene, {
size: this.size,
cfg: config.section('gates.route.objectPulse', {}),
color: toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a),
x: 0, y: 0,
depth: this.depth,
});
this.add(this.routePulse.ring);
if (this.sheetFrame !== null && this.scene.textures.exists(Station.TEXTURE_KEY)) {
this._buildVariant();
return;
}
this._buildProcedural();
}
/**
* Sprite build — the station's spacestations.png frame, scaled to the
* keepout disc (the art fills its frame: scale = 2×size/frameWidth —
* the gate's rule, JumpGate._buildSprite). No ring/beacon to animate:
* the art already reads as occupied (neon, glass, wings).
*/
_buildVariant() {
const scene = this.scene;
const scale = (this.size * 2) / config.get('stations.frameWidth', 256);
const body = scene.add.image(0, 0, Station.TEXTURE_KEY, this.sheetFrame);
body.setScale(scale);
this.add(body);
this.sprite = body;
}
/** Procedural build — the same worn hardware every game. */
_buildProcedural() {
const scene = this.scene;
const S = this.size;
const g = scene.add.graphics();
this.add(g);
// ---- Deep-space station -----------------------------------------
const wingX0 = S * 0.24;
const wingX1 = S * 0.85;
const wingH = S * 0.18;
// Solar wings — weathered blue-gray panels in a rusty frame.
for (const dir of [-1, 1]) {
const x0 = dir === 1 ? wingX0 : -wingX1;
const w = wingX1 - wingX0;
g.lineStyle(3, 0x57493a, 1);
g.lineBetween(dir * S * 0.1, 0, dir * wingX0, 0); // the truss
g.fillStyle(0x3f5468, 1);
g.fillRect(x0, -wingH / 2, w, wingH);
g.fillStyle(0x6e3a1f, 0.35); // rust streak on the outer edge
g.fillRect(dir === 1 ? wingX1 - 6 : -wingX1, -wingH / 2, 6, wingH);
g.lineStyle(2, 0x22303e, 1);
g.strokeRect(x0, -wingH / 2, w, wingH);
g.lineStyle(1, 0x22303e, 0.9);
for (let i = 1; i <= 4; i++) {
const x = x0 + (w * i) / 5;
g.lineBetween(x, -wingH / 2, x, wingH / 2);
}
}
// The rotating ring (spokes + pods) — its own container so
// update() can spin it.
this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0);
const rg = scene.add.graphics();
const ringR = S * 0.48;
rg.lineStyle(S * 0.065, 0x6a5a48, 0.95);
rg.strokeCircle(0, 0, ringR);
rg.lineStyle(S * 0.03, 0x57493a, 1);
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2;
rg.lineBetween(
Math.cos(a) * S * 0.12,
Math.sin(a) * S * 0.12,
Math.cos(a) * ringR,
Math.sin(a) * ringR,
);
rg.fillStyle(0x8a6a48, 1);
rg.fillCircle(Math.cos(a) * ringR, Math.sin(a) * ringR, S * 0.037);
}
this.ringBody.add(rg);
this.add(this.ringBody);
// The hub (over the ring): a weathered core + rust.
const hubR = S * 0.205;
g.fillStyle(0x57493a, 1);
g.fillCircle(0, 0, hubR);
g.lineStyle(2, 0x2c2118, 1);
g.strokeCircle(0, 0, hubR);
g.fillStyle(0x3a2f26, 1);
g.fillCircle(0, 0, hubR * 0.55);
g.fillStyle(0x8a6a48, 1);
g.fillCircle(0, 0, hubR * 0.22);
g.fillStyle(0x6e3a1f, 0.5);
g.fillCircle(hubR * 0.5, -hubR * 0.35, 3);
g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5);
// The beacon light — the thing that says "occupied".
const hy = -S * 0.26;
this.beaconGlow = scene.add.circle(0, hy, 7, 0xff5a3c, 0.2);
this.beacon = scene.add.circle(0, hy, 2.6, 0xff7a5c, 1);
this.add([this.beaconGlow, this.beacon]);
}
/** The ring turns; the beacon breathes (the procedural build — the
* variant art is static); the route pulse (a no-op while unmarked).
* (GameScene.update drives this.) */
update(time) {
const t = time / 1000;
this.routePulse?.draw(t);
if (this.ringBody) this.ringBody.rotation = t * this.ringSpeed;
if (this.beaconGlow) {
const pulse = 0.5 + 0.5 * Math.sin(t * 2.6);
this.beaconGlow.setAlpha(0.12 + 0.3 * pulse);
this.beacon.setAlpha(0.6 + 0.4 * pulse);
}
}
/**
* ROUTE-TARGET state (GameScene.update, each frame): true while this
* station is the route's FINAL STOP — the destination object in the
* destination system. True shows the subdued pulse (update() animates
* it); false clears + hides it. A no-op when unchanged.
*/
setRouteTarget(on, color = null) {
this.routePulse?.set(on);
if (color != null) this.routePulse.color = color;
}
// ---- SOLID (the same contract as Planet / 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 (the ship can stop anywhere at the edge). */
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 (position +
* velocity + acceleration). 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();
}
}