288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
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();
|
||
}
|
||
}
|