Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Fertig 9299859003 Add CRT scanlines and RGB pull-apart to the command deck
- Port the menu's GlitchText chromatic fringe to ActionBar: additive
  magenta/cyan icon ghosts, label ghosts with per-slot phase, and a panel
  outline ghost that activates during glitch bursts.
- Add a tiled-scanline overlay (1 dark row per pitch) matching CyberOverlay,
  configurable via actionbar.scanline with theme.overlay.scanline defaults.
- Replace idleGhost with animation.rgb (idleOffset/burstOffset/idleAlpha/
  burstAlpha); level now decays ~90 ms after triggerBurst for the fast-snap
  GlitchText feel while slice bars linger.
- Update README, PROJECT_NOTES, and research-builds tests to cover the new
  scanline and rgb config keys.
2026-09-03 20:42:33 -06:00
Brian Fertig 2253251768 Add command deck UI and research/build data layer
- New ActionBar component (js/ui/ActionBar.js) renders a procedural cyberpunk
  bottom bar with six slots (Research, Build, Ship, two reserved, Menu),
  boot flicker, rail comet, glitch bursts, and RGB shimmer; fully driven by
  data/actionbar.json
- Data contracts for the player's progression loop: research.json (time-based,
  one project at a time, gates builds/research) and builds.json (credits +
  minerals, ship/planet/station categories), both with _template docs and
  empty maps; registered in manifest.json
- GameScene wires the deck in, routes clicks over it away from fly-to, pins
  the hint above the bar, and drives per-frame update/shutdown
- New dev/research-builds.test.mjs pins the data contract (manifest entries,
  rule knobs, template fields, six-slot order)
- cdpshot.mjs: cross-platform temp dir + --no-sandbox for container/CI
- README and PROJECT_NOTES updated with the new loop, deck docs, and a Phaser
  v4 scrollFactor-on-screen-fixed-container quirk
2026-09-03 20:33:04 -06:00
11 changed files with 1212 additions and 6 deletions

View File

@ -68,6 +68,9 @@ orbit/
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── 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
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
@ -78,7 +81,7 @@ orbit/
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
│ ├── 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)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser
@ -93,12 +96,40 @@ orbit/
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
- 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
```sh
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/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),

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

60
data/actionbar.json Normal file
View File

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

29
data/builds.json Normal file
View File

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

View File

@ -8,6 +8,9 @@
"galaxy.json",
"systems.json",
"settlements.json",
"naming.json"
"naming.json",
"research.json",
"builds.json",
"actionbar.json"
]
}

20
data/research.json Normal file
View File

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

View File

@ -19,7 +19,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
* its profile dir.
*/
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 });
const chrome = spawn(CHROME, [
@ -29,6 +33,7 @@ export async function launch(targetUrl, port = 9333 + Math.floor(Math.random() *
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--no-sandbox', // container/CI friendly (also a no-op where it isn't needed)
'--enable-unsafe-swiftshader',
'--hide-scrollbars',
`--window-size=${width},${height}`,

View File

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

View File

@ -205,6 +205,14 @@ world** — solid, rendered, flyable-to. Rules and seams:
the `__MISSING` texture forever — even after the key is generated later
in the same session. Generate the texture **first** (see
`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
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
- [ ] Landing & exploration: settlements become points of interest you
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[]`:
clustering by type, borders, adjacency affinity) — hook marked in
`Galaxy._generate()`

View File

@ -10,6 +10,7 @@ import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { Starfield } from '../visuals/Starfield.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 HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
@ -122,9 +123,29 @@ export class GameScene extends Phaser.Scene {
}
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
.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(),
fontSize: '14px',
color: '#54608a',
@ -133,10 +154,12 @@ export class GameScene extends Phaser.Scene {
.setOrigin(0.5)
.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
// inside. (Worlds don't overlap, so sequential clamping is exact.)
this.input.on('pointerdown', (pointer) => {
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const p of this.solidPlanets) {
aim = p.aimPoint(aim.x, aim.y, this.ship.radius);
@ -221,6 +244,7 @@ export class GameScene extends Phaser.Scene {
}
this.updateCamera(delta);
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
}
@ -384,5 +408,6 @@ export class GameScene extends Phaser.Scene {
shutdown() {
this.starfield?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
}
}

919
js/ui/ActionBar.js Normal file
View File

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