Add deep-space station variants with spritesheet art and landing support

- Introduce StationFrames.js galaxy-wide pass that spreads spacestations.png
  variant frames across the galaxy, avoiding collisions among neighbor stars
- Stamp settlement.stationFrame in SystemGenerator so content carries the
  assigned variant (lazy === eager)
- Render deep-space stations from the spritesheet when available; keep
  procedural fallback for waypoints and missing textures
- Extend comms target with isStation/frame and allow REQUEST LANDING on
  deep-space stations, passing station flag into SurfaceScene
- Add landing.stationVideos entries and music.stationFrames slots keyed by
  variant frame; surface/shop clips degrade gracefully until authored
- Add dev tooling: station-shot page/runner for world/panel/land/deck states
  and a Node test asserting variant spread beats naive random assignment
This commit is contained in:
Brian Fertig 2026-09-07 22:23:12 -06:00
parent 568b1feaf2
commit 1c5767b095
15 changed files with 755 additions and 42 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -1,8 +1,12 @@
{
"_comment": [
"Landing + surface videos, keyed by planets.png SHEET FRAME (same index the",
"planet sprite uses — frames 0..2 are the terran worlds, 3..5 the gas",
"giants; see data/planets.json → frames).",
"Landing + surface videos, keyed by the world's SHEET FRAME (same index the",
"sprite uses): videos = planets.png frames (0..2 terran, 3..5 gas giants,",
"6..8 rocky — data/planets.json → frames); stationVideos = spacestations.png",
"VARIANT frames (0..2 — the deep-space station types, data/stations.json →",
"variants; the same frame the station is drawn with and its comms label",
"reads, data/stations.json → typeLabels). See data/stations.json for the",
"station variants; waypoints are beacons, not a port (no clips).",
"",
"`land` — the full-screen one-shot clip played when the ship lands.",
"`surface` — the looping clip shown on the surface deck behind the action bar.",
@ -21,6 +25,13 @@
"A DOUBLE-CLICK (two quick presses) during the `land` or `takeoff`",
"clips skips the rest of the clip — the deck / the flight world comes",
"up immediately (no setting; the 350 ms window lives in SurfaceScene).",
"",
"Station surface/shop clips are authored later: the stationVideos entries",
"carry null `surface`/`shop` slots until then — the deck degrades (flat",
"deck, SHOP notes 'no shop') exactly like an unset planet slot. Land and",
"takeoff are the ss-land-0N / ss-takeoff-0N clips (variant 1 = frame 0,",
"…).",
"",
"Set `enabled` to false to disable landing entirely (REQUEST LANDING is",
"greyed out in comms, same as a reputation ban)."
],
@ -52,5 +63,11 @@
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
],
"stationVideos": [
{ "land": "ss-land-01.mp4", "surface": null, "takeoff": "ss-takeoff-01.mp4", "shop": null },
{ "land": "ss-land-02.mp4", "surface": null, "takeoff": "ss-takeoff-02.mp4", "shop": null },
{ "land": "ss-land-03.mp4", "surface": null, "takeoff": "ss-takeoff-03.mp4", "shop": null }
]
}

View File

@ -14,5 +14,7 @@
"3": "assets/music/gasgiant-02.mp3",
"4": "assets/music/gasgiant-02.mp3",
"5": "assets/music/gasgiant-02.mp3"
}
},
"_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.",
"stationFrames": {}
}

View File

@ -1,6 +1,15 @@
{
"_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (red — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s).",
"_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (red — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s, the procedural fallback only). texture/frameWidth/frameHeight = the station spritesheet (frame 0 = top-left); variants = the sheet frames a DEEP-SPACE STATION may wear — the galaxy-wide pass (js/galaxy/StationFrames.js) spreads them across the galaxy (a system avoids what its nearest stars already wear, like the planet frames — js/galaxy/PlanetFrames.js). typeLabels = the comms/HUD label a variant reads as (keyed by sheet frame — the fallback for an unlabeled frame is the kind's label, data/settlements.json → kinds.<kind>.label). The landing/surface/take-off/shop clips a variant plays come from data/landing.json → stationVideos (same sheet-frame key) — surface/shop are null until they are authored.",
"enabled": true,
"texture": "assets/images/spacestations.png",
"frameWidth": 256,
"frameHeight": 256,
"variants": [0, 1, 2],
"typeLabels": {
"0": "Colony Station",
"1": "Smuggler Outpost",
"2": "Research Station"
},
"shipClearance": 50,
"compassColor": "#ff4d5e",
"kinds": {

156
dev/station-frames.test.mjs Normal file
View File

@ -0,0 +1,156 @@
/**
* Station-variant-spread test (dev tool, run with Node no browser):
*
* node dev/station-frames.test.mjs
*
* Asserts the galaxy-wide deep-space-station variant pass
* (js/galaxy/StationFrames.js, wired in js/galaxy/Galaxy.js,
* data/stations.json variants):
* - every deep-space station carries a sheet frame inside the variant
* pool (settlement.stationFrame, stamped by the content generator);
* - the pass beats a naive random pick: the variant collision rate
* among each station's 8-nearest stars is LOWER than the same metric
* for an independent random-per-station assignment;
* - determinism: same seed identical variant map, different seed
* different assignment (spot check);
* - lazy === eager: the stamped settlement frames equal a fresh
* galaxy's pass output.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const SEED = 'station-frames-test-seed';
const g = Galaxy.create(SEED);
const POOL = Math.max(1, Math.floor(g.params.neighbors ?? 8));
const POOLV = (config.get('stations.variants') ?? []).filter((f) => Number.isInteger(f) && f >= 0);
check('data/stations.json → variants is a non-empty integer pool', POOLV.length >= 1, JSON.stringify(config.get('stations.variants')));
// The station-bearing systems and their stamped frame (via content).
const stations = new Map(); // id → { rec, frame, name }
for (const rec of g.records) {
const c = g.ensureContent(rec.id);
for (const s of c.settlements) {
if (s.kind === 'deepSpaceStation') {
stations.set(rec.id, { rec, frame: s.stationFrame, name: s.name });
}
}
}
console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space stations, pool [${POOLV}])`);
// ----------------------------------------------------------------------
// 1. Frames are present and in-pool
// ----------------------------------------------------------------------
{
let inPool = true;
let why = '';
for (const [id, st] of stations) {
if (typeof st.frame !== 'number' || !Number.isInteger(st.frame) || !POOLV.includes(st.frame)) {
inPool = false;
if (!why) why = `${id}: frame ${st.frame} ∉ [${POOLV}]`;
}
}
check('every deep-space station wears a pool variant (settlement.stationFrame)', inPool, why);
check('the galaxy pass covers exactly the station-bearing systems',
stations.size === g.stationFrames.size,
`${stations.size} settlements vs ${g.stationFrames.size} pass entries`);
}
// ----------------------------------------------------------------------
// 2. The pass beats a naive random assignment
// ----------------------------------------------------------------------
{
// The pass's variant collision rate: for each station, count its
// POOL-nearest stars whose station wears the SAME frame (directed).
const collisionRate = (frameOf) => {
let coll = 0;
let checks = 0;
for (const [id, st] of stations) {
for (const nb of g.neighborsOf(id, POOL)) {
checks++;
const nbFrame = frameOf(nb.id);
if (nbFrame !== null && nbFrame === st.frame) coll++;
}
}
return { coll, checks, rate: checks === 0 ? 0 : coll / checks };
};
const passed = collisionRate((id) => stations.get(id)?.frame ?? null);
// Naive: independent random pool picks (seeded, per station).
const naive = new Map();
for (const id of stations.keys()) {
const f = POOLV.length === 1
? POOLV[0]
: POOLV[Math.floor(Rng.derive(SEED, 'naive-station', id).next() * POOLV.length)];
naive.set(id, f);
}
const na = collisionRate((id) => naive.get(id) ?? null);
check(
`variant collisions vs the ${POOL}-nearest pool: pass ${(passed.rate * 100).toFixed(1)}% < naive ${(na.rate * 100).toFixed(1)}%`,
passed.rate < na.rate,
`${passed.coll}/${passed.checks} vs ${na.coll}/${na.checks} (station density sets the floor — most stars carry none)`,
);
// The spread should also produce ALL variants across the galaxy
// (with 3 stations' worth of pool breadth and this many stations,
// pigeonhole is generous) — the point is variety, not just local spread.
const used = new Set(stations.size === 0 ? [] : [...stations.values()].map((s) => s.frame));
check('the galaxy wears more than one station type (the pool shows variety)',
used.size >= Math.min(2, POOLV.length, stations.size),
`used [${[...used].sort()}] of pool [${POOLV}]`);
}
// ----------------------------------------------------------------------
// 3. Determinism + lazy === eager
// ----------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
const same = JSON.stringify([...g.stationFrames.entries()].sort()) ===
JSON.stringify([...g2.stationFrames.entries()].sort());
check('same seed ⇒ same station-variant assignment galaxy-wide', same);
// Content stamp === galaxy pass (the stamping contract).
let stamped = true;
let why = '';
for (const [id, st] of stations) {
if (g2.stationFrames.get(id) !== st.frame) {
stamped = false;
if (!why) why = `${id}: content ${st.frame} vs pass ${g2.stationFrames.get(id)}`;
}
}
check('content settlement frames === the galaxy pass (lazy === eager stamping)', stamped, why);
const g3 = Galaxy.create('station-frames-OTHER');
const diff = JSON.stringify([...g.stationFrames.entries()].sort()) !==
JSON.stringify([...g3.stationFrames.entries()].sort());
check('different seed ⇒ different assignment (spot check)', diff || g3.records[0].id !== g.records[0].id);
}
console.log(failures === 0 ? '\nAll station-variant tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

33
dev/station-shot.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Station Variants Dev</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/theme.css" />
<style>
html,
body {
margin: 0;
height: 100%;
background: #04060d;
overflow: hidden;
}
#game {
display: grid;
place-items: center;
}
</style>
</head>
<body>
<div id="game"></div>
<script src="lib/phaser.min.js"></script>
<script type="module" src="dev/station-shot.mjs"></script>
</body>
</html>

220
dev/station-shot.mjs Normal file
View File

@ -0,0 +1,220 @@
/**
* Dev: the deep-space station VARIANTS (spacestations.png, frames 0..2)
* end-to-end the sprite in-world, the comms panel (the variant label,
* REQUEST LANDING live), and the station landing flow (the ss-land /
* ss-takeoff clips, the silent flat deck surface/shop clips are
* authored later):
*
* world a deep-space station in its system, framed (the variant art
* the spread pass assigned it settlement.stationFrame)
* panel the comms panel on that station (variant label + REQUEST
* LANDING enabled)
* land startLanding through the scene's real path (SurfaceScene up,
* the station's land clip queued under the station key)
* deck the surface deck on a station (no surface clip the flat
* deck; SHOP is a "no shop" note)
*
* The starting system (VESTRA Oribreicor) holds a deep-space station
* ("Terminal 11", variant 1 the smuggler's bar), so all four states are
* one system.
*
* node dev/server.mjs 8080
* node dev/wdshot.mjs http://localhost:8080/dev/station-shot.html /tmp/station-world.png \
* 'return await window.__STATION_SHOT__("world");' 40000 5000
* node dev/wdshot.mjs http://localhost:8080/dev/station-shot.html /tmp/station-panel.png \
* 'return await window.__STATION_SHOT__("panel");' 40000 5000
* node dev/wdshot.mjs http://localhost:8080/dev/station-shot.html /tmp/station-land.png \
* 'return await window.__STATION_SHOT__("land");' 40000 5000
* node dev/wdshot.mjs http://localhost:8080/dev/station-shot.html /tmp/station-deck.png \
* 'return await window.__STATION_SHOT__("deck");' 40000 5000
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load();
config.init(data);
// The game measures its UI text in the web font — wait for it BEFORE the
// scenes build any text (same contract as js/main.js).
const __fonts = globalThis.document?.fonts;
if (__fonts && typeof __fonts.load === 'function') {
const fams = ['header', 'body']
.map((k) => config.get(`theme.fonts.${k}.family`))
.filter((f) => typeof f === 'string' && f.length > 0);
if (fams.length > 0) {
await Promise.race([
Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))),
new Promise((r) => setTimeout(r, 2000)),
]);
}
}
// The deterministic starting system (VESTRA — Oribreicor holds a
// deep-space station — variant 1, "Terminal 11"). A different starting
// system (and thus station variant) can be pinned per run:
// http://localhost:8080/dev/station-shot.html?seed=probe-0 (variant 0)
// http://localhost:8080/dev/station-shot.html?seed=probe-25 (variant 2)
globalThis.__ORBIT_DEV_SEED =
(globalThis.location?.search && new URLSearchParams(globalThis.location.search).get('seed')) || 'VESTRA';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
/**
* Drive the station-variant states through the scene's real paths and
* report a result object for dev/wdshot.mjs to print.
*/
window.__STATION_SHOT__ = async (which = 'world') => {
const g = window.game;
const s = g.scene.getScene('GameScene');
if (!s || !s.systemStations) return { ready: false, note: s ? 'scene not ready (yet?)' : 'no scene' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- The station (the system's deep-space station) -------------------
const st = s.systemStations.find((o) => o.kind === 'deepSpaceStation') ?? s.systemStations[0] ?? null;
if (!st) return { ready: false, note: 'no free-space station in this system' };
// ---- Camera: frame the station at screen (640, 430) ------------------
const sx = 640;
const sy = 430;
s.ship.stop();
s.hideHint?.();
const cam = s.cameras.main;
if (which === 'world') {
// Park the ship just off the rim (it was flying there), lower-right.
const rr = st.radius + 70;
s.ship.x = st.x + rr * Math.cos(0.5);
s.ship.y = st.y + rr * Math.sin(0.5);
cam.scrollX = st.x - sx;
cam.scrollY = st.y - sy;
s.cameraFollowShip = false;
for (let i = 0; i < 200; i++) g.loop.step(performance.now());
cam.scrollX = st.x - sx;
cam.scrollY = st.y - sy;
const r = {
ready: true,
which,
station: st.discoveryName,
kind: st.kind,
size: st.size,
sheetFrame: st.sheetFrame,
variantLabel: config.get(`stations.typeLabels.${st.sheetFrame}`),
childType: st.list?.[0]?.type ?? st.list?.[0]?.constructor?.name,
childTexture: st.list?.[0]?.texture?.key ?? null,
childScale: st.list?.[0]?.scale,
comms: s.commsTargetFor(st),
};
window.__STATION_SHOT__RESULT__ = r;
return r;
}
if (which === 'panel') {
s.ship.x = st.x + (st.radius + 70) * Math.cos(0.5);
s.ship.y = st.y + (st.radius + 70) * Math.sin(0.5);
cam.scrollX = st.x - sx;
cam.scrollY = st.y - sy;
s.openCommsPanel(st, { worldX: st.x, worldY: st.y, x: sx, y: sy });
s.cameraFollowShip = false;
const done = () => s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null && s.commsPanel.repRevealT0 === null;
for (let i = 0; i < 6000 && !done(); i++) g.loop.step(performance.now());
cam.scrollX = st.x - sx;
cam.scrollY = st.y - sy;
const cp = s.commsPanel;
const r = {
ready: true,
which,
station: st.discoveryName,
sheetFrame: st.sheetFrame,
name: cp.nameText.text,
kindLabel: cp.settledText?.text ?? null,
settled: cp.settled,
btn1: cp.btn1.text.text,
btn1Disabled: cp.btn1.disabled,
btn2: cp.btn2.text.text,
onScreen: cp.rect
? (() => {
const lx = cp.rect.x - cam.scrollX;
const ly = cp.rect.y - cam.scrollY;
return lx >= -1 && ly >= -1 && lx + cp.rect.w <= s.scale.width + 1 && ly + cp.rect.h <= s.scale.height + 1;
})()
: false,
};
window.__STATION_SHOT__RESULT__ = r;
return r;
}
if (which === 'land') {
// The real entry point — the comms panel's own REQUEST LANDING path.
const target = s.commsTargetFor(st);
if (!target?.isStation || !target?.canLand) {
const r = { ready: false, which, target };
window.__STATION_SHOT__RESULT__ = r;
return r;
}
s.startLanding(target);
await sleep(300);
const ss = g.scene.getScene('SurfaceScene');
const landKey = `__surf_st_land_${ss.planetFrame}`;
const r = {
ready: true,
which,
station: st.discoveryName,
isStation: ss.isStation,
planetFrame: ss.planetFrame,
phase: ss.phase,
musicSpec: ss.musicSpec,
landKey,
landCached: ss.cache.video.has(landKey),
landVideo: ss.landVideo !== null,
gsSleeping: s.sys.isSleeping(),
};
window.__STATION_SHOT__RESULT__ = r;
return r;
}
if (which === 'deck') {
// Land, then force the deck (the station has NO surface clip — the
// flat deck is the station's surface state today).
const target = s.commsTargetFor(st);
if (!target?.isStation || !target?.canLand) {
const r = { ready: false, which, target };
window.__STATION_SHOT__RESULT__ = r;
return r;
}
s.startLanding(target);
await sleep(300);
const ss = g.scene.getScene('SurfaceScene');
ss.startSurface();
await sleep(300);
const ids = (ss.actionBar?.slots ?? []).map((slot) => slot.id);
const loopKey = `__surf_st_loop_${ss.planetFrame}`;
const r = {
ready: true,
which,
station: st.discoveryName,
isStation: ss.isStation,
planetFrame: ss.planetFrame,
phase: ss.phase,
deckSlots: ids,
loopKey,
loopCached: ss.cache.video.has(loopKey),
surfaceVideo: ss.surfaceVideo !== null,
hudName: ss.hudName?.text ?? null,
hudLine: ss.hudLine?.text ?? null,
};
window.__STATION_SHOT__RESULT__ = r;
return r;
}
const r = { ready: false, which, note: 'unknown mode' };
window.__STATION_SHOT__RESULT__ = r;
return r;
};

View File

@ -1,27 +1,54 @@
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: a weathered hub + slowly turning ring +
* solar wings (deep-space station) or a small nav beacon (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).
* 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).
*
* update(time) turns the ring and breathes the beacon driven by
* GameScene.update, like the asteroid clusters.
* 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 }
* 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 = {}) {
@ -39,13 +66,57 @@ export class Station extends Phaser.GameObjects.Container {
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();
}
/** Procedural build — the same worn hardware every game. */
/**
* 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();
@ -131,13 +202,16 @@ export class Station extends Phaser.GameObjects.Container {
this.add([this.beaconGlow, this.beacon]);
}
/** The ring turns; the beacon breathes. (GameScene.update drives this.) */
/** 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;
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);
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) ---------

View File

@ -3,6 +3,7 @@ import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { buildJumpNetwork } from './JumpNetwork.js';
import { assignPlanetFrames } from './PlanetFrames.js';
import { assignStationFrames } from './StationFrames.js';
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
const TAU = Math.PI * 2;
@ -61,6 +62,7 @@ export class Galaxy {
this.homeSystemId = null;
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
this.homeWorldFrame = null; // the starting system's home world frame
this.stationFrames = new Map(); // id → frame — the station-variant spread pass
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
}
@ -236,6 +238,22 @@ export class Galaxy {
});
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
this.homeWorldFrame = homeFrame;
// STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's
// deep-space station wears one of the spacestations.png variants
// (data/stations.json → variants) — the frame avoids what the
// NEAREST stars already wear, so the same station type is spread
// across the galaxy instead of clustering in one region. Fixed
// roster order ⇒ visit-order independent; read back by the content
// generator (settlement.stationFrame) — lazy === eager preserved.
const { frames: stationFrames } = assignStationFrames({
seed: this.seed,
records,
homeId: this.currentSystemId,
params: this.params,
neighborsOf: (id) => this.neighborsOf(id, pool8),
});
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
}
/** Center-weighted radius sample in [0,1]: core bulge + disk. */

View File

@ -0,0 +1,96 @@
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
/**
* STATION VARIANT SPREAD the galaxy-wide deep-space-station frame
* assignment (the sibling of the planet frame pass, PlanetFrames.js).
*
* data/stations.json variants lists the spacestations.png sheet frames
* a DEEP-SPACE STATION may wear (today 0..2 the first three frames of
* the sheet; add a line when the art lands). Picking a random frame per
* system would let neighboring stars wear the same face; this pass
* spreads the variants instead: when a system's deep-space station needs
* a frame, it AVOIDS the frames its NEAREST stars already wear (the
* galaxy's neighbor pool, data/galaxy.json neighbors), so the same
* station type reappears only far away.
*
* Only DEEP-SPACE STATIONS take a variant a system holds at most one
* (rollSystemComposition rolls a boolean), and waypoints keep their
* beacon look (no sheet art for them). The landing handoff reads the
* same frame (settlement.stationFrame data/landing.json
* stationVideos), so a station always lands with the clip matching the
* art it is drawn with.
*
* Why the pass is order-independent (determinism): systems are visited
* in a FIXED order sorted by (x, y), a pure function of the seeded
* roster and each system only counts frames ALREADY assigned to its
* neighbors. Nothing depends on generation timing or visit order, so
* lazy (on-arrival) content generation and eager generateAll stamp the
* same frames. Ties are broken by a derived, per-pick Rng (seeded).
*
* Shared rolls: the pass re-derives each system's composition via the
* same exported roll and forks as the content generator
* (SystemGenerator.rollSystemComposition) guaranteed to agree, since
* the forks are pure functions of (seed, id, type, density).
*
* Cost: one pool scan per station-bearing system trivial at this
* galaxy size (data/galaxy.json systemCount), computed once at galaxy
* build time (js/galaxy/Galaxy.js).
*/
export function assignStationFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) {
const defs = typeDefs ?? config.get('systems.types', {});
const shim = { params: params ?? {} }; // settlementDensity's shape
// The variant pool (data/stations.json → variants). An empty/missing
// pool means "no variants yet" — nothing is stamped and the renderer
// keeps its procedural station.
const raw = config.get('stations.variants');
const pool = Array.isArray(raw)
? raw.filter((f) => Number.isInteger(f) && f >= 0)
: [];
const frames = new Map(); // id → frame (only station-bearing systems)
if (pool.length === 0) return { frames };
const assigned = new Map(); // id → frame (assigned so far)
// FIXED spatial order (x, then y): a pure function of the seeded
// roster, so the assignment never depends on generation timing or
// visit order (the planet pass's contract).
const ordered = records.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
for (const rec of ordered) {
const isHome = rec.id === homeId;
const attr = defs[rec.type]?.attributes ?? {};
const density = settlementDensity(shim, rec);
const { deepSpace } = rollSystemComposition(
seed, rec, isHome, attr.settlements ?? {}, density, attr,
);
if (!deepSpace) continue; // no deep-space station here — nothing to frame
const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? [];
// usage(frame) = how many ALREADY-ASSIGNED neighbor stars wear it
// (the spread objective — the same lexicographic pick as the planet
// pass, minus the "own" axis: a system never wears two stations).
const usage = (f) => {
let nb = 0;
for (const n of neighbors) {
if (assigned.get(n.id) === f) nb++;
}
return nb;
};
// The least-used face among the neighbors; ties broken by a
// deterministic derived pick (seed, id).
let best = Infinity;
for (const f of pool) best = Math.min(best, usage(f));
const tied = pool.filter((f) => usage(f) === best);
const frame = tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id).pick(tied);
frames.set(rec.id, frame);
assigned.set(rec.id, frame);
}
return { frames };
}

View File

@ -93,6 +93,14 @@ const DEG = Math.PI / 180;
* roster order visit-order independent) and stamps `planet.frame` /
* `content.homeFrame` here; the renderer prefers those over a random pick.
*
* STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for
* the deep-space stations each system's free-space station wears one of
* the spacestations.png variants (data/stations.json variants), chosen
* to avoid what the NEAREST stars already wear. The pass stamps
* `settlement.stationFrame` here (the landing handoff reads the same
* frame data/landing.json stationVideos); waypoints keep their beacon
* look and take no frame.
*
* Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is
* built from the system id + the object's position in its generated list:
@ -206,6 +214,18 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
isHome,
});
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
// spread pass): stamp the assigned spacestations.png frame on this
// system's deep-space station, so the same station type is spread
// across the galaxy (the renderer prefers settlement.stationFrame over
// a fallback pick; the landing handoff reads the same frame).
const stationFrame = galaxy?.stationFrames?.get(record.id);
if (typeof stationFrame === 'number') {
for (const s of settlements) {
if (s.kind === 'deepSpaceStation') s.stationFrame = stationFrame;
}
}
// --- Jump gate targets (the galaxy's gate network) --------------------
// The other stars this system's gates jump to (Galaxy.jumpNetwork —
// data/gates.json, js/galaxy/JumpNetwork.js): 1maxGates, local (the

View File

@ -141,6 +141,22 @@ export class GameScene extends Phaser.Scene {
);
}
// The space-station spritesheet (data/stations.json → texture): the
// deep-space station variants (variants 0..2 today — the first three
// 256×256 frames); waypoints keep their procedural beacon. Station
// falls back to its procedural hardware if the sheet is missing.
const stationTexture = config.get('stations.texture', '');
if (stationTexture && config.get('stations.enabled', true) !== false) {
this.load.spritesheet(
Station.TEXTURE_KEY,
stationTexture,
{
frameWidth: config.get('stations.frameWidth', 256),
frameHeight: config.get('stations.frameHeight', 256),
},
);
}
// The jump gate spritesheet (data/gates.json → texture): frame 0 =
// the gate body, frame 1 = the active swirl. If it isn't configured
// or the file is missing, JumpGate falls back to its built-in
@ -2026,21 +2042,40 @@ export class GameScene extends Phaser.Scene {
* - reputation the standing on that key (neutral 0 when none)
* - canLand standing > 4 (the panel grays the button at 4)
* - kindLabel a short flavor line under the name
* - isPlanet / isStation / frame the landing handoff (startLanding
* SurfaceScene): a PLANTED planet or a deep-space station
* (both wear a spritesheet frame the planet's class
* frame / the station's variant frame and land with
* the matching clips: data/landing.json videos /
* stationVideos). Waypoints are comms, not a port.
*/
commsTargetFor(obj) {
let key = null;
let settled = false;
let kindLabel = '';
let isStation = false;
let frame = null;
if (obj === this.planet && this.isHomeSystem) {
// The home world: standing pinned at the scale's top.
key = HOME_KEY;
settled = true;
kindLabel = config.get('planets.homeTypeLabel', 'Home World');
frame = this.planet?.sheetFrame ?? 0;
} else if (obj.settlement) {
// A free-space station — settled by definition; key = its id.
key = obj.settlement.id;
settled = true;
kindLabel = config.get(`settlements.kinds.${obj.kind}.label`, 'Station');
// Deep-space stations wear a spacestations.png variant (the
// galaxy-wide spread pass — js/galaxy/StationFrames.js) and label
// themselves by it (data/stations.json → typeLabels — the fallback
// is the kind's label). Waypoints are a nav beacon: comms, not a
// port, and no sheet frame.
isStation = obj.kind === 'deepSpaceStation';
const variantLabel = typeof obj.sheetFrame === 'number'
? config.get(`stations.typeLabels.${obj.sheetFrame}`)
: null;
kindLabel = variantLabel || config.get(`settlements.kinds.${obj.kind}.label`, 'Station');
frame = isStation ? (obj.sheetFrame ?? 0) : null;
} else {
// A system planet: settled when a settlement is anchored to it
// (anchor.type 'planet', anchor.ordinal = the planet's ordinal).
@ -2055,6 +2090,7 @@ export class GameScene extends Phaser.Scene {
key = anchored[0].id;
}
}
frame = obj.sheetFrame ?? 0;
}
const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0;
const isPlanet = !obj.settlement; // home world + system planets vs free-space stations
@ -2064,10 +2100,15 @@ export class GameScene extends Phaser.Scene {
settled,
key,
reputation: rep,
canLand: settled && rep > -4 && landingOn, // panel grays the button otherwise
// Landing = the surface sequence: a PLANTED world (population > 0)
// or a deep-space station (always settled) with enough goodwill.
// Waypoints are not a port (isStation stays false), and the home
// world's button is hidden as a UI fact (the comms panel).
canLand: settled && rep > -4 && landingOn && (isPlanet || isStation),
kindLabel,
isPlanet,
frame: isPlanet ? (obj.sheetFrame ?? 0) : null, // planets.png frame → landing.json videos
isStation,
frame, // planets.png / spacestations.png frame → landing.json videos
};
}
@ -2130,12 +2171,12 @@ export class GameScene extends Phaser.Scene {
if (gt) this.jumpThroughGate(gt);
return;
}
if ((id === 'request-landing' || id === 'land') && target.isPlanet) {
if ((id === 'request-landing' || id === 'land') && (target.isPlanet || target.isStation)) {
this.startLanding(target);
return;
}
// 'request-landing' on a space station — no surface on a station yet;
// the click is acknowledged on the console for now.
// 'request-landing' on a waypoint — a nav beacon is not a port; the
// click is acknowledged on the console for now.
console.info(`[orbit] comms: ${id}${target.name} (key: ${target.key ?? 'none'})`);
}
@ -2157,9 +2198,13 @@ export class GameScene extends Phaser.Scene {
// the world's CANONICAL spelling — resolve whatever casing the
// target arrived in (UI display paths uppercase) to that spelling
// before it crosses into the surface (js/utils/WorldNames.js).
// Station names are in the list too — a deep-space station is a
// world to the surface deck, so its name keys the deck's lookups
// just like a planet's.
const name = canonicalPlanetName(target.name, [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.systemStations ?? []).map((s) => s.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
]);
// A live scan would leave the camera roll/zoom mid-wobble — the scene
@ -2174,14 +2219,17 @@ export class GameScene extends Phaser.Scene {
this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame.
// sheetFrame is the raw planets.png index (`.frame` is the texture
// Frame OBJECT — Number() of it is NaN, and the video/music lookups
// need the number).
// sheetFrame is the raw planets.png / spacestations.png index
// (`.frame` is the texture Frame OBJECT — Number() of it is NaN, and
// the video/music lookups need the number). `station` tells the
// surface deck which clip set the frame indexes into (data/landing.json
// → videos for planets, stationVideos for station variants).
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(name),
station: target.isStation === true,
});
this.scene.sleep();
}

View File

@ -45,14 +45,19 @@ const DOUBLE_CLICK_MS = 350;
* with no `takeoff` clip leaves immediately. A double-click skips
* the rest of the clip the flight world wakes now.
*
* Video selection is by the planet's planets.png SHEET FRAME (the same
* index its sprite uses frames 0..2 the terran worlds, 3..5 the gas
* giants, per data/planets.json frames), so a planet always lands with
* the clip matching the art it is drawn with. Missing clips fall back to
* the existing stubs in data/landing.json; a broken/missing file skips
* straight to the surface instead of stranding the player. SHOP swaps the
* surface loop for the frame's `shop` clip (null on worlds without one
* a console note instead).
* Video selection is by the world's SHEET FRAME (the same index its
* sprite uses a planet's planets.png class frame: frames 0..2 the
* terran worlds, 3..5 the gas giants, per data/planets.json frames; a
* deep-space station's spacestations.png VARIANT frame: 0..2, per
* data/stations.json variants), so a world always lands with the clip
* matching the art it is drawn with. Planet entries live in
* data/landing.json videos; station variant entries in
* stationVideos (same sheet-frame key; surface/shop are null until
* authored). Missing clips fall back to the existing stubs in
* data/landing.json; a broken/missing file skips straight to the surface
* instead of stranding the player. SHOP swaps the surface loop for the
* frame's `shop` clip (null on worlds without one a console note
* instead).
*/
export class SurfaceScene extends Phaser.Scene {
constructor() {
@ -60,13 +65,21 @@ export class SurfaceScene extends Phaser.Scene {
}
init(data = {}) {
this.planetFrame = Number(data.frame ?? 0); // planets.png frame (video key)
this.planetFrame = Number(data.frame ?? 0); // the world's sheet frame (video key)
this.planetName = String(data.name ?? '');
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" / "Smuggler Outpost"
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
// A deep-space STATION surface? (GameScene.startLanding hands over
// `station: true` for a deepSpaceStation — waypoints never land.)
// Same deck, same flow; the frame indexes a different clip set
// (data/landing.json → stationVideos) and a different music key
// (data/music.json → stationFrames). Surface/shop clips are authored
// later — null slots degrade exactly like an unset planet slot.
this.isStation = data.station === true;
this.phase = 'landing'; // 'landing' → 'surface'
this.lastClickT = 0; // last pointerdown (performance.now(), ms) — the double-click skip
this.musicSpec = 'music.frames.' + this.planetFrame; // the surface loop (data/music.json)
this.musicSpec = (this.isStation ? 'music.stationFrames.' : 'music.frames.') + this.planetFrame; // the surface loop (data/music.json)
this.clipKeyPrefix = this.isStation ? '__surf_st_' : '__surf_'; // cache keys (planet/station frames overlap)
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
@ -93,11 +106,17 @@ export class SurfaceScene extends Phaser.Scene {
/** Pick this world's clips (data/landing.json) and queue them. */
preload() {
const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {};
this.landKey = `__surf_land_${this.planetFrame}`;
this.surfaceKey = `__surf_loop_${this.planetFrame}`;
this.takeoffKey = `__surf_takeoff_${this.planetFrame}`;
this.shopKey = `__surf_shop_${this.planetFrame}`;
// The entry keyed by this world's sheet frame: a planet's class frame
// (→ landing.videos) or a deep-space station's variant frame (→
// landing.stationVideos) — { land, surface, takeoff, shop }.
const videos = this.isStation
? (config.get('landing.stationVideos') ?? [])
: (config.get('landing.videos') ?? []);
const entry = videos[this.planetFrame] ?? {};
this.landKey = `${this.clipKeyPrefix}land_${this.planetFrame}`;
this.surfaceKey = `${this.clipKeyPrefix}loop_${this.planetFrame}`;
this.takeoffKey = `${this.clipKeyPrefix}takeoff_${this.planetFrame}`;
this.shopKey = `${this.clipKeyPrefix}shop_${this.planetFrame}`;
this.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null);
@ -107,7 +126,8 @@ export class SurfaceScene extends Phaser.Scene {
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
if (this.shopUrl) this.load.video(this.shopKey, this.shopUrl);
// The surface's music loop (data/music.json → frames[planets.png frame])
// The surface's music loop (data/music.json → frames[planets.png frame]
// for a planet, → stationFrames[spacestations.png frame] for a station)
// — the same key as the videos, so a world hums the track matching the
// art it is drawn with. A frame with no entry is silent.
if (config.get('music.enabled', true)) {