diff --git a/assets/images/originals/ss-surface-01.png b/assets/images/originals/ss-surface-01.png new file mode 100644 index 0000000..a723fad Binary files /dev/null and b/assets/images/originals/ss-surface-01.png differ diff --git a/assets/images/originals/ss-surface-02.png b/assets/images/originals/ss-surface-02.png new file mode 100644 index 0000000..f1851a0 Binary files /dev/null and b/assets/images/originals/ss-surface-02.png differ diff --git a/assets/images/originals/ss-surface-03.png b/assets/images/originals/ss-surface-03.png new file mode 100644 index 0000000..489c4e6 Binary files /dev/null and b/assets/images/originals/ss-surface-03.png differ diff --git a/data/landing.json b/data/landing.json index ec338f2..24ee51c 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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 } ] } diff --git a/data/music.json b/data/music.json index c117be2..0139377 100644 --- a/data/music.json +++ b/data/music.json @@ -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": {} } diff --git a/data/stations.json b/data/stations.json index 43bc9b7..07a55b7 100644 --- a/data/stations.json +++ b/data/stations.json @@ -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..size = the station's keepout radius (px — the wings/ring extent); kinds..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..size = the station's keepout radius (px — the wings/ring extent); kinds..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..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": { diff --git a/dev/station-frames.test.mjs b/dev/station-frames.test.mjs new file mode 100644 index 0000000..dd1d6d5 --- /dev/null +++ b/dev/station-frames.test.mjs @@ -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); diff --git a/dev/station-shot.html b/dev/station-shot.html new file mode 100644 index 0000000..5c0c4f9 --- /dev/null +++ b/dev/station-shot.html @@ -0,0 +1,33 @@ + + + + + + Orbit — Station Variants Dev + + + + + + + +
+ + + + diff --git a/dev/station-shot.mjs b/dev/station-shot.mjs new file mode 100644 index 0000000..2d17a23 --- /dev/null +++ b/dev/station-shot.mjs @@ -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; +}; diff --git a/js/entities/Station.js b/js/entities/Station.js index d6b08da..f4e88b4 100644 --- a/js/entities/Station.js +++ b/js/entities/Station.js @@ -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..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) --------- diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index a1621b3..0fde093 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -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. */ diff --git a/js/galaxy/StationFrames.js b/js/galaxy/StationFrames.js new file mode 100644 index 0000000..f7b92bd --- /dev/null +++ b/js/galaxy/StationFrames.js @@ -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 }; +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 7e81f44..9db0c8a 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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): 1–maxGates, local (the diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 3adc34a..b99595b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); } diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index b1a47c2..f60d7a3 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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)) {