Add MAP console with system chart and fog-of-war
- Introduce MapWindow (js/ui/MapWindow.js): full-screen cartography overlay
with a left video feed, CURRENT SYSTEM / GALAXY(standby) tabs, a canvas
system chart (discovered planets/stations/gates/rock fields, tether union
boundary, fog-of-war dim outside the tether), and a discovery/resources
readout with segmented bars + legend. Clicking a discovered object plots
the course via autopilot.
- Add SystemChart.js (js/galaxy/SystemChart.js) with pure geometry/stats:
chartBounds, fitToRect, navDiscoveryStats, resourceStats — Node-testable
in dev/system-chart.test.mjs.
- Wire MAP into the deck (ActionBar + GameScene): new 'map' slot right of
SHIP, full-screen open/close contract, ESC handling, snapshot polling,
and video preload for assets/videos/map.mp4.
- Add data/map.json (manifest entry) driving all map colors, tabs, stats,
legend, sweep, glitch, hover, and click copy.
- Two-beat menu intro synced to the mainmenu track crescendo: beat 1 shows
console chrome + subtitle; beat 2 (revealDelay, default 3200 ms) reveals
title, bloom, buttons, seed decode, and a signature glitch.
- Fix rocky landing surface/takeoff assets in data/landing.json to point at
the new rocky-surface-01.mp4 / rocky-takeoff-01.mp4 clips.
- Dev tooling: dev/map-shot.{html,mjs} (headless screenshot driver),
dev/menu-intro-test.{html,mjs} (intro verification), and updated
research-builds.test.mjs for the new MAP slot ordering.
This commit is contained in:
parent
d78b7ad505
commit
be6e9fc558
Binary file not shown.
Binary file not shown.
|
|
@ -15,7 +15,7 @@
|
|||
{ "id": "research", "label": "Research", "accent": "#00e5ff" },
|
||||
{ "id": "scan", "label": "Scan", "accent": "#ffc94d" },
|
||||
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||
{ "id": null, "label": null },
|
||||
{ "id": "map", "label": "Map", "accent": "#c084fc" },
|
||||
{ "id": null, "label": null },
|
||||
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||
],
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" },
|
||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" },
|
||||
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
|
||||
{ "land": "rocky-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
|
||||
{ "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"scan.json",
|
||||
"signalCompass.json",
|
||||
"save.json",
|
||||
"mineralhud.json"
|
||||
"mineralhud.json",
|
||||
"map.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"_comment": "MAP — the deck's MAP button (to the right of SHIP): the full-screen cartography console, js/ui/MapWindow.js (depth 80, the ResearchWindow idiom). LEFT = the cartography feed (assets/videos/map.mp4, a muted 2:3 loop, the same recipe as the research archive feed). RIGHT = tabs on top (CURRENT SYSTEM is live; GALAXY is a reserved socket — pressing it toasts the lock line), the SYSTEM CHART in the middle, and the SYSTEM readout on the bottom (name + discovery % + resource %). THE CHART (the magic, drawn to a canvas texture): the whole solar system — every object's extent (planets, stations, gates AND asteroid clusters, discovered or not) + `bounds.padding` px of padding on each edge — fitted to the plate. Only DISCOVERED objects are drawn (planets / stations / gates / rock fields, small representations). The TETHER RANGE is drawn as the union boundary (Tether.visibleArcs) with a soft zone glow inside, and everything OUTSIDE the tether union is grayed out (a fog-of-war dim pass with feathered rim) — so the chart reads as 'here is what you have charted, and here is the room to expand'. A live SHIP marker + hover tooltips + click-to-autopilot (GameScene.autopilotTo) sit on top. All colors/motion are steered here.",
|
||||
"enabled": true,
|
||||
"title": "STELLAR CARTOGRAPHY",
|
||||
"meta": "NAV DATA // LIVE FEED",
|
||||
"video": {
|
||||
"_comment": "The cartography feed (like research.video): `file` is the clip under assets/videos/ (or a full relative path / URL). Muted, looping, 2:3 portrait (map.mp4 is 544×800). A missing file leaves the NO SIGNAL plate up — the console still works.",
|
||||
"file": "map.mp4",
|
||||
"aspect": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
"tabs": {
|
||||
"_comment": "The two sockets above the chart (top right). `currentSystem` is the live tab (default). `galaxy` is reserved — `standby: true` renders it dimmed with the standbyTag, and a press fires the scene's onLocked (a console toast) instead of switching.",
|
||||
"currentSystem": {
|
||||
"id": "currentSystem",
|
||||
"label": "Current System",
|
||||
"accent": "#00e5ff"
|
||||
},
|
||||
"galaxy": {
|
||||
"id": "galaxy",
|
||||
"label": "Galaxy",
|
||||
"accent": "#ffc94d",
|
||||
"standby": true,
|
||||
"standbyTag": "OFFLINE"
|
||||
}
|
||||
},
|
||||
"galaxyLockedToast": "GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED",
|
||||
"bounds": {
|
||||
"_comment": "The chart's frame: the furthest X/Y extents of EVERY object in the system (planets, stations, gates, asteroid clusters — discovered or not) plus this much padding, px, on each edge (the design rule: ~1024).",
|
||||
"padding": 1024
|
||||
},
|
||||
"chart": {
|
||||
"_comment": "The plate's rendering (the canvas texture is redrawn on open / on discovery / on tether changes). colors follow theme.json (neon cyan range, amber alerts). grid.spacing = the world-px step of the adaptive grid (snapped to 1/2/5×10^k). glow = the soft light pooled inside each tether zone (fraction of the zone radius the gradient spans, and its strength). labels = the small name tags under NAV objects.",
|
||||
"colors": {
|
||||
"bg": "#040914",
|
||||
"bgGlow": "#0a2238",
|
||||
"star": "#9fd8ff",
|
||||
"grid": "#1b3a5a",
|
||||
"gridAlpha": 0.28,
|
||||
"ink": "#eaf6ff",
|
||||
"dim": "#7d92c4",
|
||||
"faint": "#3d4c74",
|
||||
"tether": "#00e5ff",
|
||||
"tetherGlow": "#00e5ff",
|
||||
"neon2": "#ff2d6f",
|
||||
"amber": "#ffc94d",
|
||||
"fog": "#111c33",
|
||||
"fogAlpha": 0.55
|
||||
},
|
||||
"grid": {
|
||||
"spacing": 512,
|
||||
"alpha": 0.28
|
||||
},
|
||||
"glow": {
|
||||
"span": 0.92,
|
||||
"alpha": 0.22
|
||||
},
|
||||
"tetherLine": {
|
||||
"dash": 7,
|
||||
"gap": 5,
|
||||
"width": 2,
|
||||
"coreWidth": 1,
|
||||
"ghost": 2.5
|
||||
},
|
||||
"labels": {
|
||||
"enabled": true,
|
||||
"size": 10,
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
"planetMin": 4,
|
||||
"planetMax": 16,
|
||||
"starMin": 7,
|
||||
"starMax": 30,
|
||||
"vignette": 0.34
|
||||
},
|
||||
"stats": {
|
||||
"_comment": "The bottom-right SYSTEM readout. discovery = the share of the system's NAV objects (planets + space stations + jump gates — the central body is the chart's anchor, not a count) the player has found; resources = the share of asteroid fields found (the only resource kind for now). Both render as segmented bars (one segment per object) + a percent.",
|
||||
"discoveryLabel": "SYSTEM DISCOVERY",
|
||||
"discoveryMeta": "PLANETS · STATIONS · JUMP GATES",
|
||||
"resourcesLabel": "SYSTEM RESOURCES",
|
||||
"resourcesMeta": "ASTEROID FIELDS",
|
||||
"noResources": "NO RESOURCE FIELDS CHARTED"
|
||||
},
|
||||
"legend": {
|
||||
"_comment": "The glyph key under the stats (the chart's visual language).",
|
||||
"items": [
|
||||
{
|
||||
"glyph": "planet",
|
||||
"label": "WORLD"
|
||||
},
|
||||
{
|
||||
"glyph": "station",
|
||||
"label": "STATION"
|
||||
},
|
||||
{
|
||||
"glyph": "gate",
|
||||
"label": "JUMP GATE"
|
||||
},
|
||||
{
|
||||
"glyph": "rock",
|
||||
"label": "ROCKS"
|
||||
},
|
||||
{
|
||||
"glyph": "tether",
|
||||
"label": "TETHER RANGE"
|
||||
},
|
||||
{
|
||||
"glyph": "ship",
|
||||
"label": "SHIP"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sweep": {
|
||||
"_comment": "The scan band crawling across the chart (the CRT pass of light).",
|
||||
"enabled": true,
|
||||
"everyMs": [
|
||||
5200,
|
||||
9800
|
||||
],
|
||||
"durationMs": 1500
|
||||
},
|
||||
"glitch": {
|
||||
"_comment": "Ambient glitch bursts over the window (slice bars + title RGB split, the ResearchWindow model).",
|
||||
"enabled": true,
|
||||
"intervalMs": [
|
||||
5000,
|
||||
11000
|
||||
],
|
||||
"durationMs": [
|
||||
200,
|
||||
420
|
||||
]
|
||||
},
|
||||
"hover": {
|
||||
"_comment": "Plate hover: the object under the cursor gets a highlight ring + tooltip. radiusSlop = extra screen-px hit slop (tiny map objects need room).",
|
||||
"enabled": true,
|
||||
"radiusSlop": 9
|
||||
},
|
||||
"clickToast": {
|
||||
"_comment": "Plate interaction copy.",
|
||||
"coursePlotted": "COURSE PLOTTED — {name}",
|
||||
"noCourse": "NO NAV LOCK — OBJECT NOT CHARTED"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"_comment_revealDelay": "ms to wait before the title + buttons arrive — synced to the crescendo intro of the menu track (assets/music/mainmenu.mp3). Beat 1 (corner frame, chrome text, subtitle) is up immediately; this delay is beat 2.",
|
||||
"revealDelay": 3200,
|
||||
"title": "ORBIT",
|
||||
"titleFontSize": 118,
|
||||
"titleLetterSpacing": 12,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="../" />
|
||||
<title>Orbit — Map console (dev shot)</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/map-shot.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Dev-only: boot GameScene, discover a handful of the system's objects,
|
||||
* and open the MAP console (depth 80) so a headless screenshot shows the
|
||||
* whole feature — left cartography feed (muted loop), the CURRENT SYSTEM /
|
||||
* GALAXY(standby) tabs, the system chart (discovered objects on the padded
|
||||
* frame, the tether union boundary, the fog of what the tether doesn't
|
||||
* cover yet, the ship marker) and the SYSTEM readout (discovery /
|
||||
* resources segmented bars + legend).
|
||||
*
|
||||
* node dev/server.mjs 8082 # static server
|
||||
* node dev/shot-firefox.mjs \
|
||||
* "http://127.0.0.1:8082/dev/map-shot.html" \
|
||||
* "window.__MAP_SHOT ? window.__MAP_SHOT.ready : null" \
|
||||
* MAP_SHOT.png 30000
|
||||
*
|
||||
* The report (top-left) lists console errors + the window's paint state.
|
||||
*/
|
||||
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';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
|
||||
// Capture console errors + uncaught exceptions for the report.
|
||||
const errors = [];
|
||||
const origErr = console.error.bind(console);
|
||||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
||||
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
|
||||
|
||||
const gameConfig = createGameConfig();
|
||||
gameConfig.scene = [GameScene];
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
window.game = game;
|
||||
|
||||
// The report lives OUTSIDE the canvas — a DOM <pre> the screenshot can
|
||||
// always read (headless canvases don't have to cooperate).
|
||||
const report = document.createElement('pre');
|
||||
report.id = 'report';
|
||||
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
|
||||
document.body.appendChild(report);
|
||||
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
|
||||
setReport('booting…');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function waitScene() {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = game.scene.getScene('GameScene');
|
||||
if (s && s.ship && s.mapWindow) return s;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('GameScene never booted');
|
||||
}
|
||||
|
||||
try {
|
||||
const s = await waitScene();
|
||||
await sleep(800); // let the boot settle (dossier decode, deck flicker)
|
||||
|
||||
// Discover a HANDFUL of objects so the chart shows both sides: the
|
||||
// discovered (bright, lit segments) and the still-hidden (fog + dark
|
||||
// segments). Deterministic picks: the central body (home), the planets,
|
||||
// the first station (if any) and the first rock field (if any).
|
||||
const sysId = s.systemRecord.id;
|
||||
let known = s.discovery.bySystem.get(sysId);
|
||||
if (!known) {
|
||||
known = new Set();
|
||||
s.discovery.bySystem.set(sysId, known);
|
||||
}
|
||||
const push = (obj) => {
|
||||
if (!obj || known.has(obj.id)) return;
|
||||
// Discovery.check with the ship "on" the object = discovered, no FX.
|
||||
s.discovery.check(sysId, obj.x, obj.y, [obj]);
|
||||
};
|
||||
if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius });
|
||||
for (const p of s.systemPlanets) push({ id: p.discoveryId, x: p.x, y: p.y, radius: p.radius });
|
||||
if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 });
|
||||
if (s.asteroidClusters[0]) push({ id: s.asteroidClusters[0].discoveryId, x: s.asteroidClusters[0].x, y: s.asteroidClusters[0].y, radius: s.asteroidClusters[0].bound ?? 100 });
|
||||
|
||||
// Open the console (the deck MAP button does exactly this).
|
||||
s.deckAction('map');
|
||||
await sleep(1600); // the boot reveal + first chart paint + font repaint
|
||||
|
||||
// The locked GALAXY socket: a press must not crash (onLocked toast).
|
||||
let locked = 'n/a';
|
||||
try {
|
||||
const gal = s.mapWindow.tabs.find((t) => t.id === 'galaxy');
|
||||
s.mapWindow._tabHit(gal);
|
||||
locked = 'press ok';
|
||||
} catch (e) { locked = 'press err: ' + e.message; }
|
||||
|
||||
const win = s.mapWindow;
|
||||
const snap = s.mapChartSnapshot?.() ?? null;
|
||||
const lines = [
|
||||
errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
|
||||
`window: ${win?.openState} · tabs=${win?.tabs.map((t) => t.id).join(',')}`,
|
||||
snap
|
||||
? `snapshot: ${snap.objects.length} objects · ${snap.tethers.length} tethers · nav=${snap.stats.nav.found}/${snap.stats.nav.total} · res=${snap.stats.res.found}/${snap.stats.res.total}`
|
||||
: 'snapshot: NONE',
|
||||
`discovered now: ${s.discovery.discoveredIds(sysId).join(', ') || '—'}`,
|
||||
`video: ${win?.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL'}`,
|
||||
`galaxy tab: ${locked}`,
|
||||
`chart tex: ${win?._texKey ?? '—'} · tf scale=${win?._tf ? win._tf.scale.toExponential(2) : '—'}`,
|
||||
];
|
||||
setReport(lines);
|
||||
window.__MAP_SHOT = { ready: true, lines, errors };
|
||||
console.info('map-shot: report painted');
|
||||
} catch (err) {
|
||||
errors.push(`FATAL: ${err.message}`);
|
||||
setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
|
||||
window.__MAP_SHOT = { ready: true, fatal: String(err.message), errors };
|
||||
console.error('map-shot: fatal', err);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev menu intro test</title>
|
||||
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||
rooted at the project root — resolve them against it. -->
|
||||
<base href="../" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<!-- The CDP runner (dev/cdp-firefox.mjs) reads window.__CAPTURED_ERRORS__
|
||||
to surface any page-level failure the driver itself can't see. -->
|
||||
<script>
|
||||
window.__CAPTURED_ERRORS__ = [];
|
||||
window.__CAPTURED_LOGS__ = [];
|
||||
const __oe = console.error.bind(console);
|
||||
const __ol = console.log.bind(console);
|
||||
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
|
||||
console.log = (...a) => { window.__CAPTURED_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __ol(...a); };
|
||||
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
|
||||
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
|
||||
</script>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/menu-intro-test.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Dev-only MenuScene intro driver (headless browser — NOT a Node test).
|
||||
*
|
||||
* Verifies the two-beat menu intro synced to the menu track's crescendo
|
||||
* (assets/music/mainmenu.mp3 swells at ~2.2 s, data/menu.json revealDelay):
|
||||
*
|
||||
* beat 1 (t≈0): the console is up — corner frame, chrome text, and
|
||||
* the "THE GALAXY AWAITS" subtitle + rule are visible,
|
||||
* while the title, bloom, buttons, and seed panel
|
||||
* are STILL DARK.
|
||||
* beat 2 (t≈2200): the title flickers in (boot flicker armed), the
|
||||
* bloom blooms, the buttons rise in priority order,
|
||||
* the seed decodes, one signature glitch fires.
|
||||
*
|
||||
* Served by dev/menu-intro-test.html; results land in
|
||||
* `window.__MENU_INTRO__` for the CDP runner (dev/cdp-firefox.mjs):
|
||||
*
|
||||
* python3 -m http.server 8080
|
||||
* node dev/cdp-firefox.mjs http://localhost:8080/dev/menu-intro-test.html
|
||||
*/
|
||||
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 { MenuScene } from '../js/scenes/MenuScene.js';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
|
||||
// A quiet run (no audio files to fetch in headless).
|
||||
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
|
||||
const gameConfig = createGameConfig();
|
||||
gameConfig.scene = [MenuScene]; // the menu boots first
|
||||
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
window.game = game;
|
||||
|
||||
const results = [];
|
||||
const check = (label, cond) => {
|
||||
const pass = !!cond;
|
||||
results.push({ label, pass });
|
||||
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
|
||||
};
|
||||
|
||||
// WAIT — throttling-proof. This headless box starves setTimeout and
|
||||
// stretches its compositor clock, so the only honest barrier is the
|
||||
// GAME'S OWN CLOCK: poll scene time.now on rAF until it has advanced
|
||||
// past the base (same pattern as dev/saves-ui-test.mjs).
|
||||
const gameClock = () => {
|
||||
try {
|
||||
const s = window.game.scene.getScenes(true)[0];
|
||||
if (s && typeof s.time.now === 'number') return s.time.now;
|
||||
} catch { /* not booted yet */ }
|
||||
return null;
|
||||
};
|
||||
const wait = (ms) => new Promise((resolve) => {
|
||||
const base = gameClock();
|
||||
if (base === null) { // pre-boot: fall back to wall clock
|
||||
const start = performance.now();
|
||||
setTimeout(() => resolve(), ms);
|
||||
return;
|
||||
}
|
||||
const poll = () => {
|
||||
const now = gameClock();
|
||||
if (now !== null && now - base >= ms) return resolve();
|
||||
requestAnimationFrame(poll);
|
||||
};
|
||||
requestAnimationFrame(poll);
|
||||
});
|
||||
|
||||
const near = (a, v) => Math.abs(a - v) < 0.02;
|
||||
|
||||
const run = async () => {
|
||||
// ---- boot: the menu must come up --------------------------------------
|
||||
const bootT0 = Date.now();
|
||||
while (!game.scene.isActive('MenuScene') || !game.scene.getScene('MenuScene').title) {
|
||||
if (Date.now() - bootT0 > 90000) throw new Error('MenuScene never came up');
|
||||
await wait(200);
|
||||
}
|
||||
const scene = game.scene.getScene('MenuScene');
|
||||
check('the menu boots', game.scene.isActive('MenuScene'));
|
||||
|
||||
// Beat flags for dev/shot-firefox.mjs (screenshot at the right moment).
|
||||
window.__MENU_BEAT1__ = null;
|
||||
window.__MENU_BEAT2__ = null;
|
||||
|
||||
const revealAt = config.get('menu.revealDelay', 3200);
|
||||
|
||||
// ---- beat 1: console up, title + buttons still dark --------------------
|
||||
// Sample at +1000 ms: past beat 1's fade (60+450) and well before the
|
||||
// reveal (revealDelay, default 3200).
|
||||
await wait(1000);
|
||||
window.__MENU_BEAT1__ = true;
|
||||
check('beat 1: the "THE GALAXY AWAITS" subtitle is up',
|
||||
scene.subtitle.alpha > 0.95);
|
||||
check('beat 1: the subtitle rule is up',
|
||||
scene.rule.alpha > 0.95);
|
||||
check('beat 1: the main title is STILL DARK',
|
||||
near(scene.title.alpha, 0) && scene.title.bootT0 === null);
|
||||
check('beat 1: the title bloom is STILL DARK',
|
||||
near(scene.titleBloom.main.alpha, 0) && near(scene.titleBloom.fringe.alpha, 0));
|
||||
check('beat 1: the buttons are STILL DARK',
|
||||
near(scene.continueBtn.alpha, 0) && near(scene.newGameBtn.alpha, 0) && near(scene.loadGameBtn.alpha, 0));
|
||||
check('beat 1: the seed panel is STILL DARK',
|
||||
scene.seedIntroTargets.every((t) => near(t.alpha, 0)) && near(scene.rerollBtn.alpha, 0));
|
||||
|
||||
// ---- beat 2: the downbeat — title, bloom, buttons, seed, decode -------
|
||||
// The last beat 2 effect settles at revealAt + 540 + 620 (decode end);
|
||||
// sample at revealAt + 1300 to be comfortably past everything.
|
||||
await wait(revealAt + 1300 - 1000);
|
||||
window.__MENU_BEAT2__ = true;
|
||||
check('beat 2: the main title is up (boot flicker finished)',
|
||||
near(scene.title.alpha, 1) && scene.title.bootT0 === null);
|
||||
check('beat 2: the title bloom is up',
|
||||
near(scene.titleBloom.main.alpha, 1) && near(scene.titleBloom.fringe.alpha, 1));
|
||||
check('beat 2: the buttons are up (Continue / New Game / Load Game)',
|
||||
near(scene.continueBtn.alpha, 1) && near(scene.newGameBtn.alpha, 1) && near(scene.loadGameBtn.alpha, 1));
|
||||
check('beat 2: the seed panel is up',
|
||||
scene.seedIntroTargets.every((t) => near(t.alpha, 1)) && near(scene.rerollBtn.alpha, 1));
|
||||
check('beat 2: the seed decoded to its final value',
|
||||
scene.decode === null && scene.seedText.text.startsWith(scene.seedValue));
|
||||
|
||||
const failed = results.filter((r) => !r.pass).length;
|
||||
console.log(failed === 0 ? `MENU INTRO OK (${results.length} checks)` : `MENU INTRO FAILED (${failed}/${results.length})`);
|
||||
window.__MENU_INTRO__ = { results, failed };
|
||||
};
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('menu-intro-test crashed:', err);
|
||||
window.__MENU_INTRO__ = { results, failed: results.length, error: String(err && err.stack || err) };
|
||||
});
|
||||
|
|
@ -238,8 +238,9 @@ check('builds._template.requires is an array', Array.isArray(bt.requires));
|
|||
// ----------------------------------------------------------------------
|
||||
const slots = actionbar.buttons ?? [];
|
||||
check('actionbar: exactly six slots', slots.length === 6);
|
||||
check('actionbar: slot ids in order (Research, Scan, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', null, null, 'menu']));
|
||||
check('actionbar: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu']));
|
||||
check('actionbar: slot ids in order (Research, Scan, Ship, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu']));
|
||||
check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu']));
|
||||
check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })());
|
||||
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
|
||||
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
|
||||
check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* SystemChart test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node dev/system-chart.test.mjs
|
||||
*
|
||||
* Asserts the pure geometry + stats behind the deck's MAP console
|
||||
* (js/galaxy/SystemChart.js):
|
||||
* - chartBounds: the furthest X/Y extents of the object set (object
|
||||
* EDGE = center ± radius), symmetric padding on every edge, the
|
||||
* degenerate empty-set case (a 2×padding box about the origin);
|
||||
* - fitToRect: uniform scale that fits the frame into the plate,
|
||||
* centred — toX/toY land the bounds centre on the plate centre, the
|
||||
* whole frame inside the plate, aspect preserved;
|
||||
* - navDiscoveryStats: the SYSTEM DISCOVERY share over the system's NAV
|
||||
* points (planets + space stations + gates) MINUS the central body
|
||||
* ('home') — found/total + pct; 0 total → pct 0;
|
||||
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
|
||||
* (content.asteroids) — found/total + pct; missing list → 0/0.
|
||||
*/
|
||||
import { chartBounds, fitToRect, navDiscoveryStats, resourceStats } from '../js/galaxy/SystemChart.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(cond, label) {
|
||||
if (cond) {
|
||||
passed++;
|
||||
console.log(` ok ${label}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`FAIL ${label}`);
|
||||
}
|
||||
}
|
||||
function near(a, b, eps = 1e-9) {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
// ── chartBounds ────────────────────────────────────────────────────────────
|
||||
console.log('chartBounds');
|
||||
{
|
||||
const b = chartBounds(
|
||||
[
|
||||
{ x: 100, y: -50, radius: 30 },
|
||||
{ x: -200, y: 40, radius: 10 },
|
||||
{ x: 0, y: 500, radius: 200 },
|
||||
],
|
||||
1000
|
||||
);
|
||||
ok(near(b.minX, -210 - 1000), 'minX = leftmost edge − padding');
|
||||
ok(near(b.maxX, 0 + 200 + 1000), 'maxX = rightmost edge (the 200-radius object) + padding');
|
||||
ok(near(b.minY, -50 - 30 - 1000), 'minY = top edge − padding');
|
||||
ok(near(b.maxY, 500 + 200 + 1000), 'maxY = bottom edge + padding');
|
||||
ok(near(b.w, b.maxX - b.minX) && near(b.h, b.maxY - b.minY), 'w/h consistent');
|
||||
ok(near(b.cx, (b.minX + b.maxX) / 2) && near(b.cy, (b.minY + b.maxY) / 2), 'centre = midpoint');
|
||||
}
|
||||
{
|
||||
// default padding (the design rule: ~1024 px on every edge)
|
||||
const b = chartBounds([{ x: 0, y: 0, radius: 500 }], 1024);
|
||||
ok(near(b.minX, -1524) && near(b.maxX, 1524), '±(radius + 1024) about the origin');
|
||||
}
|
||||
{
|
||||
const b = chartBounds([], 512);
|
||||
ok(near(b.minX, -512) && near(b.maxX, 512) && near(b.minY, -512) && near(b.maxY, 512), 'empty set → a 2×padding box about the origin');
|
||||
}
|
||||
{
|
||||
const b = chartBounds([{ x: 0, y: 0, radius: 10 }, { bogus: true }], 100);
|
||||
ok(near(b.minX, -110), 'non-object entries ignored');
|
||||
}
|
||||
|
||||
// ── fitToRect ──────────────────────────────────────────────────────────────
|
||||
console.log('fitToRect');
|
||||
{
|
||||
const bounds = { minX: -1000, minY: -500, maxX: 3000, maxY: 1500, w: 4000, h: 2000, cx: 1000, cy: 500 };
|
||||
const tf = fitToRect(bounds, 800, 600);
|
||||
// uniform scale = min(800,600)/max(4000,2000) = 600/4000 = 0.15
|
||||
ok(near(tf.scale, 0.15), 'scale = min(w,h) / max(bounds w,h)');
|
||||
ok(near(tf.toX(1000), 400), 'toX: bounds centre → plate centre x');
|
||||
ok(near(tf.toY(500), 300), 'toY: bounds centre → plate centre y');
|
||||
ok(tf.toX(-1000) >= 0 && tf.toX(3000) <= 800, 'frame x inside plate');
|
||||
ok(tf.toY(-500) >= 0 && tf.toY(1500) <= 600, 'frame y inside plate');
|
||||
// aspect preserved: a 100×100 world square maps to a 15×15 px square
|
||||
const x0 = tf.toX(0);
|
||||
const y0 = tf.toY(0);
|
||||
ok(near(tf.toX(100) - x0, tf.toY(100) - y0, 1e-9), 'uniform scale — x and y move equally');
|
||||
}
|
||||
{
|
||||
// wider-than-tall plate: the frame is limited by WIDTH
|
||||
const bounds = { minX: -100, minY: -100, maxX: 100, maxY: 100, w: 200, h: 200, cx: 0, cy: 0 };
|
||||
const tf = fitToRect(bounds, 1000, 200);
|
||||
ok(near(tf.scale, 1), 'width-constrained: scale = h / bounds.h');
|
||||
ok(near(tf.toX(0), 500) && near(tf.toY(0), 100), 'centred');
|
||||
ok(near(tf.toX(100) - tf.toX(-100), 200), 'frame width preserved');
|
||||
}
|
||||
|
||||
// ── navDiscoveryStats ──────────────────────────────────────────────────────
|
||||
console.log('navDiscoveryStats');
|
||||
{
|
||||
const content = {
|
||||
planets: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
|
||||
settlements: [
|
||||
{ id: 's1', anchor: { type: 'space' } },
|
||||
{ id: 's2', anchor: { type: 'surface' } }, // not a NAV point (planet-anchored)
|
||||
],
|
||||
jumps: [{ id: 'g1' }],
|
||||
};
|
||||
const foundSet = new Set(['A', 'g1']);
|
||||
const discovery = { isDiscovered: (sysId, id) => foundSet.has(id) };
|
||||
const st = navDiscoveryStats(discovery, 'sys1', content);
|
||||
ok(st.total === 5, 'total = 3 planets + 1 space station + 1 gate (central body excluded, surface settlement excluded)');
|
||||
ok(st.found === 2, 'found counts the discovered NAV points');
|
||||
ok(near(st.pct, 2 / 5), 'pct = found/total');
|
||||
}
|
||||
{
|
||||
const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', { planets: [], settlements: [], jumps: [] });
|
||||
ok(st.total === 0 && st.found === 0 && st.pct === 0, 'no NAV points → 0/0, pct 0');
|
||||
}
|
||||
{
|
||||
// the central body is discoverable ('home') but NOT counted — the
|
||||
// readout rule: planets + stations + gates only.
|
||||
const content = { planets: [{ name: 'A' }], settlements: [], jumps: [] };
|
||||
const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', content);
|
||||
ok(st.total === 1, 'home body never enters the total');
|
||||
}
|
||||
{
|
||||
const st = navDiscoveryStats(null, 'sys', { planets: [{ name: 'A' }] });
|
||||
ok(st.total === 1 && st.found === 0 && st.pct === 0, 'null discovery state → nothing found, no crash');
|
||||
}
|
||||
|
||||
// ── resourceStats ──────────────────────────────────────────────────────────
|
||||
console.log('resourceStats');
|
||||
{
|
||||
const content = { asteroids: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }] };
|
||||
const foundSet = new Set(['r2']);
|
||||
const st = resourceStats({ isDiscovered: (sysId, id) => foundSet.has(id) }, 'sys', content);
|
||||
ok(st.total === 3, 'total = asteroid cluster count');
|
||||
ok(st.found === 1 && near(st.pct, 1 / 3), 'found/pct over the clusters');
|
||||
}
|
||||
{
|
||||
const st = resourceStats({ isDiscovered: () => true }, 'sys', {});
|
||||
ok(st.total === 0 && st.pct === 0, 'no asteroid fields → 0/0');
|
||||
}
|
||||
{
|
||||
const st = resourceStats(null, 'sys', { asteroids: [{ id: 'r1' }] });
|
||||
ok(st.total === 1 && st.found === 0, 'null discovery state → nothing found');
|
||||
}
|
||||
|
||||
// ── result ─────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* SystemChart — the pure geometry + stats behind the deck's MAP console
|
||||
* (js/ui/MapWindow.js draws on it; GameScene feeds it).
|
||||
*
|
||||
* chartBounds(objects, padding) the system's chart frame: the furthest
|
||||
* X/Y extents of EVERY object (planets,
|
||||
* stations, jump gates AND asteroid
|
||||
* clusters — discovered or not) plus
|
||||
* `padding` px (data/map.json →
|
||||
* bounds.padding, ~1024) on each edge.
|
||||
* fitToRect(bounds, w, h) the world → plate transform that fits
|
||||
* that frame into the map plate, centred.
|
||||
* navDiscoveryStats(d, sysId, c) the SYSTEM DISCOVERY share — the NAV
|
||||
* objects (planets + free-space stations
|
||||
* + jump gates; the central body is the
|
||||
* chart's anchor, not a count) vs. the
|
||||
* player's Discovery state.
|
||||
* resourceStats(d, sysId, c) the SYSTEM RESOURCES share — the
|
||||
* asteroid fields vs. Discovery (the only
|
||||
* resource kind for now).
|
||||
*
|
||||
* Pure (no Phaser) — Node-testable (dev/system-chart.test.mjs). The NAV
|
||||
* point set is SystemCategory.navPoints (the same discoverable set the
|
||||
* jumpgate chart gate uses) minus the central body, per the readout's
|
||||
* rule: planets, stations, gates.
|
||||
*/
|
||||
import { navPoints } from '../research/SystemCategory.js';
|
||||
|
||||
/**
|
||||
* The chart's bounding frame from a list of objects.
|
||||
*
|
||||
* @param {Array<{x:number, y:number, radius:number}>} objects — the system's
|
||||
* objects (any discoverable set; `radius` = the object's extent from its
|
||||
* center — planet disc radius, station/gate keepout, cluster bound)
|
||||
* @param {number} [padding=1024] — px of margin added on every edge
|
||||
* @returns {{minX:number, minY:number, maxX:number, maxY:number,
|
||||
* w:number, h:number, cx:number, cy:number}}
|
||||
* (a degenerate empty list yields a `2×padding` box about the origin)
|
||||
*/
|
||||
export function chartBounds(objects, padding = 1024) {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const o of objects ?? []) {
|
||||
if (!o || typeof o.x !== 'number' || typeof o.y !== 'number') continue;
|
||||
const r = Math.max(0, Number(o.radius) || 0);
|
||||
if (o.x - r < minX) minX = o.x - r;
|
||||
if (o.y - r < minY) minY = o.y - r;
|
||||
if (o.x + r > maxX) maxX = o.x + r;
|
||||
if (o.y + r > maxY) maxY = o.y + r;
|
||||
}
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) {
|
||||
// No objects: a zero-size box about the origin (the padding then makes
|
||||
// the chart a 2×padding box about it).
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 0;
|
||||
maxY = 0;
|
||||
}
|
||||
minX -= padding;
|
||||
minY -= padding;
|
||||
maxX += padding;
|
||||
maxY += padding;
|
||||
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit a world-space rect into a plate of `w × h`, centred, uniform scale.
|
||||
*
|
||||
* @param {ReturnType<typeof chartBounds>} bounds
|
||||
* @param {number} w — plate width (px)
|
||||
* @param {number} h — plate height (px)
|
||||
* @returns {{scale:number, ox:number, oy:number, toX:(x:number)=>number,
|
||||
* toY:(y:number)=>number}}
|
||||
* `toX`/`toY` map world → plate; `scale` = plate px per world px.
|
||||
*/
|
||||
export function fitToRect(bounds, w = 100, h = 100) {
|
||||
const scale = Math.min(w, h) / Math.max(1, Math.max(bounds.w, bounds.h));
|
||||
const ox = w / 2 - bounds.cx * scale;
|
||||
const oy = h / 2 - bounds.cy * scale;
|
||||
return {
|
||||
scale,
|
||||
ox,
|
||||
oy,
|
||||
toX: (x) => x * scale + ox,
|
||||
toY: (y) => y * scale + oy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The SYSTEM DISCOVERY share (data/map.json → stats): the player's found /
|
||||
* total over the system's NAV objects — planets, free-space stations and
|
||||
* jump gates (SystemCategory.navPoints minus the central body 'home',
|
||||
* which is the chart's anchor rather than a discoverable count — and
|
||||
* minus the asteroid clusters, which are the RESOURCE side of the readout).
|
||||
*
|
||||
* @param {object} discovery — the Discovery state (isDiscovered)
|
||||
* @param {string} systemId
|
||||
* @param {object} content — the generated system content
|
||||
* @returns {{total:number, found:number, pct:number}} — `pct` is 0..1
|
||||
* (0 when the system has no NAV objects, which the readout renders as —)
|
||||
*/
|
||||
export function navDiscoveryStats(discovery, systemId, content) {
|
||||
const points = navPoints(content).filter((p) => p.kind !== 'home');
|
||||
const canQuery = typeof discovery?.isDiscovered === 'function';
|
||||
let found = 0;
|
||||
for (const p of points) if (canQuery && discovery.isDiscovered(systemId, p.id)) found++;
|
||||
const total = points.length;
|
||||
return { total, found, pct: total > 0 ? found / total : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SYSTEM RESOURCES share (data/map.json → stats): the player's found /
|
||||
* total over the system's asteroid fields (content.asteroids — the only
|
||||
* resource kind for now).
|
||||
*
|
||||
* @param {object} discovery — the Discovery state (isDiscovered)
|
||||
* @param {string} systemId
|
||||
* @param {object} content — the generated system content
|
||||
* @returns {{total:number, found:number, pct:number}}
|
||||
*/
|
||||
export function resourceStats(discovery, systemId, content) {
|
||||
const clusters = (content?.asteroids ?? []).filter((c) => c && typeof c.id === 'string');
|
||||
const canQuery = typeof discovery?.isDiscovered === 'function';
|
||||
let found = 0;
|
||||
for (const c of clusters) if (canQuery && discovery.isDiscovered(systemId, c.id)) found++;
|
||||
const total = clusters.length;
|
||||
return { total, found, pct: total > 0 ? found / total : 0 };
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ import { ScanPulse } from '../scan/ScanPulse.js';
|
|||
import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
|
||||
import { CommsPanel } from '../ui/CommsPanel.js';
|
||||
import { ResearchWindow } from '../ui/ResearchWindow.js';
|
||||
import { MapWindow } from '../ui/MapWindow.js';
|
||||
import { navDiscoveryStats, resourceStats } from '../galaxy/SystemChart.js';
|
||||
import { ResearchState } from '../research/ResearchState.js';
|
||||
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
|
||||
import {
|
||||
|
|
@ -166,6 +168,19 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// The map console's cartography feed (data/map.json → video): a muted
|
||||
// 2:3 loop behind the MAP window. Same contract as the research feed —
|
||||
// a missing file just leaves the window's NO SIGNAL plate up.
|
||||
if (config.get('map.enabled', true)) {
|
||||
const mapVideo = String(config.get('map.video.file') ?? '');
|
||||
if (mapVideo) {
|
||||
const url = /^(https?:)?\/\//.test(mapVideo) || mapVideo.startsWith('assets/')
|
||||
? mapVideo
|
||||
: `assets/videos/${mapVideo}`;
|
||||
this.load.video(MapWindow.VIDEO_KEY, url);
|
||||
}
|
||||
}
|
||||
|
||||
// The build console's feed (data/builds.json → video): a muted 2:3
|
||||
// loop behind the BUILD window on a planet surface (SurfaceScene's
|
||||
// BuildWindow reads the shared cache). A missing file just leaves the
|
||||
|
|
@ -610,6 +625,25 @@ export class GameScene extends Phaser.Scene {
|
|||
systemTree: this.systemTree,
|
||||
onResearch: (catId, id) => this.beginResearch(catId, id),
|
||||
});
|
||||
|
||||
// ---- MAP CONSOLE (the deck's MAP button, right of SHIP) ----
|
||||
// The cartography window (data/map.json, js/ui/MapWindow.js, depth 80):
|
||||
// left cartography feed + the system chart — discovered objects on a
|
||||
// padded frame, the tether union boundary, the fog of what the tether
|
||||
// doesn't cover yet, the system discovery / resources readout.
|
||||
// Clicking a discovered object on the chart plots the course (the same
|
||||
// autopilot as a world click). The window is a pure view — it polls
|
||||
// mapChartSnapshot() while open and repaints on any change.
|
||||
this.mapWindow = new MapWindow(this, {
|
||||
getChart: () => this.mapChartSnapshot(),
|
||||
getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null),
|
||||
onSelect: (objectId) => this.autopilotTo(objectId),
|
||||
onLocked: () =>
|
||||
this.consoleToast(config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'), {
|
||||
glyph: '✕',
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
}),
|
||||
});
|
||||
// deck progress bar — drawn over the RESEARCH button while a project runs
|
||||
this._researchDeckBar = {
|
||||
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
|
||||
|
|
@ -732,6 +766,8 @@ export class GameScene extends Phaser.Scene {
|
|||
// The research console (depth 80) is full-screen — it owns all input
|
||||
// (its own buttons, the close, ESC); a world click never lands behind it.
|
||||
if (this.researchWindow && this.researchWindow.isOpen) return;
|
||||
// The map console (depth 80) — the same full-screen contract.
|
||||
if (this.mapWindow && this.mapWindow.isOpen) return;
|
||||
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
|
||||
// its buttons — the buttons fire on their own pointerdown). ANY
|
||||
// click OUTSIDE — world, deck, HUD, compass — folds it back down
|
||||
|
|
@ -1196,6 +1232,7 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const d of _researchDone) this._completeResearch(d.category, d.id);
|
||||
}
|
||||
this.researchWindow?.update(_time); // the console's living details (open state)
|
||||
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
|
||||
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
|
||||
// Builds: a build started on a surface keeps running in space if the
|
||||
// player took off mid-build (game-loop clock — see beginBuild); when
|
||||
|
|
@ -2124,6 +2161,18 @@ export class GameScene extends Phaser.Scene {
|
|||
this.researchWindow?.open();
|
||||
return;
|
||||
}
|
||||
// The map console (full-screen, depth 80) — the deck's other console.
|
||||
if (id === 'map') {
|
||||
if (this.mapWindow && this.mapWindow.isOpen) {
|
||||
this.mapWindow.close();
|
||||
return;
|
||||
}
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
||||
this.mapWindow?.open();
|
||||
return;
|
||||
}
|
||||
if (id === 'menu') {
|
||||
this.menuAction();
|
||||
return;
|
||||
|
|
@ -2135,6 +2184,124 @@ export class GameScene extends Phaser.Scene {
|
|||
console.info(`[orbit] command deck: ${id}`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ map console
|
||||
|
||||
/**
|
||||
* The MAP console's chart snapshot (js/ui/MapWindow.js polls this while
|
||||
* open; js/galaxy/SystemChart.js shapes the stats).
|
||||
*
|
||||
* The frame covers EVERY object of the system — discovered or not (the
|
||||
* map must always show the full extent of the solar system) — plus the
|
||||
* player's tether reach; the chart DRAWS only the discovered ones.
|
||||
*/
|
||||
mapChartSnapshot() {
|
||||
const sysId = this.systemRecord?.id;
|
||||
const content = this.systemContent;
|
||||
if (!sysId || !content) return null;
|
||||
const disc = this.discovery;
|
||||
const isDisc = (id) => (disc ? disc.isDiscovered(sysId, id) : true);
|
||||
|
||||
const objects = [];
|
||||
for (const p of this.systemPlanets) {
|
||||
objects.push({
|
||||
id: p.discoveryId,
|
||||
kind: 'planet',
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
radius: p.radius,
|
||||
name: p.discoveryName,
|
||||
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
|
||||
discovered: isDisc(p.discoveryId),
|
||||
tint: toCss(config.get(`planets.classTint.${p.name}`, '#9fb6d8')),
|
||||
});
|
||||
}
|
||||
for (const st of this.systemStations) {
|
||||
objects.push({
|
||||
id: st.discoveryId,
|
||||
kind: 'station',
|
||||
x: st.x,
|
||||
y: st.y,
|
||||
radius: st.bound ?? st.size,
|
||||
name: st.discoveryName,
|
||||
typeLabel: st.kind === 'waypoint' ? 'Waypoint' : 'Station',
|
||||
discovered: isDisc(st.discoveryId),
|
||||
});
|
||||
}
|
||||
for (const gt of this.systemGates) {
|
||||
objects.push({
|
||||
id: gt.discoveryId,
|
||||
kind: 'gate',
|
||||
x: gt.x,
|
||||
y: gt.y,
|
||||
radius: gt.bound ?? gt.size,
|
||||
name: gt.discoveryName,
|
||||
typeLabel: gt.toName ? `Gate → ${gt.toName}` : 'Jump Gate',
|
||||
discovered: isDisc(gt.discoveryId),
|
||||
rotation: gt.rotation,
|
||||
active: gt.active,
|
||||
});
|
||||
}
|
||||
for (const c of this.asteroidClusters) {
|
||||
objects.push({
|
||||
id: c.discoveryId,
|
||||
kind: 'cluster',
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
radius: c.bound,
|
||||
name: c.discoveryName,
|
||||
typeLabel: 'Rock Field',
|
||||
discovered: isDisc(c.discoveryId),
|
||||
rocks: (c.members ?? []).map((m) => ({
|
||||
dx: m.lx * 1, // local offset — the plate transform is a uniform scale
|
||||
dy: m.ly,
|
||||
r: Math.max(1, m.radius),
|
||||
seed: (m.lx * 7919 + m.ly * 104729) % 1000 / 1000,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// the player's tether zones — the explored region (union boundary + fog)
|
||||
const tethers = (this.tetherField?.tethers ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
x: t.x,
|
||||
y: t.y,
|
||||
radius: t.radius,
|
||||
level: t.level,
|
||||
label: t.label ?? '',
|
||||
}));
|
||||
|
||||
// central body: the home world in the starting system, else the star
|
||||
const starClass = String(content.star?.class ?? '').toUpperCase();
|
||||
const central = this.isHomeSystem
|
||||
? {
|
||||
name: this.homeWorldName ?? 'Terra',
|
||||
isHome: true,
|
||||
radius: this.planet?.radius ?? 200,
|
||||
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||||
}
|
||||
: {
|
||||
name: this.planet?.discoveryName ?? 'Star',
|
||||
isHome: false,
|
||||
radius: 240,
|
||||
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
|
||||
color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')),
|
||||
};
|
||||
|
||||
return {
|
||||
systemId: sysId,
|
||||
systemName: this.systemRecord?.name ?? sysId,
|
||||
isHome: this.isHomeSystem === true,
|
||||
central,
|
||||
objects,
|
||||
tethers,
|
||||
ship: this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null,
|
||||
stats: {
|
||||
nav: navDiscoveryStats(disc, sysId, content),
|
||||
res: resourceStats(disc, sysId, content),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ research
|
||||
|
||||
/**
|
||||
|
|
@ -2641,6 +2808,11 @@ export class GameScene extends Phaser.Scene {
|
|||
this.researchWindow.close();
|
||||
return;
|
||||
}
|
||||
// The map console (depth 80) — the same contract.
|
||||
if (this.mapWindow && this.mapWindow.isOpen) {
|
||||
this.mapWindow.close();
|
||||
return;
|
||||
}
|
||||
// The mining context menu is the topmost open thing after the save UI.
|
||||
if (this.miningPopup && this.miningPopup.isOpen) {
|
||||
this.miningPopup.close();
|
||||
|
|
@ -2838,6 +3010,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.miningPopup?.destroy();
|
||||
this.commsPanel?.destroy();
|
||||
this.researchWindow?.destroy(); // the console (video + UI)
|
||||
this.mapWindow?.destroy(); // the map console (video + chart canvas)
|
||||
this.mineralHud?.destroy();
|
||||
this.mining?.destroy();
|
||||
this.scanPulse?.destroy();
|
||||
|
|
|
|||
|
|
@ -151,9 +151,12 @@ export class MenuScene extends Phaser.Scene {
|
|||
{ redColor: '#ff2d6f', cyanColor: '#00e5ff', baseOffset: 1.6, burstOffset: 12, ghostAlpha: 0.5 },
|
||||
);
|
||||
this.overlay.onGlitch((level) => this.title.setBurst(level));
|
||||
this.title.start(this.time.now);
|
||||
// The title stays dark until the menu track's crescendo lands (see the
|
||||
// intro sequence below) — its boot flicker is armed, not started, here.
|
||||
this.title.setAlpha(0);
|
||||
|
||||
// Title bloom: two offset radial glows (cyan core, magenta fringe).
|
||||
// Parked at alpha 0 — it blooms in with the title (intro beat 2).
|
||||
this.titleBloom = new TitleBloom(
|
||||
this,
|
||||
cx,
|
||||
|
|
@ -161,6 +164,8 @@ export class MenuScene extends Phaser.Scene {
|
|||
config.get('theme.colors.neon', '#00e5ff'),
|
||||
config.get('theme.colors.neon2', '#ff2d6f'),
|
||||
);
|
||||
this.titleBloom.main.setAlpha(0);
|
||||
this.titleBloom.fringe.setAlpha(0);
|
||||
|
||||
// ---- subtitle + rule
|
||||
this.subtitle = this.add
|
||||
|
|
@ -274,29 +279,40 @@ export class MenuScene extends Phaser.Scene {
|
|||
letterSpacing: 2,
|
||||
}).setOrigin(1, 0.5);
|
||||
|
||||
// ---- intro sequence: title flickers in, console assembles,
|
||||
// the buttons rise in priority order, the seed decodes, and
|
||||
// one signature glitch fires.
|
||||
// ---- intro sequence — two beats, synced to the menu track
|
||||
// (assets/music/mainmenu.mp3):
|
||||
// beat 1 (now): the console is already up — the corner frame, the
|
||||
// chrome text, and the "THE GALAXY AWAITS" subtitle settle in
|
||||
// first, while the title and buttons stay dark;
|
||||
// beat 2 (revealAt): the track's crescendo lands (~3.2 s in) — the
|
||||
// title flickers in, the bloom blooms, the buttons rise in
|
||||
// priority order, the seed decodes, and one signature glitch fires.
|
||||
const revealAt = config.get('menu.revealDelay', 3200);
|
||||
this.intro(60, () => {
|
||||
this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 450, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(revealAt, () => {
|
||||
this.title.setAlpha(1);
|
||||
this.title.start(this.time.now); // boot flicker lands ON the downbeat
|
||||
this.tweens.add({ targets: [this.titleBloom.main, this.titleBloom.fringe], alpha: 1, duration: 700, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.continueBtn.y += 12;
|
||||
this.newGameBtn.y += 12;
|
||||
this.intro(180, () => {
|
||||
this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(340, () => {
|
||||
this.intro(revealAt, () => {
|
||||
this.tweens.add({ targets: this.continueBtn, alpha: 1, y: this.continueBtn.y - 12, duration: 420, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(460, () => {
|
||||
this.intro(revealAt + 120, () => {
|
||||
this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(580, () => {
|
||||
this.intro(revealAt + 240, () => {
|
||||
this.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(620, () => {
|
||||
this.intro(revealAt + 280, () => {
|
||||
const targets = [...this.seedIntroTargets, this.rerollBtn];
|
||||
this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' });
|
||||
});
|
||||
this.intro(880, () => this.startDecode(this.seedValue));
|
||||
this.intro(1150, () => this.overlay.trigger(320, 0.95));
|
||||
this.intro(revealAt + 540, () => this.startDecode(this.seedValue));
|
||||
this.intro(revealAt + 810, () => this.overlay.trigger(320, 0.95));
|
||||
|
||||
// ---- input: typing goes to the seed field; a click elsewhere blurs it
|
||||
this.input.keyboard.on('keydown', (e) => this.onSeedKey(e));
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
{ id: 'research', label: 'Research', accent: '#00e5ff' },
|
||||
{ id: 'scan', label: 'Scan', accent: '#ffc94d' },
|
||||
{ id: 'ship', label: 'Ship', accent: '#7ce8a4' },
|
||||
{ id: null, label: null },
|
||||
{ id: 'map', label: 'Map', accent: '#c084fc' },
|
||||
{ id: null, label: null },
|
||||
{ id: 'menu', label: 'Menu', accent: '#ff2d6f' },
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue