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..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(); } }