Add research console with Exploration tech tree and save persistence

- Full-screen RESEARCH window: looping archive feed, category tabs, branching
  DAG tech tree (top-down layout), per-tech dossier + RESEARCH button, deck
  progress bar
- Pure rules layer (ResearchModel / ResearchState) is Node-testable; the
  window is a passive view and GameScene owns effects, toasts, and saves
- First category **Exploration** (Tether Level 1–4, Tether Anchoring, Signal
  Amplification) lives in data/research/exploration.json; adding a category
  = one JSON file + one registry line + one manifest line
- Effects seam: `tether.level` grows the home tether via TetherField.setLevel;
  `capability` flags land on scene.researchCapabilities for future systems
- Save/restore round-trips an in-flight project (remainingMs captured at save,
  startedAt rebuilt on load); old saves without research still load
- Dev tooling: dev/cdp-shot.mjs (CDP screenshot with readiness wait),
  dev/research-shot.html/.mjs (boots the console + starts a run for a shot)
- Assets: gasgiant planet frame 03, takeoff/landing clips, research-computer
  feed; planets.png/.psd updated
This commit is contained in:
Brian Fertig 2026-09-05 14:59:27 -06:00
parent 407f6d6175
commit 7138d153fa
22 changed files with 2723 additions and 63 deletions

View File

@ -78,6 +78,20 @@ python3 -m http.server 8080
your range is where the ship parks — on the line — until a tether grows. your range is where the ship parks — on the line — until a tether grows.
Upgrades and extra anchors (planets/stations) plug into Upgrades and extra anchors (planets/stations) plug into
`TetherField.add/setLevel` (tuning in `data/tether.json`) `TetherField.add/setLevel` (tuning in `data/tether.json`)
- **The Research console** — the **RESEARCH** deck button opens a full-screen
window: the left pane loops the muted `assets/videos/research-computer.mp4`
archive feed (scanlines, sweep band, REC pulse, glitch bursts), the right
pane holds the category tabs and the **branching tech tree** for the
selected category — it starts at the top and unlocks downward, one branch
at a time. Selecting a tech shows its dossier (icon + description +
duration) and a **RESEARCH** button, which appears only when the tech is
researchable and nothing else is in progress (one project at a time,
`maxConcurrent: 1`). A deck progress bar tracks the run; completion fires
the tech's `effects` (e.g. `tether: {level: N}` grows the home tether's
range) and a toast. Each category's tree is its own JSON file under
`data/research/` (the first is **Exploration**: Tether Level 14,
Tether Anchoring, Signal Amplification). Unlocks and in-progress work
persist in the save bank — and resume correctly on load
- Camera gently trails the ship; the **parallax starfield** streams past - Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest comes to rest
@ -99,9 +113,12 @@ orbit/
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…) │ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations │ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ ├── research.json # RESEARCH: time-based, one at a time, gates builds/research │ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/
│ │ └── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades │ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu) │ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks │ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames) ├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body) ├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
@ -113,7 +130,8 @@ orbit/
│ ├── entities/ # Ship (own behavior), Planet (home world, solid) │ ├── entities/ # Ship (own behavior), Planet (home world, solid)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport │ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line) │ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable) │ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared) │ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator) │ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser │ └── vendor/ # shim to the vendored Phaser
@ -130,22 +148,29 @@ orbit/
## The player's loop: research + building ## The player's loop: research + building
Beyond flying, Orbit is built around two progression verbs — both are data Beyond flying, Orbit is built around two progression verbs:
layers now, with the rules and panels to come next:
- **Research** (`data/research.json`) — *time-based*. A project takes a fixed - **Research** (`data/research.json` + `data/research/<category>.json`) —
`duration`; the player researches **one thing at a time** *time-based*. A project takes a fixed `duration` (`timeUnit: seconds`);
(`maxConcurrent: 1`). Research is the gate: it unlocks **builds** and the player researches **one thing at a time** (`maxConcurrent: 1`).
**further research**. `projects` is an empty map for now; the `_template` Research is the gate: it unlocks **builds** and **further research**
entry documents the shape each project must have. (each category tree is a DAG — a node's `requires` must be unlocked
first). The RESEARCH deck button opens the console window
(`js/ui/ResearchWindow.js`): looping archive feed, category tabs, the
branching tree, per-tech dossier + RESEARCH button, and a deck progress
bar. The rules are pure (`js/research/ResearchModel.js`,
`ResearchState.js` — Node-testable); the window is a passive view that
asks `GameScene` to start a run. `effects` on a finished node carry the
payload (e.g. `{ tether: { level: 2 } }``TetherField.setLevel`), so new
effect kinds plug in without touching the tree data.
- **Building** (`data/builds.json`) — *cost-based*, paid in **credits and - **Building** (`data/builds.json`) — *cost-based*, paid in **credits and
minerals** (`resources`). A build improves the **ship**, a **planet**, or a minerals** (`resources`). A build improves the **ship**, a **planet**, or a
**space station** (`category`). `builds` is an empty map; the `_template` **space station** (`category`). `builds` is an empty map; the `_template`
entry documents the shape (including `repeatable`, for things like extra entry documents the shape (including `repeatable`, for things like extra
mining rigs). mining rigs). The build UI is next — the research side is live now.
- **The command deck** (`data/actionbar.json`, rendered by - **The command deck** (`data/actionbar.json`, rendered by
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen. `js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**. The slots
fire `onAction` and are otherwise inert; two slots are reserved. Dressed fire `onAction` and are otherwise inert; two slots are reserved. Dressed
with the menu's CRT language — scanlines over the strip and the GlitchText with the menu's CRT language — scanlines over the strip and the GlitchText
RGB pull-apart on labels and the panel outline during bursts. All RGB pull-apart on labels and the panel outline during bursts. All
@ -171,7 +196,15 @@ node dev/music.test.mjs # the shared music voice: guards + music.json
``` ```
`dev/test-game.html` boots straight into the GameScene (no menu click), `dev/test-game.html` boots straight into the GameScene (no menu click),
handy for manual testing of the flight feel. handy for manual testing of the flight feel. `dev/research-shot.html`
does the same but opens the Research console and starts a run, and
`dev/cdp-shot.mjs` waits for a readiness expression before screenshotting
through CDP (any Chromium-CDP browser on `127.0.0.1:9333`):
```sh
node dev/cdp-shot.mjs "http://127.0.0.1:8081/dev/research-shot.html" shot.png \
"window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" 90000
```
## Phaser ## Phaser

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -14,6 +14,7 @@
"reputation.json", "reputation.json",
"naming.json", "naming.json",
"research.json", "research.json",
"research/exploration.json",
"builds.json", "builds.json",
"actionbar.json", "actionbar.json",
"landing.json", "landing.json",

View File

@ -1,20 +1,29 @@
{ {
"_comment": "RESEARCH — time-based tech. The player runs at most `maxConcurrent` projects at a time (= 1: one thing at a time). Starting a project starts its clock; when `duration` (in `timeUnit`) has elapsed the project is complete and its `effects` apply. Research is the unlock gate: a project's `requires` lists the projects that must be complete first, and `unlocks` names what it opens up — buildable items (data/builds.json → `builds.<id>`) and follow-on research (this file → `projects.<id>`). Add a project = one entry under `projects` (the key is the project's id). Keys starting with `_` (like `_template`) are documentation, not data. The research state/UI is not implemented yet — this file is the data layer that will drive it.", "_comment": "RESEARCH — time-based tech, one project at a time (maxConcurrent). The command deck's RESEARCH button opens the full-screen research console (js/ui/ResearchWindow.js). Categories are the tab row; each category's TECH TREE lives in its own file under data/research/ — the file basename is the config section AND the category id (data/research/exploration.json → section 'exploration'). A tree is a branching DAG drawn top→down: `nodes` is a flat map (data order matters — roots/children lay out left-to-right in this order), each node's `requires` names the parents that must be RESEARCHED before it is. Research rules: start an available node → its `duration` (timeUnit) runs; when it completes the node is unlocked, its `effects` apply (the scene applies them — the seam), and its children become available. `unlocks` is the documented forward reference (builds.json ids + follow-on research) for the build system that comes later. `starting` names nodes a fresh run already owns. Keys starting with `_` are documentation, not data.",
"enabled": true,
"timeUnit": "seconds", "timeUnit": "seconds",
"maxConcurrent": 1, "maxConcurrent": 1,
"projects": {}, "defaultCategory": "exploration",
"_template": { "categories": [
"label": "Project Name", { "id": "exploration", "label": "Exploration", "accent": "#00e5ff" }
"description": "What this research is and why it matters.", ],
"duration": 300, "video": {
"requires": [], "_comment": "The console's left-panel feed: loops muted while the window is open. aspect is [w, h] — the file is 544×800 (a 2:3 portrait).",
"unlocks": { "file": "assets/videos/research-computer.mp4",
"builds": [], "aspect": [2, 3]
"research": []
}, },
"effects": {}, "fx": {
"theme": { "_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed.",
"color": "#00e5ff" "glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] },
} "sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 }
},
"_nodeTemplate": {
"label": "Tech name",
"description": "What this tech is and what it does — shown in the detail readout at the bottom of the console.",
"icon": "tether",
"duration": 120,
"requires": ["parent-tech-id"],
"unlocks": { "builds": [], "research": [] },
"effects": { "tether": { "level": 2 } }
} }
} }

View File

@ -0,0 +1,60 @@
{
"_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level1), see data/tether.json and docs/PROJECT_NOTES.md). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"starting": ["tether_l1"],
"nodes": {
"tether_l1": {
"label": "Tether Level 1",
"description": "Your first circle of open space: a level-1 tether anchored on the home world, a 5,120 m ring of flyable void. Everything inside the line is yours to work; everything beyond it is a wall. This is the tether you launched with.",
"icon": "tether",
"duration": 0,
"requires": [],
"unlocks": { "builds": [], "research": ["tether_l2", "signal_amp"] },
"effects": {}
},
"tether_l2": {
"label": "Tether Level 2",
"description": "A second stage of field power. The boundary ring pulls out to 6,400 m — the far reaches of a compact system drop inside your reach, and the keep-out wall moves with you.",
"icon": "tether",
"duration": 60,
"requires": ["tether_l1"],
"unlocks": { "builds": [], "research": ["tether_l3", "tether_anchors"] },
"effects": { "tether": { "level": 2 } }
},
"tether_l3": {
"label": "Tether Level 3",
"description": "Third stage: 8,000 m of tether. The outer belt of an average system sits comfortably inside the ring — less time parked on the line, more time working it.",
"icon": "tether",
"duration": 150,
"requires": ["tether_l2"],
"unlocks": { "builds": [], "research": ["tether_l4"] },
"effects": { "tether": { "level": 3 } }
},
"tether_l4": {
"label": "Tether Level 4",
"description": "Fourth stage: 10,000 m. At this level every object of a ten-body system — all the worlds and stations — lies inside one ring around your anchor, and the barrier stops meaning anything out here.",
"icon": "tether",
"duration": 300,
"requires": ["tether_l3"],
"unlocks": { "builds": [], "research": [] },
"effects": { "tether": { "level": 4 } }
},
"tether_anchors": {
"label": "Tether Anchoring",
"description": "Tether relays that bolt to worlds and stations you hold. Their zones stitch into your own — the union of every ring you anchor becomes open space, with no wall between them.",
"icon": "anchor",
"duration": 120,
"requires": ["tether_l2"],
"unlocks": { "builds": [], "research": [] },
"effects": { "capability": "tether-anchors" }
},
"signal_amp": {
"label": "Signal Amplification",
"description": "Booster coils for the ship's long-range array. Faint emissions — distant beacons, far stations, half-dead transponders — resolve at range, and scans return cleaner hits.",
"icon": "signal",
"duration": 60,
"requires": ["tether_l1"],
"unlocks": { "builds": [], "research": [] },
"effects": { "capability": "signal-amplification" }
}
}
}

98
dev/cdp-shot.mjs Normal file
View File

@ -0,0 +1,98 @@
/**
* Dev-only CDP screenshot harness (any Chromium-CDP browser: Chrome,
* Brave, Edge). Unlike `--headless --screenshot` (which fires at first
* paint), this waits for the page to signal readiness before capturing.
*
* node dev/cdp-shot.mjs <url> <out.png> [readyExpr] [timeoutMs] [beforeCaptureExpr]
*
* readyExpr is a JS expression evaluated repeatedly in the page; the shot
* is taken when it returns a truthy value (default: none capture
* shortly after load). The expression's truthy value is printed.
* beforeCaptureExpr (optional) runs once right before the capture (e.g.
* to hide dev overlays).
*
* Requires a headless browser exposing CDP on 127.0.0.1:9333:
* /opt/brave.com/brave/brave --headless --disable-gpu \
* --remote-debugging-port=9333 about:blank
* (or point CDP_PORT at another one.)
*/
const [url, out, readyExpr, timeoutMs, beforeCaptureExpr] = process.argv.slice(2);
if (!url || !out) {
console.error('usage: node dev/cdp-shot.mjs <url> <out.png> [readyExpr] [timeoutMs]');
process.exit(2);
}
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
const deadline = Date.now() + Number(timeoutMs ?? 60000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
// Pick the first page target (or open one).
let target = await (await fetch(`http://${CDP}/json/list`)).json()
.then((ts) => ts.find((t) => t.type === 'page'));
if (!target) {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
}
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
const events = [];
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?')));
});
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
} else if (msg.method) {
events.push(msg);
}
};
await send('Page.enable');
await send('Runtime.enable');
await send('Page.navigate', { url });
const loaded = new Promise((res) => {
const t = setInterval(() => {
if (events.some((e) => e.method === 'Page.loadEventFired')) { clearInterval(t); res(); }
}, 50);
setTimeout(() => { clearInterval(t); res(); }, 20000);
});
await loaded;
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
let ready = null;
if (readyExpr) {
while (Date.now() < deadline) {
ready = await evaluate(readyExpr).catch(() => null);
if (ready) break;
await sleep(200);
}
} else {
await sleep(1000);
}
if (beforeCaptureExpr) await evaluate(beforeCaptureExpr).catch(() => null);
// One more beat so any final paint lands.
await sleep(250);
const shot = await send('Page.captureScreenshot', { format: 'png' });
const buf = Buffer.from(shot.data, 'base64');
await (await import('node:fs/promises')).writeFile(out, buf);
ws.close();
console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 400)}` : ''));
}
main().catch((err) => { console.error('cdp-shot failed:', err.message); process.exit(1); });

View File

@ -1,28 +1,32 @@
/** /**
* Research & build data-layer test (dev tool, run with Node no browser): * Research data-layer test (dev tool, run with Node no browser):
* *
* node dev/research-builds.test.mjs * node dev/research-builds.test.mjs
* *
* The player's progression loop has two halves research (time-based, * Pins the contract behind the research console (the deck's RESEARCH
* one project at a time) and building (credits + minerals) and the * button js/ui/ResearchWindow.js) and the build system that follows it:
* command deck that will host them. The RULES are not implemented yet; * - the config files are registered in data/manifest.json;
* this test pins the data contract the future code will lean on: * - research.json carries the rules (time-based, one project at a time),
* - the three config files are registered in data/manifest.json; * the category registry, the video feed and the fx knobs;
* - the rule knobs exist (timeUnit, maxConcurrent = 1, resources); * - each category's tech tree (data/research/<id>.json) is a well-formed
* - projects/builds are typed, empty maps (no content yet); * DAG with typed nodes checked both as RAW JSON and through the real
* - the `_template` entries document every required field; * code (ResearchModel's loadCategory/issues/layoutTree/isAvailable);
* - the deck has exactly six slots in the right order. * - the player-progress rules (ResearchState) behave: starting techs
* pre-unlocked, availability, one at a time, save/restore round-trip;
* - builds.json + actionbar.json keep their contracts.
*/ */
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, '../data'); const root = join(__dirname, '..');
const dataDir = join(root, 'data');
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
const manifest = read('manifest.json'); const manifest = read('manifest.json');
const research = read('research.json'); const research = read('research.json');
const exploration = read('research/exploration.json');
const builds = read('builds.json'); const builds = read('builds.json');
const actionbar = read('actionbar.json'); const actionbar = read('actionbar.json');
@ -31,32 +35,146 @@ const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++; if (!cond) failures++;
}; };
const hex = /^#[0-9a-fA-F]{6}$/;
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 1. Manifest registration // 1. Manifest registration (section name = file basename)
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
for (const f of ['research.json', 'builds.json', 'actionbar.json']) { for (const f of ['research.json', 'research/exploration.json', 'builds.json', 'actionbar.json']) {
check(`manifest registers ${f}`, manifest.files.includes(f)); check(`manifest registers ${f}`, manifest.files.includes(f));
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 2. Research — time-based, one at a time // 2. research.json — the rules + the registry
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
check('research: player researches one thing at a time (maxConcurrent === 1)', research.maxConcurrent === 1); check('research: master switch present', typeof research.enabled === 'boolean');
check('research: timeUnit is a named unit', typeof research.timeUnit === 'string' && research.timeUnit.length > 0); check('research: time-based (timeUnit named)', typeof research.timeUnit === 'string' && research.timeUnit.length > 0);
check('research: projects is a map', !!research.projects && typeof research.projects === 'object' && !Array.isArray(research.projects)); check('research: one project at a time (maxConcurrent === 1)', research.maxConcurrent === 1);
check('research: no projects yet (empty map)', Object.keys(research.projects ?? {}).filter((k) => !k.startsWith('_')).length === 0); check('research: categories is a non-empty array', Array.isArray(research.categories) && research.categories.length > 0);
check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? '')));
check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory));
const rt = research._template ?? {}; // every category in the registry has its own tree file in the manifest
for (const k of ['label', 'description', 'duration', 'requires', 'unlocks', 'effects', 'theme']) { const missing = research.categories.filter((c) => !manifest.files.includes(`research/${c.id}.json`));
check(`research._template documents "${k}"`, k in rt); check('research: every registered category has a tree file', missing.length === 0);
// the video feed (a 2:3 portrait loop)
check('research: video file configured', typeof research.video?.file === 'string' && research.video.file.length > 0);
check('research: video file exists on disk', fs.existsSync(join(root, research.video?.file ?? '')));
check('research: video aspect is [w, h] > 0', Array.isArray(research.video?.aspect) && research.video.aspect.every((n) => typeof n === 'number' && n > 0));
check('research: fx knobs present', !!research.fx?.glitch && !!research.fx?.sweep);
// ----------------------------------------------------------------------
// 3. The exploration tree — RAW JSON contract
// ----------------------------------------------------------------------
const nodes = exploration.nodes ?? {};
check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Object.keys(nodes).length > 0);
check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id]));
check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1'));
const ICON_NAMES = ['tether', 'anchor', 'signal', 'diamond']; // mirrors js/research/ResearchIcons.js
let nodeFieldsOk = true;
let requiresOk = true;
let unlocksOk = true;
let iconOk = true;
for (const [id, n] of Object.entries(nodes)) {
if (!n || typeof n !== 'object') { nodeFieldsOk = false; continue; }
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) nodeFieldsOk = false;
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && nodes[r])) requiresOk = false;
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) unlocksOk = false;
if (!ICON_NAMES.includes(n.icon)) iconOk = false;
} }
check('research._template.duration is a positive number', typeof rt.duration === 'number' && rt.duration > 0); check('exploration: every node has label/description/icon/duration', nodeFieldsOk);
check('research._template.requires is an array', Array.isArray(rt.requires)); check('exploration: every `requires` names a node in the same tree', requiresOk);
check('research._template.unlocks names builds[] + research[]', Array.isArray(rt.unlocks?.builds) && Array.isArray(rt.unlocks?.research)); check('exploration: every node documents unlocks {builds, research}', unlocksOk);
check('exploration: icons use the procedural glyph set', iconOk);
// no cycles (iterative DFS, three-color)
const color = new Map();
let cyclic = false;
for (const id of Object.keys(nodes)) {
if (color.has(id)) continue;
const stack = [[id, (nodes[id].requires ?? []).filter((r) => nodes[r])]];
color.set(id, 1);
while (stack.length) {
const [cur, kids] = stack[stack.length - 1];
if (kids.length) {
const next = kids[0];
stack[stack.length - 1][1] = kids.slice(1);
if (color.get(next) === 1) { cyclic = true; break; }
if (!color.has(next)) {
color.set(next, 1);
stack.push([next, (nodes[next].requires ?? []).filter((r) => nodes[r])]);
}
} else {
color.set(cur, 2);
stack.pop();
}
}
if (cyclic) break;
}
check('exploration: the tree is a DAG (no cycles)', !cyclic);
check('exploration: has at least one root (a node with no requires)', Object.values(nodes).some((n) => !(n.requires ?? []).length));
// tether level chain: each level's effect names the next level up
const levelsOk = [2, 3, 4].every((lvl) => {
const n = nodes[`tether_l${lvl}`];
return n && n.effects?.tether?.level === lvl;
});
check('exploration: tether_l2/l3/l4 each raise the tether to their level', levelsOk);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 3. Builds — credits + minerals // 4. The REAL code path — ResearchModel + ResearchState (pure modules)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, builds, actionbar });
const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
check('model: categories() resolves the registry', categories().some((c) => c.id === 'exploration'));
const tree = loadCategory('exploration');
check('model: loadCategory returns the exploration tree', !!tree && tree.nodes.tether_l1 && tree.order.length === Object.keys(nodes).length);
check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).length === 0);
const layout = layoutTree(tree);
check('model: layout rows = 4 (l1 → l2/anchor/signal → l3 → l4)', layout.rows === 4);
check('model: layout levels — l1 root, l2/signal tier 1, l3/anchors tier 2, l4 tier 3',
layout.level.tether_l1 === 0 && layout.level.tether_l2 === 1 && layout.level.signal_amp === 1 &&
layout.level.tether_l3 === 2 && layout.level.tether_anchors === 2 && layout.level.tether_l4 === 3);
check('model: layout is deterministic (same tree → same columns)', JSON.stringify(layout.col) === JSON.stringify(layoutTree(tree).col));
// a fresh run: starting techs pre-unlocked, the rest follow the rules
const state = new ResearchState();
for (const id of tree.starting) state.unlock(tree.id, id);
check('state: a fresh run already owns Tether Level 1', state.isUnlocked('exploration', 'tether_l1'));
check('state: Tether Level 2 is the FIRST thing to research', isAvailable(tree, state, 'tether_l2'));
check('state: Signal Amplification is also available (requires only L1)', isAvailable(tree, state, 'signal_amp'));
check('state: Tether Level 3 waits on L2 (locked)', !isAvailable(tree, state, 'tether_l3'));
check('state: missingRequires names the parents (L3 ← L2)', JSON.stringify(missingRequires(tree, state, 'tether_l3')) === JSON.stringify(['tether_l2']));
// one project at a time — the in-flight slot
check('state: start() claims the slot', state.start('exploration', 'tether_l2', 60_000, 0) === true);
check('state: a second start() is refused while one runs', state.start('exploration', 'signal_amp', 60_000, 0) === false);
check('state: progress runs 0→1 over the duration', state.progress(0).fraction === 0 && state.progress(60_000).fraction === 1);
check('state: tick() reports completion at the deadline', state.tick(59_999).length === 0 && state.tick(60_000).length === 1);
check('state: L2 is researched after completion', state.isUnlocked('exploration', 'tether_l2'));
check('state: L3 + Tether Anchoring open up next', isAvailable(tree, state, 'tether_l3') && isAvailable(tree, state, 'tether_anchors'));
// save/restore round-trip — the in-flight project keeps its remaining time
const state2 = new ResearchState();
for (const id of tree.starting) state2.unlock(tree.id, id);
state2.start('exploration', 'tether_l2', 60_000, 1_000);
const saved = state2.toJSON(10_000); // saved 9s into a 60s project
const restored = new ResearchState();
for (const k of saved.unlocked) restored.unlock(...k.split('::'));
restored.restoreActive(saved.active, 500_000); // reloaded an hour later
check('state: save carries the remaining time (60s 9s = 51s)', saved.active?.remainingMs === 51_000);
check('state: restore keeps the project in flight', restored.getActive() !== null && restored.getActive().id === 'tether_l2');
check('state: restored project finishes 51s after the load', restored.progress(500_000).fraction < 0.2 && restored.progress(551_000).fraction >= 1);
check('state: an old save without research restores as a fresh start', (() => { const s = new ResearchState(); s.restoreActive(null, 0); return s.getActive() === null; })());
// ----------------------------------------------------------------------
// 5. Builds — credits + minerals (the next deck feature)
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string'); check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string');
check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string'); check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string');
@ -73,13 +191,12 @@ check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'bool
check('builds._template.requires is an array', Array.isArray(bt.requires)); check('builds._template.requires is an array', Array.isArray(bt.requires));
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 4. Command deck — six evenly spaced slots, right order // 6. Command deck — six evenly spaced slots, right order
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const slots = actionbar.buttons ?? []; const slots = actionbar.buttons ?? [];
check('actionbar: exactly six slots', slots.length === 6); 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: 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: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu']));
const hex = /^#[0-9a-fA-F]{6}$/;
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); 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: 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'); check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');

22
dev/research-shot.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Research 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/research-shot.mjs"></script>
<!-- Holds the `load` event (when --screenshot fires) until the boot,
window-open, research-start and report paint have all landed.
Served by dev/slow-server.mjs. On a plain static server this is
just a 404 script — the shot still works, only the timing drifts. -->
<script defer src="/sleep?ms=7500"></script>
</body>
</html>

96
dev/research-shot.mjs Normal file
View File

@ -0,0 +1,96 @@
/**
* Dev-only: boot GameScene and open the RESEARCH console (depth 80) so a
* headless screenshot shows the whole feature left video feed (muted
* loop), category tabs, the topdown tech tree (Tether Level 1 owned
* Level 2 + Signal Amplification available), the detail readout with the
* RESEARCH button, and a project started in flight (deck bar + status).
*
* node dev/slow-server.mjs 8081 # serves / + /sleep?ms=N
* firefox --headless --screenshot RESEARCH_SHOT.png \
* --window-size=1280,720 \
* "http://127.0.0.1:8081/dev/research-shot.html"
*
* The page carries a defer'd <script src="/sleep?ms=7000"> the `load`
* event (when --screenshot fires) is held until AFTER the boot, the
* window open, the research start and the report paint (dev/slow-server).
* The report (top-left) lists console errors and the research 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.researchWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
try {
const s = await waitScene();
setReport(['GAME SCENE BOOTED', errors.length ? errors.slice(0, 3).join('\n') : 'no console errors']);
await sleep(800); // let the boot settle (dossier decode, deck flicker)
// Open the console (the deck RESEARCH button does exactly this).
s.deckAction('research');
await sleep(700); // the boot reveal finishes
// Start the first researchable project — the window offers Tether L2.
s.beginResearch('exploration', 'tether_l2');
await sleep(400);
// The report: errors + the research state + the window's paint states.
const win = s.researchWindow;
const st = s.researchState;
const entry = win.trees.get('exploration');
const nodeStates = entry
? entry.tree.order.map((id) => `${id}=${win._nodeState(id)}`).join(' ')
: 'NO TREE';
const p = st.progress(s.time.now);
const lines = [
errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 3).join('\n')}`,
`window: ${win.openState} · cat=${win.activeCat} · sel=${win.selected ? win.selected.category + '/' + win.selected.id : '—'}`,
`state: ${st.unlocked.size} unlocked · active=${st.getActive() ? st.getActive().category + '/' + st.getActive().id : '—'}`,
p ? `progress: ${Math.round(p.fraction * 100)}%` : 'progress: —',
`nodes: ${nodeStates}`,
`video: ${win.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL'}`,
];
setReport(lines);
window.__RESEARCH_SHOT = { ready: true, lines, errors };
console.info('research-shot: report painted');
} catch (err) {
errors.push(`FATAL: ${err.message}`);
setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
window.__RESEARCH_SHOT = { ready: true, fatal: String(err.message), errors };
console.error('research-shot: fatal', err);
}

View File

@ -405,6 +405,7 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
audio: (key, url) => s.audioQueued.push([key, url]), audio: (key, url) => s.audioQueued.push([key, url]),
spritesheet: () => {}, spritesheet: () => {},
image: () => {}, image: () => {},
video: (key, url) => (s.videoQueued ??= []).push([key, url]), // the research console's archive feed
}; };
s.scale = { width: 1280, height: 720 }; s.scale = { width: 1280, height: 720 };
s.time.delayedCall = (_ms, fn) => { fn(); return {}; }; // toast lifetime: instant in the harness s.time.delayedCall = (_ms, fn) => { fn(); return {}; }; // toast lifetime: instant in the harness

View File

@ -259,6 +259,69 @@ the seams are in place.
no camera math beyond culling. The scene drives `tick()`/`draw()` from no camera math beyond culling. The scene drives `tick()`/`draw()` from
`update()` alongside the TimeClock/tween stepping. `update()` alongside the TimeClock/tween stepping.
## Research — the progression gate (time-based, one at a time)
The RESEARCH deck slot opens the **Research console** (`js/ui/ResearchWindow.js`):
left pane loops the muted `assets/videos/research-computer.mp4` archive feed
(scanlines, sweep band, REC pulse, ambient glitch bursts, decode-in reveals),
right pane holds the category tabs + the **branching tech tree** for the
selected category (starts at the top, unlocks downward), and the selected
tech's dossier (icon, description, duration) with a RESEARCH button that is
only present when the tech is researchable and nothing is in progress.
**Data (one file per category):**
- `data/research.json` — global rules: `enabled`, `timeUnit: "seconds"`,
`maxConcurrent: 1`, `defaultCategory`, the `categories` registry (id,
label, icon, accent), the `video` (file + aspect), and `fx` timing.
- `data/research/<category>.json` — the tree: a flat `nodes` map where each
node has `label`, `description`, `duration` (in `timeUnit`), `requires`
(parent ids — a DAG), and optional `effects`. `starting` lists the
pre-unlocked roots. Section name = file basename
(`research/exploration.json` → `config.section('exploration')`).
**Add a category = one file + one line in `research.json → categories`
+ one line in `data/manifest.json`.**
**Code layering (same rules as the tether):**
- `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues`
(DAG validation), `levels` (longest-path level), `layoutTree`
(deterministic column/row layout: DFS leaf-slot assignment, parent =
mean of children), `isAvailable`, `missingRequires`. Node-tested.
- `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`,
`start`, `progress(time)`, `tick(time)` (→ array of completions),
`restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested.
- `js/research/ResearchIcons.js` — procedural 128 px icon textures
(tether rings / anchor / signal waves / diamond fallback), tinted.
- `js/ui/ResearchWindow.js` — the scene-facing window (depth 80, above the
save panel). A **passive view**: it asks `GameScene` to start a run via
`onResearch(catId, id)`; the scene owns the rules, the effects, the
toasts, and the save data. The window re-renders from
`ResearchState` + `ResearchModel` only.
- `GameScene``beginResearch`, `_completeResearch`,
`_applyResearchEffects`, `_deckResearchBar`. The **effects seam** reads
`node.effects`: `{ tether: { level: N } }``TetherField.setLevel(homeId, N)`
+ toast; `{ capability: "flag" }``scene.researchCapabilities.add(flag)`;
unknown shapes log and no-op. New effect kinds plug in there without
touching tree data.
**Save:** `record.research = { unlocked: ["cat::id", …], active:
{category, id, durationMs, remainingMs} | null }`. `remainingMs` is captured
at save time; `restoreActive(spec, now)` rebuilds `startedAt` from the fresh
`now`. A save from before research exists loads as fresh (no unlocks, no
active run) — old saves keep working. No auto-save: research state persists
on the next explicit player save (SavePanel), consistent with the rest of
the game.
**SFX:** begin → `construct`, complete → `discovery` (both existing
`data/sfx.json` keys — there are no `research_begin`/`research_complete`
keys). Open/close → `ui_window`/`ui_close`.
**Verified:** `dev/research-builds.test.mjs` (manifest registration,
research.json globals, the exploration tree's raw-JSON contract — DAG, node
fields, effects — the real code path: ResearchModel layout/levels/determinism
+ ResearchState start/tick/complete/restore round-trip, builds.json,
actionbar.json). `dev/research-shot.html` + `dev/cdp-shot.mjs` open the
window and start a run through CDP for a screenshot.
## Reputation — standing on planets & space stations (data layer; factions later) ## Reputation — standing on planets & space stations (data layer; factions later)
The player holds a REPUTATION (standing) on each planet and space station: The player holds a REPUTATION (standing) on each planet and space station:
@ -468,9 +531,21 @@ The player holds a REPUTATION (standing) on each planet and space station:
clips are actually selected per world. intro-01.mp3 is parked for clips are actually selected per world. intro-01.mp3 is parked for
the intro sequence that hasn't been built yet the intro sequence that hasn't been built yet
(js/scenes/GameScene.js → setMiningLoop, data/sfx.json) (js/scenes/GameScene.js → setMiningLoop, data/sfx.json)
- [ ] Tether progression: the build/research verbs that anchor tethers on - [x] Research rules + panel: start a project (one at a time), tick its
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange` duration, award `unlocks`; the Research slot on the deck opens the
seams are in place; the costs + panel + research gate come next) console window — looping archive feed, category tabs, the branching
tech tree, per-tech dossier + RESEARCH button, deck progress bar.
Pure rules/state in `js/research/` (Node-tested), the window is a
passive view, `effects` carry the payload (e.g. `tether.level` grows
the home tether). First category **Exploration** is live (Tether
Level 14, Tether Anchoring, Signal Amplification); add a category =
one JSON file + one line in the registry + one line in the manifest
(`js/ui/ResearchWindow.js`, `js/research/*`, `data/research.json`,
`data/research/exploration.json`, dev/research-builds.test.mjs)
- [ ] Tether progression (research side is done; the build side is next):
anchor tethers on planets/stations and upgrade levels beyond the
Exploration tree (the `add`/`setLevel`/`onChange` seams are in place;
the costs + build panel come next)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders, - [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy (the reputation layer and the player's place in a populated galaxy (the reputation layer
already resolves standing through `owner` — `Reputation. already resolves standing through `owner` — `Reputation.
@ -494,13 +569,11 @@ The player holds a REPUTATION (standing) on each planet and space station:
one at a time, gates builds/research) and building (credits + minerals, one at a time, gates builds/research) and building (credits + minerals,
ship/planet/station) data layers (`data/research.json`, ship/planet/station) data layers (`data/research.json`,
`data/builds.json`) with templates, and the command deck `data/builds.json`) with templates, and the command deck
(`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Build, (`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Scan, Ship,
Ship, ·, ·, Menu. The deck reuses the menu's CRT language: the same ·, ·, Menu. The deck reuses the menu's CRT language: the same
scanline tile recipe as `CyberOverlay` (clipped to the bar) and the scanline tile recipe as `CyberOverlay` (clipped to the bar) and the
`GlitchText` RGB pull-apart (icons + labels at all times, panel outline `GlitchText` RGB pull-apart (icons + labels at all times, panel outline
during bursts) — see `actionbar.animation.rgb` / `actionbar.scanline` during bursts) — see `actionbar.animation.rgb` / `actionbar.scanline`
- [ ] Research rules + panel: start a project (one at a time), tick its
duration, award `unlocks`; the Research slot on the deck opens it
- [ ] Build panel: pay credits/minerals, apply `effects`, respect - [ ] Build panel: pay credits/minerals, apply `effects`, respect
`requires`; the Build slot on the deck opens it; a credits/minerals `requires`; the Build slot on the deck opens it; a credits/minerals
readout in the HUD readout in the HUD

View File

@ -0,0 +1,136 @@
/**
* ResearchIcons procedural tech glyphs for the tree nodes and the detail
* readout (128×128 canvas textures, neon stroke + soft glow, transparent
* background). Names come from each node's `icon` key; unknown names fall
* back to `diamond`. Tintable at use (Phaser tint multiplies).
*/
import { toColor } from '../utils/Color.js';
export const ICON_NAMES = ['tether', 'anchor', 'signal', 'diamond'];
export function iconKey(name, color = 0x00e5ff) {
const n = ICON_NAMES.includes(name) ? name : 'diamond';
const c = toColor(color);
return `__research_icon_${n}_${c.toString(16).padStart(6, '0')}`;
}
export function ensureIcon(scene, name, color = 0x00e5ff) {
const key = iconKey(name, color);
if (scene.textures.exists(key)) return key;
const S = 128;
const canvas = document.createElement('canvas');
canvas.width = S;
canvas.height = S;
const ctx = canvas.getContext('2d');
const hex = `#${toColor(color).toString(16).padStart(6, '0')}`;
const cx = S / 2;
const cy = S / 2;
ctx.clearRect(0, 0, S, S);
ctx.strokeStyle = hex;
ctx.fillStyle = hex;
ctx.shadowColor = hex;
ctx.shadowBlur = 9;
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const diamond = (x, y, r) => {
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
ctx.fill();
};
const n = ICON_NAMES.includes(name) ? name : 'diamond';
if (n === 'tether') {
// Concentric tether rings + core + tick marks + a satellite on the mid ring.
const rings = [
[52, 0.5],
[38, 0.75],
[24, 1.0],
];
for (const [r, a] of rings) {
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(cx, cy, 7, 0, Math.PI * 2);
ctx.fill();
for (const a of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 55, cy + Math.sin(a) * 55);
ctx.lineTo(cx + Math.cos(a) * 61, cy + Math.sin(a) * 61);
ctx.stroke();
}
diamond(cx + 38, cy, 8);
} else if (n === 'anchor') {
// Anchor ring + stock + crossbar + flukes (the relay bolted to a body).
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, 46, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - 24);
ctx.lineTo(cx, cy + 20);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy - 24, 7, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx - 15, cy - 8);
ctx.lineTo(cx + 15, cy - 8);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy + 4, 20, Math.PI * 0.12, Math.PI * 0.88);
ctx.stroke();
diamond(cx - 19, cy + 15, 6);
diamond(cx + 19, cy + 15, 6);
} else if (n === 'signal') {
// Long-range array: source dot + rising arcs.
ctx.beginPath();
ctx.arc(cx, cy + 22, 7, 0, Math.PI * 2);
ctx.fill();
const arcs = [
[19, 0.95],
[33, 0.7],
[47, 0.45],
];
for (const [r, a] of arcs) {
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(cx, cy + 22, r, -Math.PI * 0.82, -Math.PI * 0.18);
ctx.stroke();
}
ctx.globalAlpha = 1;
} else {
// diamond — the default glyph
ctx.beginPath();
ctx.moveTo(cx, cy - 42);
ctx.lineTo(cx + 42, cy);
ctx.lineTo(cx, cy + 42);
ctx.lineTo(cx - 42, cy);
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.moveTo(cx, cy - 22);
ctx.lineTo(cx + 22, cy);
ctx.lineTo(cx, cy + 22);
ctx.lineTo(cx - 22, cy);
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 1;
}
scene.textures.addCanvas(key, canvas);
return key;
}

View File

@ -0,0 +1,179 @@
/**
* Research pure model: category registry, tree loading/validation, and the
* deterministic topdown tree layout. No Phaser, no DOM node-testable
* (dev/research-builds.test.mjs). The browser-side ResearchWindow.js reads
* these; ResearchState.js holds the player's progress (also pure).
*
* Tree contract (one JSON per category, section named after the category id):
* starting: ["root-id"] already-owned nodes (fresh run)
* nodes: { id: { label, description, icon, duration, requires: [ids],
* unlocks: { builds, research }, effects } }
* Layout: level(id) = 0 for roots, else 1 + max(level(requires)). Columns:
* leaves take sequential slots in DFS order (data order of roots, then
* children), each interior node sits at the mean of its children's columns
* a tidy, stable arrangement for a small tech tree.
*/
import { config } from '../config/Config.js';
/** The category registry (data/research.json → categories[]). */
export function categories() {
const list = config.get('research.categories', []);
return Array.isArray(list) ? list.filter((c) => c && typeof c.id === 'string') : [];
}
/** Load one category's tree. Returns { id, label, accent, nodes, order, starting } or null. */
export function loadCategory(catId) {
const meta = categories().find((c) => c.id === catId);
if (!meta) return null;
const section = config.section(catId, {});
const raw = (section && section.nodes) || {};
const nodes = {};
for (const [id, n] of Object.entries(raw)) {
if (id.startsWith('_') || !n || typeof n !== 'object') continue;
nodes[id] = n;
}
const order = Object.keys(nodes);
if (order.length === 0) return null;
const starting = Array.isArray(section.starting) ? section.starting.filter((id) => nodes[id]) : [];
return {
id: catId,
label: typeof meta.label === 'string' ? meta.label : catId,
accent: typeof meta.accent === 'string' ? meta.accent : '#00e5ff',
nodes,
order,
starting,
};
}
/** Root nodes (no requires). */
export function roots(tree) {
return tree.order.filter((id) => {
const r = tree.nodes[id].requires;
return !Array.isArray(r) || r.length === 0;
});
}
/**
* Structural issues (empty array = well-formed): dangling requires, cycles,
* no root. The tree must be a DAG rooted at one or more roots for the
* topdown layout to be meaningful.
*/
export function issues(tree) {
const out = [];
const known = new Set(tree.order);
for (const id of tree.order) {
for (const r of tree.nodes[id].requires ?? []) {
if (!known.has(r)) out.push(`${id} requires unknown node "${r}"`);
}
}
// Cycle check (iterative DFS, three-color).
const color = new Map(); // 0/absent white, 1 gray, 2 black
const dfs = (start) => {
const stack = [[start, (tree.nodes[start].requires ?? []).filter((r) => known.has(r))]];
color.set(start, 1);
while (stack.length) {
const [id, kids] = stack[stack.length - 1];
if (kids.length) {
const next = kids[0];
stack[stack.length - 1][1] = kids.slice(1);
if (color.get(next) === 1) {
out.push(`cycle: ${id}${next}`);
return;
}
if (!color.has(next)) {
color.set(next, 1);
stack.push([next, (tree.nodes[next].requires ?? []).filter((r) => known.has(r))]);
}
} else {
color.set(id, 2);
stack.pop();
}
}
};
for (const id of tree.order) if (!color.has(id)) dfs(id);
if (roots(tree).length === 0) out.push('tree has no root node');
return out;
}
/** level(id): 0 for roots, else 1 + max(level of its requires). Cycle-safe. */
export function levels(tree) {
const lvl = {};
const visiting = new Set();
const compute = (id) => {
if (lvl[id] !== undefined) return lvl[id];
if (visiting.has(id)) return 0; // cycle guard — issues() reports these
visiting.add(id);
const reqs = (tree.nodes[id].requires ?? []).filter((r) => lvl[r] !== undefined || tree.nodes[r]);
const v = reqs.length ? Math.max(...reqs.map(compute)) + 1 : 0;
visiting.delete(id);
lvl[id] = v;
return v;
};
for (const id of tree.order) compute(id);
return lvl;
}
/**
* Tidy layout. Returns {
* col: { id: number }, column slot per node (fractional for parents)
* minCol, maxCol, spread ( 0; 0 = single column)
* level: { id: number }, 0 = top row
* rows, number of rows (max level + 1)
* }.
*/
export function layoutTree(tree) {
const level = levels(tree);
const known = new Set(tree.order);
const children = new Map(); // parent → [child ids, data order]
for (const id of tree.order) {
for (const r of tree.nodes[id].requires ?? []) {
if (!known.has(r)) continue;
if (!children.has(r)) children.set(r, []);
children.get(r).push(id);
}
}
const col = new Map();
let slot = 0;
const assign = (id, guard) => {
if (col.has(id)) return col.get(id);
guard.add(id);
const kids = (children.get(id) ?? []).filter((c) => !guard.has(c) && known.has(c));
const v = kids.length
? kids.map((c) => assign(c, guard)).reduce((a, b) => a + b, 0) / kids.length
: slot++;
guard.delete(id);
col.set(id, v);
return v;
};
for (const id of roots(tree)) assign(id, new Set());
for (const id of tree.order) if (!col.has(id)) col.set(id, slot++); // cycle residue
const values = [...col.values()];
const maxLevel = Math.max(...Object.values(level));
return {
col: Object.fromEntries(col),
minCol: Math.min(...values),
maxCol: Math.max(...values),
level,
rows: maxLevel + 1,
};
}
/**
* Can this node be researched right now? (known + all parents researched +
* not already researched.) `state` is a ResearchState (or anything with
* isUnlocked(category, id)).
*/
export function isAvailable(tree, state, id) {
const n = tree.nodes[id];
if (!n) return false;
if (state.isUnlocked(tree.id, id)) return false;
return (n.requires ?? []).every((r) => tree.nodes[r] && state.isUnlocked(tree.id, r));
}
/** The parents this node is still missing (for the LOCKED readout). */
export function missingRequires(tree, state, id) {
return (tree.nodes[id]?.requires ?? []).filter(
(r) => !tree.nodes[r] || !state.isUnlocked(tree.id, r),
);
}

View File

@ -0,0 +1,115 @@
/**
* ResearchState the player's research progress: the set of researched
* (unlocked) techs + the one in-flight project (maxConcurrent: 1 is baked
* into the shape there is exactly one `active` slot).
*
* Pure: no Phaser, no config, no DOM node-testable. Durations are passed
* in as milliseconds by the caller (which reads the node data + research.
* timeUnit). Save format (record.research):
* { unlocked: ["exploration::tether_l1", ...],
* active: { category, id, durationMs, remainingMs } | null }
* `remainingMs` is captured at save time; on load restoreActive() rebuilds
* the startedAt clock from a fresh `now`.
*/
const key = (category, id) => `${category}::${id}`;
export class ResearchState {
constructor() {
this.unlocked = new Set(); // "category::id"
this.active = null; // { category, id, startedAt, durationMs }
}
unlock(category, id) {
this.unlocked.add(key(category, id));
}
isUnlocked(category, id) {
return this.unlocked.has(key(category, id));
}
/** The in-flight project, or null. */
getActive() {
return this.active;
}
/**
* Start a project. Fails (returns false) if one is already running or the
* node is already researched. durationMs may be 0 (grants instantly
* completes on the next tick). `now` = the scene clock (ms).
*/
start(category, id, durationMs, now) {
if (this.active) return false;
if (this.unlocked.has(key(category, id))) return false;
this.active = { category, id, startedAt: now, durationMs: Math.max(0, Number(durationMs) || 0) };
return true;
}
/** { category, id, fraction: 0..1 } for the in-flight project, or null. */
progress(time) {
const a = this.active;
if (!a) return null;
const f = a.durationMs > 0 ? (time - a.startedAt) / a.durationMs : 1;
return { category: a.category, id: a.id, fraction: Math.min(1, Math.max(0, f)) };
}
/**
* Advance the clock. Returns the array of completed projects (at most one
* one at a time) and unlocks them. The scene applies their effects + toasts.
*/
tick(time) {
if (!this.active) return [];
if (this.progress(time).fraction >= 1) {
const done = { category: this.active.category, id: this.active.id };
this.unlocked.add(key(done.category, done.id));
this.active = null;
return [done];
}
return [];
}
/**
* Restore an in-flight project from a save: `spec` =
* { category, id, durationMs, remainingMs }, remaining as of the save.
* Rebuilds startedAt so the project finishes at the same wall time.
*/
restoreActive(spec, now) {
if (!spec || typeof spec.category !== 'string' || typeof spec.id !== 'string') return;
const durationMs = Math.max(0, Number(spec.durationMs) || 0);
const remainingMs = Math.min(durationMs, Math.max(0, Number(spec.remainingMs) || 0));
this.active = {
category: spec.category,
id: spec.id,
startedAt: (now || 0) - (durationMs - remainingMs),
durationMs,
};
}
/** Snapshot for SaveManager (record.research). `now` = scene clock (ms). */
toJSON(now) {
return {
unlocked: [...this.unlocked],
active: this.active
? {
category: this.active.category,
id: this.active.id,
durationMs: this.active.durationMs,
remainingMs: Math.max(0, this.active.durationMs - ((now || 0) - this.active.startedAt)),
}
: null,
};
}
/** Rebuild the unlocked set from a save (active is restored via restoreActive — it needs a fresh clock). */
static fromJSON(json) {
const s = new ResearchState();
if (json && Array.isArray(json.unlocked)) {
for (const k of json.unlocked) if (typeof k === 'string') s.unlocked.add(k);
}
return s;
}
reset() {
this.unlocked.clear();
this.active = null;
}
}

View File

@ -11,6 +11,7 @@
* discovery: Discovery.toJSON(), * discovery: Discovery.toJSON(),
* reputation: Reputation.toJSON(), * reputation: Reputation.toJSON(),
* tethers: [{ id, x, y, level, label }], * tethers: [{ id, x, y, level, label }],
* research: ResearchState.toJSON() | null,
* playTimeMs } * playTimeMs }
* *
* captureState(scene) GameScene record (the Save panel calls it) * captureState(scene) GameScene record (the Save panel calls it)
@ -69,6 +70,12 @@ export function captureState(scene) {
level: t.level, level: t.level,
label: t.label ?? '', label: t.label ?? '',
})), })),
// Research — the unlocked set + the in-flight project (its remaining
// time is captured NOW; a save predating research has no field, and
// the restore treats the absence as "no research" (old saves load).
research: scene.researchState
? scene.researchState.toJSON(scene.time?.now ?? Date.now())
: null,
playTimeMs: Math.round(scene.playTimeMs ?? 0), playTimeMs: Math.round(scene.playTimeMs ?? 0),
}; };
const err = SaveManager.validateRecord(rec); const err = SaveManager.validateRecord(rec);
@ -106,6 +113,7 @@ export function prepareLoad(registry, record) {
registry.set(PENDING_RESTORE_KEY, { registry.set(PENDING_RESTORE_KEY, {
ship: record.ship, ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [], tethers: Array.isArray(record.tethers) ? record.tethers : [],
research: record.research ?? null,
playTimeMs: Number(record.playTimeMs) || 0, playTimeMs: Number(record.playTimeMs) || 0,
}); });
} }

View File

@ -28,6 +28,9 @@ import { MiningPopup } from '../ui/MiningPopup.js';
import { ScanPulse } from '../scan/ScanPulse.js'; import { ScanPulse } from '../scan/ScanPulse.js';
import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js'; import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js'; import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable } from '../research/ResearchModel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
@ -106,6 +109,21 @@ export class GameScene extends Phaser.Scene {
); );
} }
// The research console's archive feed (data/research.json → video):
// a muted 2:3 loop behind the RESEARCH window. A missing file just
// leaves the window's NO SIGNAL plate up — the console still works.
if (config.get('research.enabled', true)) {
const researchVideo = String(config.get('research.video.file') ?? '');
if (researchVideo) {
// The config stores the full relative path (like the sfx paths);
// a bare filename still works (→ assets/videos/<name>).
const url = /^(https?:)?\/\//.test(researchVideo) || researchVideo.startsWith('assets/')
? researchVideo
: `assets/videos/${researchVideo}`;
this.load.video(ResearchWindow.VIDEO_KEY, url);
}
}
// Sound effects (data/sfx.json → enabled). Skipped entirely when the // Sound effects (data/sfx.json → enabled). Skipped entirely when the
// master switch is off — no load cost, no files fetched. // master switch is off — no load cost, no files fetched.
if (config.get('sfx.enabled', true)) { if (config.get('sfx.enabled', true)) {
@ -406,6 +424,39 @@ export class GameScene extends Phaser.Scene {
onAction: (id, target) => this.commsAction(id, target), onAction: (id, target) => this.commsAction(id, target),
}); });
// ---- RESEARCH CONSOLE (the deck's RESEARCH button) ---------------------
// The rules live in data/research.json (+ one file per category under
// data/research/); the progress in ResearchState; the view in
// ResearchWindow (js/ui/ResearchWindow.js, depth 80). This scene owns
// the effects: tether level-ups, capability flags, toasts, the deck
// progress bar, and the save data (record.research).
this.researchState = new ResearchState();
for (const cat of categories()) {
const tree = loadCategory(cat.id);
if (!tree) continue;
for (const id of tree.starting) this.researchState.unlock(cat.id, id);
}
this.researchWindow = new ResearchWindow(this, {
state: this.researchState,
onResearch: (catId, id) => this.beginResearch(catId, id),
});
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
txt: this.add
.text(0, 0, '', {
fontFamily: fontStack('body'),
fontSize: '10px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
align: 'center',
})
.setOrigin(0.5, 1)
.setScrollFactor(0)
.setDepth(52)
.setVisible(false),
};
// ---- DEEP SCAN (the deck's SCAN button) ------------------------------- // ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull, // The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION // then an omnidirectional wavefront expanding across the TETHER REGION
@ -458,6 +509,9 @@ export class GameScene extends Phaser.Scene {
// The save pop-up is MODAL — while it's up it owns all input // The save pop-up is MODAL — while it's up it owns all input
// (its scrim / cards / dialog eat the click; the world stays put). // (its scrim / cards / dialog eat the click; the world stays put).
if (this.savePanel && this.savePanel.isOpen) return; if (this.savePanel && this.savePanel.isOpen) return;
// 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;
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of // 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 // its buttons — the buttons fire on their own pointerdown). ANY
// click OUTSIDE — world, deck, HUD, compass — folds it back down // click OUTSIDE — world, deck, HUD, compass — folds it back down
@ -884,6 +938,14 @@ export class GameScene extends Phaser.Scene {
this.menuSubBar?.update(_time, delta); this.menuSubBar?.update(_time, delta);
this.savePanel?.update(_time); this.savePanel?.update(_time);
this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker
// Research: tick the in-flight project (time-based; tick() reports any
// completion and already unlocked it — the scene applies effects/SFX).
const _researchDone = this.researchState?.tick(_time) ?? null;
if (_researchDone?.length) {
for (const d of _researchDone) this._completeResearch(d.category, d.id);
}
this.researchWindow?.update(_time); // the console's living details (open state)
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
// The clusters are ALIVE: each rock tumbles, the loose group drifts, // The clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate, // the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.) // after the physics step has moved the ship.)
@ -1517,6 +1579,17 @@ export class GameScene extends Phaser.Scene {
* slots are still seams for the player's loop. * slots are still seams for the player's loop.
*/ */
deckAction(id) { deckAction(id) {
// The research console (full-screen, depth 80) — the deck's main feature.
if (id === 'research') {
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
this.researchWindow?.open();
return;
}
if (id === 'menu') { if (id === 'menu') {
this.menuAction(); this.menuAction();
return; return;
@ -1528,6 +1601,129 @@ export class GameScene extends Phaser.Scene {
console.info(`[orbit] command deck: ${id}`); console.info(`[orbit] command deck: ${id}`);
} }
// ------------------------------------------------------------ research
/**
* Begin a research project (ResearchWindow's RESEARCH button here).
* The window only offers the button when the rules allow it; this is the
* scene-side enforcement one project at a time, available tech only
* then the state runs the clock (time-based, data/research.json rules).
*/
beginResearch(catId, id) {
const state = this.researchState;
if (!state) return;
if (state.getActive()) {
this.consoleToast('A RESEARCH PROJECT IS ALREADY IN PROGRESS', {
glyph: '◆',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
const tree = loadCategory(catId);
const node = tree?.nodes?.[id];
if (!node) return;
if (!isAvailable(tree, state, id)) {
this.consoleToast('THAT TECH IS NOT AVAILABLE YET', {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
const durMs = Math.max(0, Number(node.duration ?? 0) * 1000);
if (durMs <= 0) {
// Instant tech (duration 0) — applies the moment it's requested.
state.unlock(catId, id);
this._applyResearchEffects(node.effects, node);
this._completeResearchFx(catId, id, node);
return;
}
// The in-flight project rides the next explicit save (captureState
// includes record.research — the game's save model is player-chosen).
state.start(catId, id, durMs, this.time.now);
this.consoleToast(`RESEARCH INITIATED — ${String(node.label ?? id).toUpperCase()}`, {
glyph: '◆',
});
this.playSfx('construct'); // the power-up tick (data/sfx.json key 'construct')
this.researchWindow?.refresh();
}
/** A project's clock ran out (update's tick) — apply + celebrate. */
_completeResearch(catId, id) {
const state = this.researchState;
if (!state) return;
state.unlock(catId, id);
const node = loadCategory(catId)?.nodes?.[id];
if (node?.effects) this._applyResearchEffects(node.effects, node);
this._completeResearchFx(catId, id, node);
}
/** The completion ceremony: SFX, toast, the window repaint. */
_completeResearchFx(catId, id, node) {
this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json)
this.consoleToast(`RESEARCH COMPLETE — ${String(node?.label ?? id).toUpperCase()}`, {
glyph: '✓',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
this.researchWindow?.refresh();
}
/**
* Apply a tech's effects (node.effects, data/research/<cat>.json).
* tether { level: N } the home world's tether field strengthens
* capability "flag" a scene capability set (future systems read it)
* Unknown shapes are logged and skipped data can lead code a step.
*/
_applyResearchEffects(effects, node) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
const homeId = this.tetherField.tethers[0]?.id ?? 'home';
this.tetherField.setLevel(homeId, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else {
console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id);
}
}
}
/** The RESEARCH slot's progress bar (update() calls it every frame). */
_deckResearchBar(time) {
const bar = this._researchDeckBar;
if (!bar) return;
const p = this.researchState?.progress(time);
const slot = this.actionBar?.slots?.find((s) => s.id === 'research');
if (!p || !slot) {
if (bar.g.visible) bar.g.setVisible(false);
if (bar.txt.visible) bar.txt.setVisible(false);
return;
}
const style = this.actionBar.style;
const bw = style.bw;
const bh = style.bh;
const x = slot.slot.x - bw / 2;
const y = slot.slot.y - bh / 2;
const accent = toColor(themeColor('neon', 0x00e5ff));
const g = bar.g;
g.setVisible(true);
g.clear();
g.fillStyle(0x060a10, 0.85);
g.fillRect(x + 8, y - 11, bw - 16, 5);
g.fillStyle(accent, 0.95);
g.fillRect(x + 8, y - 11, (bw - 16) * p.fraction, 5);
const txt = bar.txt;
txt.setVisible(true);
txt.setText(`RESEARCHING ${Math.round(p.fraction * 100)}%`);
txt.setColor(toCss(accent));
txt.setPosition(slot.slot.x, y - 15);
}
/** /**
* The SCAN button: fire the deep-scan pulse from the ship across the * The SCAN button: fire the deep-scan pulse from the ship across the
* tether region. The sweep's TARGET SET is the discoverable objects * tether region. The sweep's TARGET SET is the discoverable objects
@ -1663,6 +1859,7 @@ export class GameScene extends Phaser.Scene {
this.savePanel.close(); this.savePanel.close();
return; return;
} }
if (this.researchWindow && this.researchWindow.isOpen) return; // the console owns input
if (this.menuSubBar && this.menuSubBar.isOpen) { if (this.menuSubBar && this.menuSubBar.isOpen) {
this.menuSubBar.close(); this.menuSubBar.close();
return; return;
@ -1688,7 +1885,7 @@ export class GameScene extends Phaser.Scene {
} }
} }
/** ESC: topmost open thing first — confirm dialog → pop-up → mining → sub-bar. */ /** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */
escAction() { escAction() {
if (this.savePanel) { if (this.savePanel) {
if (this.savePanel.confirm.isOpen) { if (this.savePanel.confirm.isOpen) {
@ -1700,6 +1897,11 @@ export class GameScene extends Phaser.Scene {
return; return;
} }
} }
// The research console (depth 80) — above everything else but the save UI.
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
// The mining context menu is the topmost open thing after the save UI. // The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) { if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close(); this.miningPopup.close();
@ -1764,6 +1966,18 @@ export class GameScene extends Phaser.Scene {
} }
} }
this.playTimeMs = Number(r.playTimeMs) || 0; this.playTimeMs = Number(r.playTimeMs) || 0;
// Research — the unlocked set + the in-flight project (fresh clock).
if (r.research && this.researchState) {
try {
const fresh = ResearchState.fromJSON(r.research);
this.researchState.reset();
this.researchState.unlocked = fresh.unlocked;
this.researchState.restoreActive(r.research.active ?? null, this.time.now);
this.researchWindow?.refresh();
} catch (e) {
console.warn('[orbit] restore: research skipped', e);
}
}
// The camera was centered on the spawn — recentre on the restored ship. // The camera was centered on the spawn — recentre on the restored ship.
this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2); this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2);
this.playSfx('construct'); this.playSfx('construct');
@ -1779,6 +1993,7 @@ export class GameScene extends Phaser.Scene {
this.savePanel?.destroy(); this.savePanel?.destroy();
this.miningPopup?.destroy(); this.miningPopup?.destroy();
this.commsPanel?.destroy(); this.commsPanel?.destroy();
this.researchWindow?.destroy(); // the console (video + UI)
this.mineralHud?.destroy(); this.mineralHud?.destroy();
this.mining?.destroy(); this.mining?.destroy();
this.scanPulse?.destroy(); this.scanPulse?.destroy();

1497
js/ui/ResearchWindow.js Normal file

File diff suppressed because it is too large Load Diff