Compare commits
2 Commits
8c52105412
...
9299859003
| Author | SHA1 | Date |
|---|---|---|
|
|
9299859003 | |
|
|
2253251768 |
33
README.md
33
README.md
|
|
@ -68,6 +68,9 @@ 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
|
||||||
|
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
|
||||||
|
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu)
|
||||||
│ └── naming.json # syllable pools for names
|
│ └── naming.json # syllable pools for names
|
||||||
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
|
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
|
||||||
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
|
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
|
||||||
|
|
@ -78,7 +81,7 @@ orbit/
|
||||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||||
│ ├── 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
|
||||||
│ ├── ui/ # MenuButton, GlitchText, CyberShape (reusable)
|
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable)
|
||||||
│ ├── 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
|
||||||
|
|
@ -93,12 +96,40 @@ orbit/
|
||||||
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
|
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
|
||||||
- Phaser is imported only via `js/vendor/phaser.js` (one-file version swap).
|
- Phaser is imported only via `js/vendor/phaser.js` (one-file version swap).
|
||||||
|
|
||||||
|
## The player's loop: research + building
|
||||||
|
|
||||||
|
Beyond flying, Orbit is built around two progression verbs — both are data
|
||||||
|
layers now, with the rules and panels to come next:
|
||||||
|
|
||||||
|
- **Research** (`data/research.json`) — *time-based*. A project takes a fixed
|
||||||
|
`duration`; the player researches **one thing at a time**
|
||||||
|
(`maxConcurrent: 1`). Research is the gate: it unlocks **builds** and
|
||||||
|
**further research**. `projects` is an empty map for now; the `_template`
|
||||||
|
entry documents the shape each project must have.
|
||||||
|
- **Building** (`data/builds.json`) — *cost-based*, paid in **credits and
|
||||||
|
minerals** (`resources`). A build improves the **ship**, a **planet**, or a
|
||||||
|
**space station** (`category`). `builds` is an empty map; the `_template`
|
||||||
|
entry documents the shape (including `repeatable`, for things like extra
|
||||||
|
mining rigs).
|
||||||
|
- **The command deck** (`data/actionbar.json`, rendered by
|
||||||
|
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
|
||||||
|
Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots
|
||||||
|
fire `onAction` and are otherwise inert; two slots are reserved. Dressed
|
||||||
|
with the menu's CRT language — scanlines over the strip and the GlitchText
|
||||||
|
RGB pull-apart on icons, labels, and the panel outline during bursts. All
|
||||||
|
layout, palette, and motion (boot flicker, rail comet, glitch bursts, RGB
|
||||||
|
split, scanlines) live in the config.
|
||||||
|
|
||||||
|
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
|
||||||
|
runtime data — loaders and tests ignore them.
|
||||||
|
|
||||||
## Dev tools
|
## Dev tools
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
||||||
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
||||||
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
|
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
|
||||||
|
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
`dev/test-game.html` boots straight into the GameScene (no menu click),
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Six evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).",
|
||||||
|
"enabled": true,
|
||||||
|
"height": 92,
|
||||||
|
"margin": { "left": 20, "right": 20, "bottom": 12 },
|
||||||
|
"padding": 16,
|
||||||
|
"button": {
|
||||||
|
"widthFactor": 0.72,
|
||||||
|
"maxWidth": 190,
|
||||||
|
"notch": 10,
|
||||||
|
"iconSize": 26,
|
||||||
|
"fontSize": 14,
|
||||||
|
"letterSpacing": 2.5
|
||||||
|
},
|
||||||
|
"buttons": [
|
||||||
|
{ "id": "research", "label": "Research", "accent": "#00e5ff" },
|
||||||
|
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
|
||||||
|
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
|
||||||
|
{ "id": null, "label": null },
|
||||||
|
{ "id": null, "label": null },
|
||||||
|
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
|
||||||
|
],
|
||||||
|
"colors": {
|
||||||
|
"panelTop": "#101c36",
|
||||||
|
"panelBottom": "#04070e",
|
||||||
|
"slotBg": "#0b1322",
|
||||||
|
"slotBorder": "#22405f",
|
||||||
|
"rail": "#00e5ff",
|
||||||
|
"railBottom": "#ff2d6f",
|
||||||
|
"text": "#eaf6ff",
|
||||||
|
"reserved": "#3d4c74",
|
||||||
|
"hatch": "#78b4ff"
|
||||||
|
},
|
||||||
|
"scanline": {
|
||||||
|
"_comment": "CRT scanlines over the bar + buttons — the menu's recipe (one dark row per pitch rows). Defaults follow theme.overlay.scanline; override here for the deck only.",
|
||||||
|
"pitch": 3,
|
||||||
|
"alpha": 0.14
|
||||||
|
},
|
||||||
|
"animation": {
|
||||||
|
"bootStagger": 70,
|
||||||
|
"rgb": {
|
||||||
|
"_comment": "RGB pull-apart on icons/labels/panel (the menu title's GlitchText technique): idleOffset px of constant chromatic fringe, burstOffset extra px at full burst, idleAlpha/burstAlpha ghost strength.",
|
||||||
|
"idleOffset": 1.8,
|
||||||
|
"burstOffset": 6,
|
||||||
|
"idleAlpha": 0.4,
|
||||||
|
"burstAlpha": 0.55
|
||||||
|
},
|
||||||
|
"comet": {
|
||||||
|
"enabled": true,
|
||||||
|
"everyMs": [3600, 7800],
|
||||||
|
"durationMs": 950
|
||||||
|
},
|
||||||
|
"glitch": {
|
||||||
|
"enabled": true,
|
||||||
|
"intervalMs": [3500, 9000],
|
||||||
|
"durationMs": [240, 480],
|
||||||
|
"slices": [3, 6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"_comment": "BUILDABLE ITEMS — cost-based improvements. A build costs the `cost` below (in `resources`) and takes effect when purchased; it is only AVAILABLE once the research it `requires` is complete (data/research.json → `projects.<id>`). `category` says where it applies: `ship` = improve the player's ship, `planet` = improve things on planets, `station` = improve space stations, `general` = new capabilities for the player. `repeatable` builds can be bought more than once (treat as false when absent). Add a build = one entry under `builds` (the key is the build's id). Keys starting with `_` (like `_template`) are documentation, not data. The build state/UI is not implemented yet — this file is the data layer that will drive it.",
|
||||||
|
"resources": {
|
||||||
|
"credits": {
|
||||||
|
"label": "Credits",
|
||||||
|
"description": "Money — the galaxy's currency."
|
||||||
|
},
|
||||||
|
"minerals": {
|
||||||
|
"label": "Minerals",
|
||||||
|
"description": "Raw materials — mined from resource worlds."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"builds": {},
|
||||||
|
"_template": {
|
||||||
|
"label": "Buildable Item",
|
||||||
|
"description": "What this is and what it does.",
|
||||||
|
"category": "ship",
|
||||||
|
"cost": {
|
||||||
|
"credits": 250,
|
||||||
|
"minerals": 40
|
||||||
|
},
|
||||||
|
"requires": [],
|
||||||
|
"repeatable": false,
|
||||||
|
"effects": {},
|
||||||
|
"theme": {
|
||||||
|
"color": "#ffc94d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
"galaxy.json",
|
"galaxy.json",
|
||||||
"systems.json",
|
"systems.json",
|
||||||
"settlements.json",
|
"settlements.json",
|
||||||
"naming.json"
|
"naming.json",
|
||||||
|
"research.json",
|
||||||
|
"builds.json",
|
||||||
|
"actionbar.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"_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.",
|
||||||
|
"timeUnit": "seconds",
|
||||||
|
"maxConcurrent": 1,
|
||||||
|
"projects": {},
|
||||||
|
"_template": {
|
||||||
|
"label": "Project Name",
|
||||||
|
"description": "What this research is and why it matters.",
|
||||||
|
"duration": 300,
|
||||||
|
"requires": [],
|
||||||
|
"unlocks": {
|
||||||
|
"builds": [],
|
||||||
|
"research": []
|
||||||
|
},
|
||||||
|
"effects": {},
|
||||||
|
"theme": {
|
||||||
|
"color": "#00e5ff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
* its profile dir.
|
* its profile dir.
|
||||||
*/
|
*/
|
||||||
export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() * 100), width = 1280, height = 720, waitMs = 4000) {
|
export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() * 100), width = 1280, height = 720, waitMs = 4000) {
|
||||||
const userDataDir = `C:/Users/BRIAN~1.FER/AppData/Local/Temp/cdp-prof-${process.pid}-${port}`;
|
const os = await import('node:os');
|
||||||
|
const base = process.platform === 'win32'
|
||||||
|
? 'C:/Users/BRIAN~1.FER/AppData/Local/Temp'
|
||||||
|
: os.tmpdir();
|
||||||
|
const userDataDir = `${base.replace(/\/$/, '')}/cdp-prof-${process.pid}-${port}`;
|
||||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
|
||||||
const chrome = spawn(CHROME, [
|
const chrome = spawn(CHROME, [
|
||||||
|
|
@ -29,6 +33,7 @@ export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() *
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
'--disable-gpu',
|
'--disable-gpu',
|
||||||
|
'--no-sandbox', // container/CI friendly (also a no-op where it isn't needed)
|
||||||
'--enable-unsafe-swiftshader',
|
'--enable-unsafe-swiftshader',
|
||||||
'--hide-scrollbars',
|
'--hide-scrollbars',
|
||||||
`--window-size=${width},${height}`,
|
`--window-size=${width},${height}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Research & build data-layer test (dev tool, run with Node — no browser):
|
||||||
|
*
|
||||||
|
* node dev/research-builds.test.mjs
|
||||||
|
*
|
||||||
|
* The player's progression loop has two halves — research (time-based,
|
||||||
|
* one project at a time) and building (credits + minerals) — and the
|
||||||
|
* command deck that will host them. The RULES are not implemented yet;
|
||||||
|
* this test pins the data contract the future code will lean on:
|
||||||
|
* - the three config files are registered in data/manifest.json;
|
||||||
|
* - the rule knobs exist (timeUnit, maxConcurrent = 1, resources);
|
||||||
|
* - projects/builds are typed, empty maps (no content yet);
|
||||||
|
* - the `_template` entries document every required field;
|
||||||
|
* - the deck has exactly six slots in the right order.
|
||||||
|
*/
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dataDir = join(__dirname, '../data');
|
||||||
|
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
|
||||||
|
|
||||||
|
const manifest = read('manifest.json');
|
||||||
|
const research = read('research.json');
|
||||||
|
const builds = read('builds.json');
|
||||||
|
const actionbar = read('actionbar.json');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (label, cond) => {
|
||||||
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 1. Manifest registration
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
for (const f of ['research.json', 'builds.json', 'actionbar.json']) {
|
||||||
|
check(`manifest registers ${f}`, manifest.files.includes(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 2. Research — time-based, one at a time
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
check('research: player researches one thing at a time (maxConcurrent === 1)', research.maxConcurrent === 1);
|
||||||
|
check('research: timeUnit is a named unit', 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: no projects yet (empty map)', Object.keys(research.projects ?? {}).filter((k) => !k.startsWith('_')).length === 0);
|
||||||
|
|
||||||
|
const rt = research._template ?? {};
|
||||||
|
for (const k of ['label', 'description', 'duration', 'requires', 'unlocks', 'effects', 'theme']) {
|
||||||
|
check(`research._template documents "${k}"`, k in rt);
|
||||||
|
}
|
||||||
|
check('research._template.duration is a positive number', typeof rt.duration === 'number' && rt.duration > 0);
|
||||||
|
check('research._template.requires is an array', Array.isArray(rt.requires));
|
||||||
|
check('research._template.unlocks names builds[] + research[]', Array.isArray(rt.unlocks?.builds) && Array.isArray(rt.unlocks?.research));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 3. Builds — credits + minerals
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string');
|
||||||
|
check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string');
|
||||||
|
check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds));
|
||||||
|
check('builds: no builds yet (empty map)', Object.keys(builds.builds ?? {}).filter((k) => !k.startsWith('_')).length === 0);
|
||||||
|
|
||||||
|
const bt = builds._template ?? {};
|
||||||
|
for (const k of ['label', 'description', 'category', 'cost', 'requires', 'repeatable', 'effects', 'theme']) {
|
||||||
|
check(`builds._template documents "${k}"`, k in bt);
|
||||||
|
}
|
||||||
|
check('builds._template.category is a known kind', ['ship', 'planet', 'station', 'general'].includes(bt.category));
|
||||||
|
check('builds._template.cost pays in credits + minerals', typeof bt.cost?.credits === 'number' && typeof bt.cost?.minerals === 'number');
|
||||||
|
check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'boolean');
|
||||||
|
check('builds._template.requires is an array', Array.isArray(bt.requires));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 4. Command deck — six evenly spaced slots, right order
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const slots = actionbar.buttons ?? [];
|
||||||
|
check('actionbar: exactly six slots', slots.length === 6);
|
||||||
|
check('actionbar: slot ids in order (Research, Build, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'build', 'ship', null, null, 'menu']));
|
||||||
|
check('actionbar: labels (Research / Build / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Build', '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: 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: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
|
||||||
|
check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|
@ -205,6 +205,14 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
the `__MISSING` texture forever — even after the key is generated later
|
the `__MISSING` texture forever — even after the key is generated later
|
||||||
in the same session. Generate the texture **first** (see
|
in the same session. Generate the texture **first** (see
|
||||||
`DiscoveryCompass.ensureArrowTexture`: texture, then `scene.add.image`).
|
`DiscoveryCompass.ensureArrowTexture`: texture, then `scene.add.image`).
|
||||||
|
- **Input on screen-fixed containers is per-child.** Hit-testing uses
|
||||||
|
each object's *own* scrollFactor (`InputManager`: `g = worldX +
|
||||||
|
scrollX*sf - scrollX`), while a child of a `scrollFactor(0)` container
|
||||||
|
renders screen-fixed. So a child whose scrollFactor is left at the
|
||||||
|
default (1) is drawn pinned but hit-tested in world space — clicks miss
|
||||||
|
it whenever the camera has scrolled. (MenuButton never bit by this:
|
||||||
|
the menu camera doesn't move.) Set `scrollFactor(0)` on **every** child
|
||||||
|
of a screen-fixed UI container — done in `ActionBar.buildSlots`.
|
||||||
- To upgrade: replace the vendored file + note the version here (and re-check
|
- To upgrade: replace the vendored file + note the version here (and re-check
|
||||||
the quirks above — they may go away).
|
the quirks above — they may go away).
|
||||||
|
|
||||||
|
|
@ -231,6 +239,22 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
and the player's place in a populated galaxy
|
and the player's place in a populated galaxy
|
||||||
- [ ] Landing & exploration: settlements become points of interest you
|
- [ ] Landing & exploration: settlements become points of interest you
|
||||||
can approach (the data — kind, anchor, population — is already there)
|
can approach (the data — kind, anchor, population — is already there)
|
||||||
|
- [x] The player's loop, laid down as data + seams: research (time-based,
|
||||||
|
one at a time, gates builds/research) and building (credits + minerals,
|
||||||
|
ship/planet/station) data layers (`data/research.json`,
|
||||||
|
`data/builds.json`) with templates, and the command deck
|
||||||
|
(`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Build,
|
||||||
|
Ship, ·, ·, Menu. The deck reuses the menu's CRT language: the same
|
||||||
|
scanline tile recipe as `CyberOverlay` (clipped to the bar) and the
|
||||||
|
`GlitchText` RGB pull-apart (icons + labels at all times, panel outline
|
||||||
|
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
|
||||||
|
`requires`; the Build slot on the deck opens it; a credits/minerals
|
||||||
|
readout in the HUD
|
||||||
|
- [ ] Ship screen (the Ship slot) — inspect & upgrade the ship
|
||||||
|
(ship-category builds) from one place
|
||||||
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
|
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
|
||||||
clustering by type, borders, adjacency affinity) — hook marked in
|
clustering by type, borders, adjacency affinity) — hook marked in
|
||||||
`Galaxy._generate()`
|
`Galaxy._generate()`
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Ship } from '../entities/Ship.js';
|
||||||
import { Planet } from '../entities/Planet.js';
|
import { Planet } from '../entities/Planet.js';
|
||||||
import { Starfield } from '../visuals/Starfield.js';
|
import { Starfield } from '../visuals/Starfield.js';
|
||||||
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
|
import { ActionBar } from '../ui/ActionBar.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);
|
||||||
|
|
@ -122,9 +123,29 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.compass = new DiscoveryCompass(this);
|
this.compass = new DiscoveryCompass(this);
|
||||||
|
|
||||||
// Hint
|
// The command deck — the cyberpunk action bar across the bottom of the
|
||||||
|
// screen (config: data/actionbar.json). Six evenly spaced slots:
|
||||||
|
// Research, Build, Ship, two reserved, Menu. The slots are the seams
|
||||||
|
// for the player's loop — research (time-based, one at a time, see
|
||||||
|
// data/research.json) and building (credits + minerals, see
|
||||||
|
// data/builds.json) — whose behavior and panels come next.
|
||||||
|
this.actionBar =
|
||||||
|
config.get('actionbar.enabled', true) === true
|
||||||
|
? new ActionBar(this, {
|
||||||
|
onAction: (id) => {
|
||||||
|
// TODO(command deck): 'research' → research panel, 'build' → build
|
||||||
|
// panel, 'ship' → ship screen, 'menu' → main menu.
|
||||||
|
console.info(`[orbit] command deck: ${id}`);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hint (pinned just ABOVE the command deck, not under it)
|
||||||
|
const deckRoom = this.actionBar
|
||||||
|
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
|
||||||
|
: 0;
|
||||||
this.hint = this.add
|
this.hint = this.add
|
||||||
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), {
|
.text(this.scale.width / 2, this.scale.height - deckRoom - (deckRoom ? 20 : 26), config.get('game.hintText', ''), {
|
||||||
fontFamily: BODY_FONT(),
|
fontFamily: BODY_FONT(),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#54608a',
|
color: '#54608a',
|
||||||
|
|
@ -133,10 +154,12 @@ export class GameScene extends Phaser.Scene {
|
||||||
.setOrigin(0.5)
|
.setOrigin(0.5)
|
||||||
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
.setScrollFactor(0); // UI: pinned to the screen, not the world
|
||||||
|
|
||||||
// Input: click = fly there. A click inside a planet clamps to that
|
// Input: click = fly there (a click ON the command deck is deck
|
||||||
|
// business, not flight). A click inside a planet clamps to that
|
||||||
// planet's keep-out rim — the ship can stop at the clearance, never
|
// planet's keep-out rim — the ship can stop at the clearance, never
|
||||||
// inside. (Worlds don't overlap, so sequential clamping is exact.)
|
// inside. (Worlds don't overlap, so sequential clamping is exact.)
|
||||||
this.input.on('pointerdown', (pointer) => {
|
this.input.on('pointerdown', (pointer) => {
|
||||||
|
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
|
||||||
let aim = { x: pointer.worldX, y: pointer.worldY };
|
let aim = { x: pointer.worldX, y: pointer.worldY };
|
||||||
for (const p of this.solidPlanets) {
|
for (const p of this.solidPlanets) {
|
||||||
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
|
||||||
|
|
@ -221,6 +244,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
this.updateCamera(delta);
|
this.updateCamera(delta);
|
||||||
this.starfield.update(); // after the camera, so it sees this frame's motion
|
this.starfield.update(); // after the camera, so it sees this frame's motion
|
||||||
|
this.actionBar?.update(_time, delta); // the deck's living details
|
||||||
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,5 +408,6 @@ export class GameScene extends Phaser.Scene {
|
||||||
shutdown() {
|
shutdown() {
|
||||||
this.starfield?.destroy();
|
this.starfield?.destroy();
|
||||||
this.compass?.destroy();
|
this.compass?.destroy();
|
||||||
|
this.actionBar?.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,919 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor, toCss } from '../utils/Color.js';
|
||||||
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
||||||
|
import { canvasTexture } from '../utils/Textures.js';
|
||||||
|
import { CyberShape } from './CyberShape.js';
|
||||||
|
|
||||||
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMMAND DECK — the cyberpunk action bar across the bottom of the screen:
|
||||||
|
* a cut-corner console panel with an energy rail, six evenly spaced slot
|
||||||
|
* buttons (drawn icons, RGB-split labels, per-slot accent colors), reserved
|
||||||
|
* "standby" slots, a rail comet that streaks past on a loop, periodic glitch
|
||||||
|
* bursts (slice bars + label jitter), and a boot flicker-in.
|
||||||
|
* Dressed with the menu's CRT language: scanlines over the whole bar (the
|
||||||
|
* same 1px-dark-row tile as CyberOverlay) and the GlitchText RGB pull-apart
|
||||||
|
* on everything — icons, labels, and the panel outline itself on bursts.
|
||||||
|
*
|
||||||
|
* Everything is procedural — no image assets — and fully config-driven
|
||||||
|
* (data/actionbar.json): slots, labels, accents, palette, and every
|
||||||
|
* animation's tuning.
|
||||||
|
*
|
||||||
|
* Component usage (scenes own one instance, like CyberOverlay):
|
||||||
|
*
|
||||||
|
* this.actionBar = new ActionBar(this, { onAction: (id) => ... });
|
||||||
|
* update(time, delta) { this.actionBar.update(time, delta); }
|
||||||
|
* shutdown() { this.actionBar.destroy(); }
|
||||||
|
*
|
||||||
|
* Live slots fire `onAction(id, slot)` on press — behavior is the
|
||||||
|
* scene's job (the Research/Build/Ship/Menu panels come next).
|
||||||
|
* `bar.contains(px, py)` (screen coords) lets a scene keep its own
|
||||||
|
* input — e.g. click-to-fly — from triggering over the deck.
|
||||||
|
*/
|
||||||
|
export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
|
/**
|
||||||
|
* @param {Phaser.Scene} scene
|
||||||
|
* @param {object} [o] { onAction?(id, slot) }
|
||||||
|
*/
|
||||||
|
constructor(scene, o = {}) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
// v4 quirk: a directly-constructed GameObject is NOT added to the scene's
|
||||||
|
// display list (the scene.add.* factories do that) — register it here or it
|
||||||
|
// never renders. Verified Sept 2026 against lib/phaser.min.js (4.2.1 Giedi).
|
||||||
|
this.scene.add.existing(this);
|
||||||
|
this.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
|
this.setDepth(50); // above the HUD dossier (30) / compass (40) / toast (45)
|
||||||
|
|
||||||
|
const ab = config.section('actionbar', {});
|
||||||
|
this.dead = false;
|
||||||
|
this.onAction = typeof o.onAction === 'function' ? o.onAction : null;
|
||||||
|
|
||||||
|
const { width: W, height: H } = scene.scale;
|
||||||
|
const mL = ab.margin?.left ?? 20;
|
||||||
|
const mR = ab.margin?.right ?? 20;
|
||||||
|
const mB = ab.margin?.bottom ?? 12;
|
||||||
|
const barW = W - mL - mR;
|
||||||
|
const barH = ab.height ?? 92;
|
||||||
|
const x0 = mL;
|
||||||
|
const y0 = H - mB - barH;
|
||||||
|
this.rect = { x: x0, y: y0, w: barW, h: barH, cx: W / 2, cy: y0 + barH / 2 };
|
||||||
|
|
||||||
|
const bc = ab.button ?? {};
|
||||||
|
const anim = ab.animation ?? {};
|
||||||
|
this.style = {
|
||||||
|
bw: 0, bh: 0, notch: 10,
|
||||||
|
slotBg: toColor(ab.colors?.slotBg, 0x0b1322),
|
||||||
|
slotBorder: toColor(ab.colors?.slotBorder, 0x22405f),
|
||||||
|
reserved: toColor(ab.colors?.reserved, 0x3d4c74),
|
||||||
|
inkCss: toCss(ab.colors?.text ?? '#eaf6ff'),
|
||||||
|
iconY: -12,
|
||||||
|
labelY: 16,
|
||||||
|
iconSize: bc.iconSize ?? 26,
|
||||||
|
};
|
||||||
|
// The menu's CRT scanline tuning (data/theme.json) is the default, so
|
||||||
|
// the bar matches the menu unless actionbar.json overrides it.
|
||||||
|
const themeScan = config.section('theme.overlay', {}).scanline ?? {};
|
||||||
|
this.cfgScan = { pitch: 3, alpha: 0.14, ...themeScan, ...(ab.scanline ?? {}) };
|
||||||
|
// RGB pull-apart (the GlitchText motion model, scaled to button size):
|
||||||
|
// idleOffset px of constant chromatic fringe, burstOffset extra px at
|
||||||
|
// level 1, plus the ghost alphas.
|
||||||
|
this.cfgRgb = {
|
||||||
|
idleOffset: 1.8,
|
||||||
|
burstOffset: 6,
|
||||||
|
idleAlpha: 0.4,
|
||||||
|
burstAlpha: 0.55,
|
||||||
|
...(anim.rgb ?? {}),
|
||||||
|
};
|
||||||
|
this.cfgComet = { enabled: true, everyMs: [3600, 7800], durationMs: 950, ...(anim.comet ?? {}) };
|
||||||
|
this.cfgGlitch = {
|
||||||
|
enabled: true,
|
||||||
|
intervalMs: [3500, 9000],
|
||||||
|
durationMs: [240, 480],
|
||||||
|
slices: [3, 6],
|
||||||
|
...(anim.glitch ?? {}),
|
||||||
|
};
|
||||||
|
this.bootStagger = anim.bootStagger ?? 70;
|
||||||
|
|
||||||
|
this.railTop = null;
|
||||||
|
this.railBottom = null;
|
||||||
|
this.comet = null;
|
||||||
|
this.cometT0 = null;
|
||||||
|
this.cometDur = 1;
|
||||||
|
this.nextCometAt = null;
|
||||||
|
this.level = 0; // RGB-split level 0..1 (GlitchText model, decays ~90 ms)
|
||||||
|
this.burstT0 = null;
|
||||||
|
this.burstDur = 1;
|
||||||
|
this.nextBurstAt = null;
|
||||||
|
this.slices = [];
|
||||||
|
this.lastTime = null;
|
||||||
|
|
||||||
|
this.buildBody(barW, barH);
|
||||||
|
this.buildRails();
|
||||||
|
this.buildSlots(ab, bc, x0, y0, barW, barH);
|
||||||
|
this.buildFx(barW, barH); // scanlines + panel RGB ghost (topmost of the bar)
|
||||||
|
this.boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Building the bar
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The console panel — a canvas texture (gradients + neon halo that
|
||||||
|
* Graphics can't do), drawn exactly over `rect` with bleed for glow. */
|
||||||
|
buildBody(barW, barH) {
|
||||||
|
const { scene } = this;
|
||||||
|
const P = 24; // halo bleed around the panel
|
||||||
|
const W = barW + P * 2;
|
||||||
|
const H = barH + P * 2;
|
||||||
|
const c = config.section('actionbar.colors', {});
|
||||||
|
const key = canvasTexture(scene, `__ab_body_${W}x${H}`, W, H, (ctx) =>
|
||||||
|
drawBarBody(ctx, W, H, P, {
|
||||||
|
panelTop: c.panelTop ?? '#101c36',
|
||||||
|
panelBottom: c.panelBottom ?? '#04070e',
|
||||||
|
hatch: c.hatch ?? '#78b4ff',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.body = scene.add.image(this.rect.cx, this.rect.cy, key).setScrollFactor(0);
|
||||||
|
this.add(this.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breathing energy lines: the top rail (cyan) and floor line (magenta). */
|
||||||
|
buildRails() {
|
||||||
|
const { scene } = this;
|
||||||
|
const { x, y, w, h } = this.rect;
|
||||||
|
const rail = toColor(config.get('actionbar.colors.rail'), 0x00e5ff);
|
||||||
|
const railBottom = toColor(config.get('actionbar.colors.railBottom'), 0xff2d6f);
|
||||||
|
|
||||||
|
this.railTop = scene
|
||||||
|
.add.rectangle(x + w / 2, y + 1, w, 2, rail, 0.2)
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
this.add(this.railTop);
|
||||||
|
|
||||||
|
this.railBottom = scene
|
||||||
|
.add.rectangle(x + w / 2, y + h - 1, w, 1.5, railBottom, 0.12)
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
this.add(this.railBottom);
|
||||||
|
|
||||||
|
// The rail comet — a bright streak that crosses the top edge on a loop.
|
||||||
|
const cometKey = canvasTexture(scene, '__ab_comet', 180, 12, (ctx) => {
|
||||||
|
const g = ctx.createLinearGradient(0, 0, 180, 0);
|
||||||
|
g.addColorStop(0, 'rgba(0,229,255,0)');
|
||||||
|
g.addColorStop(0.42, 'rgba(0,229,255,0.55)');
|
||||||
|
g.addColorStop(0.5, 'rgba(228,255,255,0.95)');
|
||||||
|
g.addColorStop(0.58, 'rgba(0,229,255,0.55)');
|
||||||
|
g.addColorStop(1, 'rgba(0,229,255,0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, 180, 12);
|
||||||
|
});
|
||||||
|
this.comet = scene
|
||||||
|
.add.image(x - 120, y + 1, cometKey)
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setDisplaySize(190, 7);
|
||||||
|
this.add(this.comet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The six slots — buttons and reserved placeholders, evenly spaced. */
|
||||||
|
buildSlots(ab, bc, x0, y0, barW, barH) {
|
||||||
|
const { scene } = this;
|
||||||
|
const pad = ab.padding ?? 16;
|
||||||
|
const buttons =
|
||||||
|
Array.isArray(ab.buttons) && ab.buttons.length > 0
|
||||||
|
? ab.buttons
|
||||||
|
: [
|
||||||
|
{ id: 'research', label: 'Research', accent: '#00e5ff' },
|
||||||
|
{ id: 'build', label: 'Build', accent: '#ffc94d' },
|
||||||
|
{ id: 'ship', label: 'Ship', accent: '#7ce8a4' },
|
||||||
|
{ id: null, label: null },
|
||||||
|
{ id: null, label: null },
|
||||||
|
{ id: 'menu', label: 'Menu', accent: '#ff2d6f' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const n = buttons.length;
|
||||||
|
const slotW = (barW - pad * 2) / n;
|
||||||
|
const bw = Math.min(slotW * (bc.widthFactor ?? 0.72), bc.maxWidth ?? 190);
|
||||||
|
const bh = barH - 26;
|
||||||
|
const notch = Math.min(bc.notch ?? 10, bh * 0.3);
|
||||||
|
this.style.bw = bw;
|
||||||
|
this.style.bh = bh;
|
||||||
|
this.style.notch = notch;
|
||||||
|
|
||||||
|
const fam = fontStack('header', FONT_FALLBACK);
|
||||||
|
const fontSize = bc.fontSize ?? 14;
|
||||||
|
const letterSpacing = bc.letterSpacing ?? 2.5;
|
||||||
|
const cyan = toCss('#00e5ff');
|
||||||
|
const magenta = toCss('#ff2d6f');
|
||||||
|
const iconSize = this.style.iconSize;
|
||||||
|
const iconY = this.style.iconY;
|
||||||
|
|
||||||
|
this.slots = buttons.map((b, i) => {
|
||||||
|
const live = typeof b.id === 'string' && b.id.length > 0;
|
||||||
|
const sx = x0 + pad + slotW * (i + 0.5);
|
||||||
|
const sy = y0 + barH / 2;
|
||||||
|
|
||||||
|
const slot = new Phaser.GameObjects.Container(scene, sx, sy);
|
||||||
|
// v4 quirk (Sept 2026): hit-testing uses EACH object's own scrollFactor
|
||||||
|
// (InputManager: `g = worldX + scrollX*sf - scrollX`) while rendering pins
|
||||||
|
// children to their scrollFactor-0 container — so every child of a
|
||||||
|
// screen-fixed container must set its own scrollFactor(0) or its input
|
||||||
|
// lands in world space. (MenuButton got away with the default only
|
||||||
|
// because the menu camera never scrolls.)
|
||||||
|
slot.setScrollFactor(0);
|
||||||
|
this.add(slot);
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
id: live ? b.id : null,
|
||||||
|
live,
|
||||||
|
slot,
|
||||||
|
accent: toColor(b.accent ?? themeColor('neon', 0x00e5ff)),
|
||||||
|
hoverOn: false,
|
||||||
|
pressing: false,
|
||||||
|
phase: i * 1.7 + 0.6, // per-slot shimmer offset
|
||||||
|
label: null,
|
||||||
|
resDot: null,
|
||||||
|
_sweep: null,
|
||||||
|
_scaleTw: null,
|
||||||
|
};
|
||||||
|
s.panel = scene.add.graphics().setScrollFactor(0);
|
||||||
|
// RGB-split icon ghosts (the GlitchText technique on the menu title):
|
||||||
|
// additive magenta/cyan copies of the drawn icon, moved per frame.
|
||||||
|
s.iconGhostM = scene
|
||||||
|
.add.graphics()
|
||||||
|
.setPosition(0, iconY)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setAlpha(0);
|
||||||
|
s.iconGhostC = scene
|
||||||
|
.add.graphics()
|
||||||
|
.setPosition(0, iconY)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setAlpha(0);
|
||||||
|
s.icon = scene.add.graphics().setPosition(0, iconY).setScrollFactor(0);
|
||||||
|
slot.add([s.panel, s.iconGhostM, s.iconGhostC, s.icon]);
|
||||||
|
|
||||||
|
// Drawn icon (accent for live slots, dim for reserved sockets) —
|
||||||
|
// live slots also get their ghost copies in the split colors.
|
||||||
|
const iconKind = live ? b.id : 'reserved';
|
||||||
|
drawIcon(s.icon, iconKind, iconSize, live ? s.accent : this.style.reserved);
|
||||||
|
if (live) {
|
||||||
|
drawIcon(s.iconGhostM, iconKind, iconSize, 0xff2d6f);
|
||||||
|
drawIcon(s.iconGhostC, iconKind, iconSize, 0x00e5ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
const labelText = String(b.label ?? '').toUpperCase();
|
||||||
|
const textStyle = {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
// v4 quirk: text colors must be CSS strings (see toCss).
|
||||||
|
color: this.style.inkCss,
|
||||||
|
letterSpacing,
|
||||||
|
};
|
||||||
|
s.label = scene.add.text(0, this.style.labelY, labelText, textStyle).setOrigin(0.5).setScrollFactor(0);
|
||||||
|
// RGB-split ghosts (additive), revealed on hover + glitch bursts.
|
||||||
|
s.ghostCyan = scene
|
||||||
|
.add.text(0, this.style.labelY, labelText, { ...textStyle, color: cyan })
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
s.ghostMagenta = scene
|
||||||
|
.add.text(0, this.style.labelY, labelText, { ...textStyle, color: magenta })
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setAlpha(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
// Light streak for the hover sweep.
|
||||||
|
s.sweep = scene
|
||||||
|
.add.rectangle(0, 0, 20, bh - 10, 0xeaf6ff, 0)
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
slot.add([s.ghostCyan, s.ghostMagenta, s.sweep, s.label]);
|
||||||
|
|
||||||
|
// Hit-test the whole slot rect with an explicit area (independent
|
||||||
|
// of the Graphics' draw state, so repainting never breaks input).
|
||||||
|
s.panel.setInteractive({
|
||||||
|
useHandCursor: true,
|
||||||
|
hitArea: new Phaser.Geom.Rectangle(-bw / 2, -bh / 2, bw, bh),
|
||||||
|
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||||
|
});
|
||||||
|
s.panel.on('pointerover', () => this.setHover(s, true));
|
||||||
|
s.panel.on('pointerout', () => this.setHover(s, false));
|
||||||
|
s.panel.on('pointerdown', () => this.press(s));
|
||||||
|
} else {
|
||||||
|
// Reserved socket: a standby dot that breathes in update().
|
||||||
|
s.resDot = scene.add.circle(0, iconY, 1.6, this.style.reserved, 0.35).setScrollFactor(0);
|
||||||
|
slot.add(s.resDot);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paintSlot(s, 'base');
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu's CRT dressing, clipped to the bar: scanlines over the whole
|
||||||
|
* strip (body + buttons) and the panel RGB ghost — the cut-corner outline
|
||||||
|
* pulled apart in magenta/cyan while a glitch burst is live (the
|
||||||
|
* GlitchText technique applied to the bar itself).
|
||||||
|
*/
|
||||||
|
buildFx(barW, barH) {
|
||||||
|
const { scene } = this;
|
||||||
|
const { x, y, w, h, cx, cy } = this.rect;
|
||||||
|
|
||||||
|
// Scanlines — the exact recipe CyberOverlay uses on the menu (one dark
|
||||||
|
// row per `pitch` rows, tiled), sized to the bar strip.
|
||||||
|
const sl = this.cfgScan;
|
||||||
|
const pitch = Math.max(2, Math.round(sl.pitch));
|
||||||
|
const scanKey = `__ab_scan_${pitch}`;
|
||||||
|
canvasTexture(scene, scanKey, 1, pitch, (ctx) => {
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.fillRect(0, pitch - 1, 1, 1); // one dark line every `pitch` rows
|
||||||
|
});
|
||||||
|
this.scanlines = scene
|
||||||
|
.add.tileSprite(cx, cy, w, h, scanKey)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setAlpha(sl.alpha);
|
||||||
|
this.add(this.scanlines);
|
||||||
|
|
||||||
|
// Panel RGB ghost — same cut corners as the body (drawBarBody uses
|
||||||
|
// min(14, h*0.16)), stroke-only, additive; driven in update().
|
||||||
|
const cut = Math.max(4, Math.min(14, Math.round(barH * 0.16)));
|
||||||
|
this.ghostPanelM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
|
||||||
|
CyberShape.draw(this.ghostPanelM, barW, barH, { notch: cut, stroke: 0xff2d6f, strokeAlpha: 1, lineWidth: 1.5 });
|
||||||
|
this.ghostPanelC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
|
||||||
|
CyberShape.draw(this.ghostPanelC, barW, barH, { notch: cut, stroke: 0x00e5ff, strokeAlpha: 1, lineWidth: 1.5 });
|
||||||
|
this.add(this.ghostPanelM);
|
||||||
|
this.add(this.ghostPanelC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Slot states
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Repaint a slot for a visual state: 'base' | 'hover' | 'press'. */
|
||||||
|
paintSlot(s, state) {
|
||||||
|
const st = this.style;
|
||||||
|
const { bw, bh, notch } = st;
|
||||||
|
const g = s.panel;
|
||||||
|
const hover = state === 'hover';
|
||||||
|
g.clear();
|
||||||
|
if (state === 'press') {
|
||||||
|
CyberShape.draw(g, bw, bh, {
|
||||||
|
notch,
|
||||||
|
fill: 0xdff6ff,
|
||||||
|
fillAlpha: 0.95,
|
||||||
|
stroke: 0xffffff,
|
||||||
|
strokeAlpha: 1,
|
||||||
|
lineWidth: 1.5,
|
||||||
|
});
|
||||||
|
} else if (s.live) {
|
||||||
|
CyberShape.draw(g, bw, bh, {
|
||||||
|
notch,
|
||||||
|
fill: hover ? mixColor(st.slotBg, s.accent, 0.16) : st.slotBg,
|
||||||
|
fillAlpha: hover ? 0.94 : 0.8,
|
||||||
|
stroke: hover ? s.accent : st.slotBorder,
|
||||||
|
strokeAlpha: hover ? 1 : 0.9,
|
||||||
|
lineWidth: 1.5,
|
||||||
|
glow: hover ? s.accent : undefined,
|
||||||
|
glowAlpha: 0.3,
|
||||||
|
});
|
||||||
|
// Accent rail along the slot's top edge + a port diamond on it.
|
||||||
|
g.fillStyle(s.accent, hover ? 1 : 0.55);
|
||||||
|
g.fillRect(-bw / 2 + notch, -bh / 2 + 1.5, bw - notch * 2, 2);
|
||||||
|
g.fillPoints(
|
||||||
|
[
|
||||||
|
{ x: 0, y: -bh / 2 - 3.5 },
|
||||||
|
{ x: 4, y: -bh / 2 },
|
||||||
|
{ x: 0, y: -bh / 2 + 3.5 },
|
||||||
|
{ x: -4, y: -bh / 2 },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Reserved socket — dim, inert, clearly "not built yet".
|
||||||
|
CyberShape.draw(g, bw, bh, {
|
||||||
|
notch,
|
||||||
|
fill: st.slotBg,
|
||||||
|
fillAlpha: 0.5,
|
||||||
|
stroke: st.slotBorder,
|
||||||
|
strokeAlpha: 0.4,
|
||||||
|
lineWidth: 1.5,
|
||||||
|
});
|
||||||
|
g.fillStyle(st.reserved, 0.3);
|
||||||
|
g.fillRect(-bw / 2 + notch, -bh / 2 + 1.5, bw - notch * 2, 1.5);
|
||||||
|
}
|
||||||
|
if (s.label) s.label.setColor(state === 'hover' ? '#ffffff' : st.inkCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHover(s, on) {
|
||||||
|
if (!s.live || this.dead) return;
|
||||||
|
s.hoverOn = on;
|
||||||
|
this.paintSlot(s, on ? 'hover' : 'base');
|
||||||
|
|
||||||
|
if (s._sweep) {
|
||||||
|
s._sweep.remove();
|
||||||
|
s._sweep = null;
|
||||||
|
}
|
||||||
|
if (s._scaleTw) s._scaleTw.stop();
|
||||||
|
s._scaleTw = this.scene.tweens.add({
|
||||||
|
targets: s.slot,
|
||||||
|
scale: on ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (on) {
|
||||||
|
const half = this.style.bw / 2 - 14;
|
||||||
|
s.sweep.setX(-half).setAlpha(0.5);
|
||||||
|
s._sweep = this.scene.tweens.add({
|
||||||
|
targets: s.sweep,
|
||||||
|
x: half,
|
||||||
|
duration: 380,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
onComplete: () => s.sweep.setAlpha(0),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
s.sweep.setAlpha(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
|
||||||
|
press(s) {
|
||||||
|
if (!s.live || s.pressing || this.dead) return;
|
||||||
|
s.pressing = true;
|
||||||
|
this.paintSlot(s, 'press');
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: s.slot,
|
||||||
|
scale: 0.96,
|
||||||
|
duration: 70,
|
||||||
|
yoyo: true,
|
||||||
|
ease: 'Sine.easeOut',
|
||||||
|
});
|
||||||
|
this.scene.time.delayedCall(130, () => {
|
||||||
|
if (this.dead) return;
|
||||||
|
s.pressing = false;
|
||||||
|
this.paintSlot(s, s.hoverOn ? 'hover' : 'base');
|
||||||
|
});
|
||||||
|
if (typeof this.onAction === 'function') {
|
||||||
|
try {
|
||||||
|
this.onAction(s.id, s);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[actionbar] onAction handler failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Boot
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The deck flickers up: panel fades in, slots chunk-flicker one by
|
||||||
|
* one (Steps ease), then a signature glitch burst + rail comet. */
|
||||||
|
boot() {
|
||||||
|
const scene = this.scene;
|
||||||
|
this.setAlpha(0);
|
||||||
|
scene.tweens.add({ targets: this, alpha: 1, duration: 360, delay: 240, ease: 'Sine.easeOut' });
|
||||||
|
this.slots.forEach((s, i) => {
|
||||||
|
s.slot.setAlpha(0);
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: s.slot,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 260,
|
||||||
|
delay: 480 + i * this.bootStagger,
|
||||||
|
ease: 'Steps(4)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const bootEnd = 480 + this.slots.length * this.bootStagger + 300;
|
||||||
|
scene.time.delayedCall(bootEnd, () => {
|
||||||
|
if (this.dead) return;
|
||||||
|
this.triggerBurst(340, 1);
|
||||||
|
this.fireComet(620);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Glitch + comet
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a glitch burst right now: slice bars across the deck, and the RGB
|
||||||
|
* split (level → 1, decays in update) on icons, labels, and the panel.
|
||||||
|
*/
|
||||||
|
triggerBurst(duration = 300, intensity = 1) {
|
||||||
|
const now = this.lastTime ?? this.scene.time.now;
|
||||||
|
this.burstT0 = now;
|
||||||
|
this.burstDur = Math.max(1, duration);
|
||||||
|
this.level = Math.max(this.level, intensity);
|
||||||
|
this.spawnSlices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a rail-comet pass right now. */
|
||||||
|
fireComet(duration = 700) {
|
||||||
|
this.cometT0 = this.lastTime ?? this.scene.time.now;
|
||||||
|
this.cometDur = Math.max(1, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slice bars + a displacement band, clipped to the deck's strip —
|
||||||
|
* the same signal-loss language as CyberOverlay, at bar scale.
|
||||||
|
*/
|
||||||
|
spawnSlices() {
|
||||||
|
const { x, y, w, h } = this.rect;
|
||||||
|
const neon = toColor(config.get('actionbar.colors.rail'), 0x00e5ff);
|
||||||
|
const mag = toColor(config.get('actionbar.colors.railBottom'), 0xff2d6f);
|
||||||
|
const bars = this.scene.add.graphics().setScrollFactor(0).setDepth(52);
|
||||||
|
const n = Math.round(this.range(this.cfgGlitch.slices[0] ?? 3, this.cfgGlitch.slices[1] ?? 6));
|
||||||
|
const palette = [neon, mag, 0xeaf6ff, 0x04060d];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const yy = y + Math.random() * h;
|
||||||
|
const bh2 = 1 + Math.random() * 9;
|
||||||
|
const dx = (Math.random() * 2 - 1) * 12;
|
||||||
|
bars.fillStyle(palette[(Math.random() * palette.length) | 0], 0.07 + Math.random() * 0.16);
|
||||||
|
bars.fillRect(x + dx - 20, yy, w + 40, bh2);
|
||||||
|
}
|
||||||
|
// One wider "displacement band" so the burst reads at a glance.
|
||||||
|
const bandY = y + Math.random() * Math.max(4, h - 18);
|
||||||
|
bars.fillStyle(0x04060d, 0.5);
|
||||||
|
bars.fillRect(x - 24, bandY, w + 48, 10 + Math.random() * 10);
|
||||||
|
bars.fillStyle(neon, 0.25);
|
||||||
|
bars.fillRect(x - 24, bandY - 2, w + 48, 1.5);
|
||||||
|
|
||||||
|
const die = (this.lastTime ?? this.scene.time.now) + (this.burstDur ?? 300) + 80;
|
||||||
|
this.slices.push({ g: bars, die });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Per-frame
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the living details: rail breathing, rail comet, glitch-burst
|
||||||
|
* scheduling, slot shimmer/jitter, and slice reaping.
|
||||||
|
* The scene calls this once per frame (Phaser v4 does not auto-update).
|
||||||
|
*/
|
||||||
|
update(time, delta) {
|
||||||
|
if (this.dead) return;
|
||||||
|
this.lastTime = time;
|
||||||
|
const t = time * 0.001;
|
||||||
|
const { x, y, w } = this.rect;
|
||||||
|
|
||||||
|
// Rail breathing — the console idles like it's alive.
|
||||||
|
if (this.railTop) this.railTop.setAlpha(0.14 + 0.1 * Math.sin(t * 0.9));
|
||||||
|
if (this.railBottom) this.railBottom.setAlpha(0.09 + 0.07 * Math.sin(t * 0.9 + Math.PI));
|
||||||
|
|
||||||
|
// Rail comet: scheduled passes with smooth travel.
|
||||||
|
if (this.cfgComet.enabled && this.comet) {
|
||||||
|
if (this.nextCometAt === null) this.nextCometAt = time + 2600;
|
||||||
|
if (this.cometT0 === null && time >= this.nextCometAt) {
|
||||||
|
this.cometT0 = time;
|
||||||
|
this.cometDur = this.cfgComet.durationMs ?? 950;
|
||||||
|
}
|
||||||
|
if (this.cometT0 !== null) {
|
||||||
|
const u = (time - this.cometT0) / this.cometDur;
|
||||||
|
if (u >= 1) {
|
||||||
|
this.cometT0 = null;
|
||||||
|
this.nextCometAt = time + this.range(this.cfgComet.everyMs[0], this.cfgComet.everyMs[1]);
|
||||||
|
this.comet.setAlpha(0);
|
||||||
|
} else {
|
||||||
|
const e = u * u * (3 - 2 * u); // smoothstep
|
||||||
|
this.comet
|
||||||
|
.setX(x - 120 + (w + 240) * e)
|
||||||
|
.setAlpha(Math.sin(Math.PI * Math.min(1, Math.max(0, u))) * 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glitch bursts: schedule → fire (level decays) → schedule again.
|
||||||
|
if (this.cfgGlitch.enabled) {
|
||||||
|
if (this.nextBurstAt === null) {
|
||||||
|
// First scheduled burst lands after the boot burst has faded.
|
||||||
|
this.nextBurstAt = time + 4200 + Math.random() * 1800;
|
||||||
|
}
|
||||||
|
if (this.burstT0 === null && time >= this.nextBurstAt) {
|
||||||
|
this.burstT0 = time;
|
||||||
|
this.burstDur = this.range(this.cfgGlitch.durationMs[0], this.cfgGlitch.durationMs[1]);
|
||||||
|
this.nextBurstAt = time + this.burstDur + this.range(this.cfgGlitch.intervalMs[0], this.cfgGlitch.intervalMs[1]);
|
||||||
|
this.spawnSlices();
|
||||||
|
}
|
||||||
|
if (this.burstT0 !== null && time - this.burstT0 >= this.burstDur) this.burstT0 = null;
|
||||||
|
}
|
||||||
|
// RGB-split level: pushed to 1 on burst trigger, decays ~90 ms — the
|
||||||
|
// GlitchText model (fast snap, while the slice bars linger longer).
|
||||||
|
this.level *= Math.exp(-(delta / 90));
|
||||||
|
if (this.level < 0.002) this.level = 0;
|
||||||
|
const L = this.level;
|
||||||
|
const rgb = this.cfgRgb;
|
||||||
|
const idle = 0.65 + 0.35 * Math.sin(t * 1.9);
|
||||||
|
const off = rgb.idleOffset * idle + L * rgb.burstOffset;
|
||||||
|
const flick = 0.6 + 0.4 * Math.sin(t * 3.3);
|
||||||
|
const baseA = Math.min(1, rgb.idleAlpha * flick + L * rgb.burstAlpha);
|
||||||
|
|
||||||
|
// Slots: the menu's RGB pull-apart on icon + label — magenta left,
|
||||||
|
// cyan right (GlitchText's exact offsets), always a faint fringe at
|
||||||
|
// idle, pulled apart + jittered during bursts; per-slot phase so the
|
||||||
|
// six buttons don't shimmer in lockstep.
|
||||||
|
for (const s of this.slots) {
|
||||||
|
if (!s.live) {
|
||||||
|
if (s.resDot) {
|
||||||
|
s.resDot.setAlpha(0.2 + 0.18 * (0.5 + 0.5 * Math.sin(t * 1.4 + s.phase)));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const jx = L * 6 * Math.sin(time * 0.053 + s.phase * 3.1) * Math.sin(time * 0.021 + 1.7 + s.phase);
|
||||||
|
const jy = L * 4 * Math.sin(time * 0.083 + 0.4 + s.phase * 2.3);
|
||||||
|
const a = s.hoverOn ? Math.min(1, baseA + 0.25) : baseA;
|
||||||
|
s.ghostMagenta.setAlpha(a).setPosition(-off + jx, this.style.labelY + jy * 0.5);
|
||||||
|
s.ghostCyan.setAlpha(a * 0.95).setPosition(off * 0.85, this.style.labelY - off * 0.3 + jy);
|
||||||
|
s.iconGhostM.setAlpha(a * 0.8).setPosition(-off + jx, this.style.iconY + jy * 0.5);
|
||||||
|
s.iconGhostC.setAlpha(a * 0.75).setPosition(off * 0.85, this.style.iconY - off * 0.3 + jy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bar itself: the panel outline pulled apart in magenta/cyan while
|
||||||
|
// the burst is live — the RGB separation on the bar, not just the text.
|
||||||
|
if (L > 0.02) {
|
||||||
|
const po = 2 + L * 5;
|
||||||
|
this.ghostPanelM.setAlpha(L * 0.5).setPosition(-po, L * 1.5);
|
||||||
|
this.ghostPanelC.setAlpha(L * 0.45).setPosition(po * 0.85, -po * 0.3);
|
||||||
|
} else {
|
||||||
|
this.ghostPanelM.setAlpha(0);
|
||||||
|
this.ghostPanelC.setAlpha(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reap expired slice bars.
|
||||||
|
if (this.slices.length > 0) {
|
||||||
|
this.slices = this.slices.filter((sl) => {
|
||||||
|
if (time >= sl.die) {
|
||||||
|
sl.g.destroy();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is (px, py) — screen coords — over the deck's strip? */
|
||||||
|
contains(px, py) {
|
||||||
|
const { x, y, w, h } = this.rect;
|
||||||
|
return px >= x && px <= x + w && py >= y && py <= y + h;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.dead) return;
|
||||||
|
this.dead = true;
|
||||||
|
for (const sl of this.slices) sl.g.destroy();
|
||||||
|
this.slices.length = 0;
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
range(lo, hi) {
|
||||||
|
return lo + Math.random() * (hi - lo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Procedural art (file-local helpers — no state, no scene bookkeeping)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Blend two color ints toward each other: mix(0x0b1322, 0x00e5ff, 0.16). */
|
||||||
|
function mixColor(a, b, t) {
|
||||||
|
const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t);
|
||||||
|
const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t);
|
||||||
|
const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t);
|
||||||
|
return (r << 16) | (g << 8) | bl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '#rrggbb' → 'rgba(r,g,b,a)' for canvas 2D fills. */
|
||||||
|
function hexA(hex, a) {
|
||||||
|
const n = toColor(hex);
|
||||||
|
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console panel, drawn into a canvas (P = halo bleed around the
|
||||||
|
* panel rect): neon halo, gradient body, clipped inner detail (top
|
||||||
|
* energy glow, hatch texture, vignette), a cyan→magenta energy rail on
|
||||||
|
* the top edge, ruler ticks, and viewfinder corner brackets.
|
||||||
|
*/
|
||||||
|
function drawBarBody(ctx, W, H, P, c) {
|
||||||
|
const w = W - P * 2;
|
||||||
|
const h = H - P * 2;
|
||||||
|
const x = P;
|
||||||
|
const y = P;
|
||||||
|
const cut = Math.max(4, Math.min(14, Math.round(h * 0.16)));
|
||||||
|
|
||||||
|
const panel = new Path2D();
|
||||||
|
panel.moveTo(x + cut, y);
|
||||||
|
panel.lineTo(x + w - cut, y);
|
||||||
|
panel.lineTo(x + w, y + cut);
|
||||||
|
panel.lineTo(x + w, y + h - cut);
|
||||||
|
panel.lineTo(x + w - cut, y + h);
|
||||||
|
panel.lineTo(x + cut, y + h);
|
||||||
|
panel.lineTo(x, y + h - cut);
|
||||||
|
panel.lineTo(x, y + cut);
|
||||||
|
panel.closePath();
|
||||||
|
|
||||||
|
// 1) Outer halo — cyan light above, magenta light below (the console
|
||||||
|
// is backlit). A translucent fill + shadowBlur is the glow pass.
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(0,229,255,0.45)';
|
||||||
|
ctx.shadowBlur = 18;
|
||||||
|
ctx.shadowOffsetY = -3;
|
||||||
|
ctx.fillStyle = 'rgba(0,229,255,0.05)';
|
||||||
|
ctx.fill(panel);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(255,45,111,0.35)';
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.shadowOffsetY = 5;
|
||||||
|
ctx.fillStyle = 'rgba(255,45,111,0.04)';
|
||||||
|
ctx.fill(panel);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 2) Body — deep console blue fading to near-black toward the floor.
|
||||||
|
const body = ctx.createLinearGradient(0, y, 0, y + h);
|
||||||
|
body.addColorStop(0, c.panelTop);
|
||||||
|
body.addColorStop(0.45, '#0a1326');
|
||||||
|
body.addColorStop(1, c.panelBottom);
|
||||||
|
ctx.fillStyle = body;
|
||||||
|
ctx.fill(panel);
|
||||||
|
|
||||||
|
// 3) Inner detail, clipped to the panel.
|
||||||
|
ctx.save();
|
||||||
|
ctx.clip(panel);
|
||||||
|
|
||||||
|
// Top energy glow bleeding down from the rail.
|
||||||
|
const topGlow = ctx.createLinearGradient(0, y, 0, y + 18);
|
||||||
|
topGlow.addColorStop(0, 'rgba(0,229,255,0.26)');
|
||||||
|
topGlow.addColorStop(1, 'rgba(0,229,255,0)');
|
||||||
|
ctx.fillStyle = topGlow;
|
||||||
|
ctx.fillRect(x, y, w, 18);
|
||||||
|
|
||||||
|
// Side glows — cyan toward the left edge, magenta toward the right.
|
||||||
|
const leftGlow = ctx.createLinearGradient(x, 0, x + 46, 0);
|
||||||
|
leftGlow.addColorStop(0, 'rgba(0,229,255,0.09)');
|
||||||
|
leftGlow.addColorStop(1, 'rgba(0,229,255,0)');
|
||||||
|
ctx.fillStyle = leftGlow;
|
||||||
|
ctx.fillRect(x, y, 46, h);
|
||||||
|
|
||||||
|
const rightGlow = ctx.createLinearGradient(x + w - 46, 0, x + w, 0);
|
||||||
|
rightGlow.addColorStop(0, 'rgba(255,45,111,0)');
|
||||||
|
rightGlow.addColorStop(1, 'rgba(255,45,111,0.11)');
|
||||||
|
ctx.fillStyle = rightGlow;
|
||||||
|
ctx.fillRect(x + w - 46, y, 46, h);
|
||||||
|
|
||||||
|
// Bottom inner glow + settle vignette.
|
||||||
|
const botGlow = ctx.createLinearGradient(0, y + h - 14, 0, y + h);
|
||||||
|
botGlow.addColorStop(0, 'rgba(255,45,111,0)');
|
||||||
|
botGlow.addColorStop(1, 'rgba(255,45,111,0.14)');
|
||||||
|
ctx.fillStyle = botGlow;
|
||||||
|
ctx.fillRect(x, y + h - 14, w, 14);
|
||||||
|
|
||||||
|
const vin = ctx.createLinearGradient(0, y + h * 0.55, 0, y + h);
|
||||||
|
vin.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
vin.addColorStop(1, 'rgba(0,0,0,0.3)');
|
||||||
|
ctx.fillStyle = vin;
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
// Diagonal hatch — faint technical texture across the whole panel.
|
||||||
|
ctx.strokeStyle = hexA(c.hatch, 0.05);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = -h; i < w; i += 18) {
|
||||||
|
ctx.moveTo(x + i, y);
|
||||||
|
ctx.lineTo(x + i + h, y + h);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 4) The energy rail — cyan→magenta across the top edge (the deck's
|
||||||
|
// signature line), with a fainter magenta floor line below.
|
||||||
|
const rail = ctx.createLinearGradient(x, 0, x + w, 0);
|
||||||
|
rail.addColorStop(0, 'rgba(0,229,255,0.05)');
|
||||||
|
rail.addColorStop(0.12, 'rgba(0,229,255,0.9)');
|
||||||
|
rail.addColorStop(0.5, 'rgba(170,255,255,0.95)');
|
||||||
|
rail.addColorStop(0.88, 'rgba(255,45,111,0.85)');
|
||||||
|
rail.addColorStop(1, 'rgba(255,45,111,0.05)');
|
||||||
|
ctx.fillStyle = rail;
|
||||||
|
ctx.fillRect(x + cut, y, w - cut * 2, 2);
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,45,111,0.3)';
|
||||||
|
ctx.fillRect(x + cut, y + h - 1.5, w - cut * 2, 1.5);
|
||||||
|
|
||||||
|
// 5) Ruler ticks just under the rail.
|
||||||
|
ctx.fillStyle = 'rgba(0,229,255,0.22)';
|
||||||
|
for (let tx = x + 30; tx < x + w - 26; tx += 22) {
|
||||||
|
ctx.fillRect(tx, y + 5, 1, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Viewfinder corner brackets — HUD chrome on each cut corner.
|
||||||
|
ctx.strokeStyle = 'rgba(0,229,255,0.5)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
const L = 11;
|
||||||
|
const corner = (px, py, sx, sy) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + sx * L, py);
|
||||||
|
ctx.lineTo(px, py);
|
||||||
|
ctx.lineTo(px, py + sy * L);
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
corner(x - 3, y - 3, 1, 1);
|
||||||
|
corner(x + w + 3, y - 3, -1, 1);
|
||||||
|
corner(x - 3, y + h + 3, 1, -1);
|
||||||
|
corner(x + w + 3, y + h + 3, -1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawn slot icons — small, geometric, in the console's language.
|
||||||
|
* Each icon is drawn centered on the icon Graphics' origin.
|
||||||
|
*/
|
||||||
|
function drawIcon(g, id, size, color) {
|
||||||
|
const r = size * 0.44;
|
||||||
|
const glow = (lw) => g.lineStyle(lw, color, 0.25);
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 'research': {
|
||||||
|
// Orbit: a ring with a lit trail, a satellite riding it, a core.
|
||||||
|
glow(4);
|
||||||
|
g.strokeCircle(0, 0, r);
|
||||||
|
g.lineStyle(1.5, color, 0.95);
|
||||||
|
g.strokeCircle(0, 0, r);
|
||||||
|
g.lineStyle(2, color, 0.5); // trail behind the satellite
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(0, 0, r, -110 * DEG, -25 * DEG);
|
||||||
|
g.stroke();
|
||||||
|
const sa = -35 * DEG;
|
||||||
|
g.fillStyle(color, 1);
|
||||||
|
g.fillCircle(Math.cos(sa) * r, Math.sin(sa) * r, 2.6);
|
||||||
|
g.fillStyle(color, 0.9);
|
||||||
|
g.fillCircle(0, 0, 1.7);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'build': {
|
||||||
|
// Isometric cube — construction.
|
||||||
|
const v = [0, -30, 30, 90, 150, 210].map((deg) => ({
|
||||||
|
x: Math.cos(deg * DEG) * r,
|
||||||
|
y: Math.sin(deg * DEG) * r,
|
||||||
|
}));
|
||||||
|
glow(4);
|
||||||
|
g.strokePoints(v, true);
|
||||||
|
g.lineStyle(1.5, color, 0.95);
|
||||||
|
g.strokePoints(v, true);
|
||||||
|
g.lineStyle(1.5, color, 0.8); // inner edges of the cube
|
||||||
|
g.lineBetween(0, 0, v[0].x, v[0].y);
|
||||||
|
g.lineBetween(0, 0, v[2].x, v[2].y);
|
||||||
|
g.lineBetween(0, 0, v[4].x, v[4].y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ship': {
|
||||||
|
// Chevron arrow, nose up.
|
||||||
|
const pts = [
|
||||||
|
{ x: 0, y: -r },
|
||||||
|
{ x: r * 0.78, y: r * 0.62 },
|
||||||
|
{ x: 0, y: r * 0.18 },
|
||||||
|
{ x: -r * 0.78, y: r * 0.62 },
|
||||||
|
];
|
||||||
|
glow(4);
|
||||||
|
g.strokePoints(pts, true);
|
||||||
|
g.lineStyle(1.5, color, 0.95);
|
||||||
|
g.strokePoints(pts, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'menu': {
|
||||||
|
// Signal bars — slightly ragged widths, left-aligned.
|
||||||
|
g.fillStyle(color, 0.9);
|
||||||
|
g.fillRect(-r, -r * 0.75, r * 2, 2.4);
|
||||||
|
g.fillRect(-r, -1.2, r * 1.5, 2.4);
|
||||||
|
g.fillRect(-r, r * 0.75 - 2.4, r * 1.85, 2.4);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Reserved socket: a dim diamond — a slot waiting for a command.
|
||||||
|
const d = [
|
||||||
|
{ x: 0, y: -7 },
|
||||||
|
{ x: 7, y: 0 },
|
||||||
|
{ x: 0, y: 7 },
|
||||||
|
{ x: -7, y: 0 },
|
||||||
|
];
|
||||||
|
g.lineStyle(1.2, color, 0.45);
|
||||||
|
g.strokePoints(d, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue