orbit/js/entities/Station.js

288 lines
11 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 { Rng } from '../utils/Rng.js';
import { Planet } from './Planet.js';
/**
* A SPACE STATION — a free-space settlement (SystemGenerator:
* `anchor.type === 'space'`, kinds `deepSpaceStation` / `waypoint`)
* rendered as a world object:
*
* DEEP-SPACE STATION — 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.
*
* WAYPOINT — a small nav beacon: a base, a mast, a breathing light
* (procedural — beacons have no sheet 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), 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). Deep-space
* stations are also 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).
*/
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`, this.kind === 'waypoint' ? 46 : 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 sheet art:
// waypoints, or a pool with no frames yet).
this.sheetFrame = null;
if (this.kind === 'deepSpaceStation') {
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 (deep-space stations) — or the procedural hardware
* (waypoints, and the deep-space fallback when the sheet is missing).
*/
build() {
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);
if (this.kind === 'waypoint') {
// A small nav beacon: a base, a mast, a breathing light.
g.fillStyle(0x4a3a2c, 1);
g.fillRect(-S * 0.22, S * 0.28, S * 0.44, S * 0.16); // the base
g.fillStyle(0x57493a, 1);
g.fillRect(-S * 0.06, -S * 0.45, S * 0.12, S * 0.78); // the mast
g.fillStyle(0x6e3a1f, 0.5); // a rust band
g.fillRect(-S * 0.06, 0, S * 0.12, S * 0.18);
g.lineStyle(1.5, 0x2c2118, 1);
g.strokeCircle(0, -S * 0.5, S * 0.13);
g.fillStyle(0x3a2f26, 1);
g.fillCircle(0, -S * 0.5, S * 0.1);
} else {
// ---- 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 (both kinds) — the thing that says "occupied".
const hy = this.kind === 'waypoint' ? -S * 0.5 : -S * 0.26;
const hue = this.kind === 'waypoint' ? 0xa8ffc4 : 0xff7a5c;
const glow = this.kind === 'waypoint' ? 0x7dffb0 : 0xff5a3c;
this.beaconGlow = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.24 : 7, glow, 0.2);
this.beacon = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.07 : 2.6, hue, 1);
this.add([this.beaconGlow, this.beacon]);
}
/** The ring turns; the beacon breathes (the procedural build — the
* variant art is static). (GameScene.update drives this.) */
update(time) {
const t = time / 1000;
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);
}
}
// ---- 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();
}
}