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:
parent
407f6d6175
commit
7138d153fa
59
README.md
59
README.md
|
|
@ -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 1–4,
|
||||||
|
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.
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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^(level−1), 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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); });
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 top→down 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 1–4, 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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* Research — pure model: category registry, tree loading/validation, and the
|
||||||
|
* deterministic top→down 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
|
||||||
|
* top→down 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue