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