diff --git a/README.md b/README.md index 7963f3c..b21bd72 100644 --- a/README.md +++ b/README.md @@ -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,38 @@ 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. All layout, + palette, and motion (boot flicker, rail comet, glitch bursts, RGB shimmer) + 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), diff --git a/assets/images/originals/terran-planet-01.png b/assets/images/originals/terran-planet-01.png new file mode 100644 index 0000000..8505472 Binary files /dev/null and b/assets/images/originals/terran-planet-01.png differ diff --git a/data/actionbar.json b/data/actionbar.json new file mode 100644 index 0000000..18f5065 --- /dev/null +++ b/data/actionbar.json @@ -0,0 +1,49 @@ +{ + "_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`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the idle RGB shimmer).", + "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" + }, + "animation": { + "bootStagger": 70, + "comet": { + "enabled": true, + "everyMs": [3600, 7800], + "durationMs": 950 + }, + "glitch": { + "enabled": true, + "intervalMs": [3500, 9000], + "durationMs": [240, 480], + "slices": [3, 6] + }, + "idleGhost": 0.09 + } +} diff --git a/data/builds.json b/data/builds.json new file mode 100644 index 0000000..094a7ec --- /dev/null +++ b/data/builds.json @@ -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.`). `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" + } + } +} diff --git a/data/manifest.json b/data/manifest.json index 020d928..23300f9 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -8,6 +8,9 @@ "galaxy.json", "systems.json", "settlements.json", - "naming.json" + "naming.json", + "research.json", + "builds.json", + "actionbar.json" ] } diff --git a/data/research.json b/data/research.json new file mode 100644 index 0000000..eb9ee9a --- /dev/null +++ b/data/research.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.`) and follow-on research (this file → `projects.`). 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" + } + } +} diff --git a/dev/cdpshot.mjs b/dev/cdpshot.mjs index 1799e54..798ea57 100644 --- a/dev/cdpshot.mjs +++ b/dev/cdpshot.mjs @@ -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}`, diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs new file mode 100644 index 0000000..8aa5b20 --- /dev/null +++ b/dev/research-builds.test.mjs @@ -0,0 +1,88 @@ +/** + * 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: 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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 8e013b1..38412df 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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,19 @@ 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 +- [ ] 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()` diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index e475aff..9c3e2d9 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); } } diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js new file mode 100644 index 0000000..3bcc315 --- /dev/null +++ b/js/ui/ActionBar.js @@ -0,0 +1,828 @@ +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. + * + * 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, + }; + this.idleGhost = anim.idleGhost ?? 0.09; + 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.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.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); + s.icon = scene.add.graphics().setPosition(0, iconY).setScrollFactor(0); + slot.add([s.panel, s.icon]); + + // Drawn icon (accent for live slots, dim for reserved sockets). + drawIcon( + s.icon, + live ? b.id : 'reserved', + iconSize, + live ? s.accent : this.style.reserved, + ); + + 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; + }); + } + + // ------------------------------------------------------------------ + // 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 + jitter). */ + triggerBurst(duration = 300, _intensity = 1) { + const now = this.lastTime ?? this.scene.time.now; + this.burstT0 = now; + this.burstDur = Math.max(1, duration); + 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; + } + const level = + this.burstT0 === null ? 0 : Math.max(0, 1 - (time - this.burstT0) / this.burstDur); + + // Slots: idle RGB shimmer, hover ghosts, and burst jitter. + 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 idle = s.hoverOn + ? 0.85 + : this.idleGhost * (0.55 + 0.45 * Math.sin(t * 2.2 + s.phase)); + const ghostA = Math.max(idle, level * 0.8); + s.ghostCyan.setAlpha(ghostA); + s.ghostMagenta.setAlpha(ghostA * 0.9); + const jx = level * 2.6 * Math.sin(time * 0.061 + s.phase * 7); + const jy = level * 1.6 * Math.sin(time * 0.043 + s.phase * 5); + s.ghostCyan.setPosition(-1.8 + jx, this.style.labelY + jy * 0.4); + s.ghostMagenta.setPosition(1.8 - jx * 0.6, this.style.labelY - jy * 0.5); + s.label.setPosition(jx * 0.5, this.style.labelY + jy * 0.5); + } + + // 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); + } + } +}