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:
parent
568b1feaf2
commit
1c5767b095
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 |
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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) ---------
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue