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:
Brian Fertig 2026-09-06 23:43:35 -06:00
parent d78b7ad505
commit be6e9fc558
18 changed files with 3007 additions and 19 deletions

BIN
assets/videos/map.mp4 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -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" }
],

View File

@ -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" }
]

View File

@ -24,6 +24,7 @@
"scan.json",
"signalCompass.json",
"save.json",
"mineralhud.json"
"mineralhud.json",
"map.json"
]
}

146
data/map.json Normal file
View File

@ -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"
}
}

View File

@ -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,

17
dev/map-shot.html Normal file
View File

@ -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>

116
dev/map-shot.mjs Normal file
View File

@ -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);
}

31
dev/menu-intro-test.html Normal file
View File

@ -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>

131
dev/menu-intro-test.mjs Normal file
View File

@ -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 (t0): 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 (t2200): 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) };
});

View File

@ -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');

148
dev/system-chart.test.mjs Normal file
View File

@ -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);

130
js/galaxy/SystemChart.js Normal file
View File

@ -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 };
}

View File

@ -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();

View File

@ -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));

View File

@ -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' },
];

2076
js/ui/MapWindow.js Normal file

File diff suppressed because it is too large Load Diff