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"; /** * 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 (label-only, per-slot accent rail on top, RGB-split labels), * 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 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'), labelY: 0, // label-only buttons: the text is centred vertically }; // 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'); 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); slot.add(s.panel); 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 (centred) that breathes in update(). s.resDot = scene.add.circle(0, 0, 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 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 the 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); } // The bar itself: the panel outline pulled apart in magenta/cyan while // the burst is live — the RGB separation on the bar, not just the text. if (L > 0.02) { const po = 2 + L * 5; this.ghostPanelM.setAlpha(L * 0.5).setPosition(-po, L * 1.5); this.ghostPanelC.setAlpha(L * 0.45).setPosition(po * 0.85, -po * 0.3); } else { this.ghostPanelM.setAlpha(0); this.ghostPanelC.setAlpha(0); } // Reap expired slice bars. if (this.slices.length > 0) { this.slices = this.slices.filter((sl) => { if (time >= sl.die) { sl.g.destroy(); return false; } return true; }); } } /** Is (px, py) — screen coords — over the deck's strip? */ contains(px, py) { const { x, y, w, h } = this.rect; return px >= x && px <= x + w && py >= y && py <= y + h; } destroy() { if (this.dead) return; this.dead = true; for (const sl of this.slices) sl.g.destroy(); this.slices.length = 0; super.destroy(); } range(lo, hi) { return lo + Math.random() * (hi - lo); } } // ---------------------------------------------------------------------- // Procedural art (file-local helpers — no state, no scene bookkeeping) // ---------------------------------------------------------------------- /** Blend two color ints toward each other: mix(0x0b1322, 0x00e5ff, 0.16). */ function mixColor(a, b, t) { const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t); const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t); const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t); return (r << 16) | (g << 8) | bl; } /** '#rrggbb' → 'rgba(r,g,b,a)' for canvas 2D fills. */ function hexA(hex, a) { const n = toColor(hex); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } /** * The console panel, drawn into a canvas (P = halo bleed around the * panel rect): neon halo, gradient body, clipped inner detail (top * energy glow, hatch texture, vignette), a cyan→magenta energy rail on * the top edge, ruler ticks, and viewfinder corner brackets. */ function drawBarBody(ctx, W, H, P, c) { const w = W - P * 2; const h = H - P * 2; const x = P; const y = P; const cut = Math.max(4, Math.min(14, Math.round(h * 0.16))); const panel = new Path2D(); panel.moveTo(x + cut, y); panel.lineTo(x + w - cut, y); panel.lineTo(x + w, y + cut); panel.lineTo(x + w, y + h - cut); panel.lineTo(x + w - cut, y + h); panel.lineTo(x + cut, y + h); panel.lineTo(x, y + h - cut); panel.lineTo(x, y + cut); panel.closePath(); // 1) Outer halo — cyan light above, magenta light below (the console // is backlit). A translucent fill + shadowBlur is the glow pass. ctx.save(); ctx.shadowColor = 'rgba(0,229,255,0.45)'; ctx.shadowBlur = 18; ctx.shadowOffsetY = -3; ctx.fillStyle = 'rgba(0,229,255,0.05)'; ctx.fill(panel); ctx.restore(); ctx.save(); ctx.shadowColor = 'rgba(255,45,111,0.35)'; ctx.shadowBlur = 16; ctx.shadowOffsetY = 5; ctx.fillStyle = 'rgba(255,45,111,0.04)'; ctx.fill(panel); ctx.restore(); // 2) Body — deep console blue fading to near-black toward the floor. const body = ctx.createLinearGradient(0, y, 0, y + h); body.addColorStop(0, c.panelTop); body.addColorStop(0.45, '#0a1326'); body.addColorStop(1, c.panelBottom); ctx.fillStyle = body; ctx.fill(panel); // 3) Inner detail, clipped to the panel. ctx.save(); ctx.clip(panel); // Top energy glow bleeding down from the rail. const topGlow = ctx.createLinearGradient(0, y, 0, y + 18); topGlow.addColorStop(0, 'rgba(0,229,255,0.26)'); topGlow.addColorStop(1, 'rgba(0,229,255,0)'); ctx.fillStyle = topGlow; ctx.fillRect(x, y, w, 18); // Side glows — cyan toward the left edge, magenta toward the right. const leftGlow = ctx.createLinearGradient(x, 0, x + 46, 0); leftGlow.addColorStop(0, 'rgba(0,229,255,0.09)'); leftGlow.addColorStop(1, 'rgba(0,229,255,0)'); ctx.fillStyle = leftGlow; ctx.fillRect(x, y, 46, h); const rightGlow = ctx.createLinearGradient(x + w - 46, 0, x + w, 0); rightGlow.addColorStop(0, 'rgba(255,45,111,0)'); rightGlow.addColorStop(1, 'rgba(255,45,111,0.11)'); ctx.fillStyle = rightGlow; ctx.fillRect(x + w - 46, y, 46, h); // Bottom inner glow + settle vignette. const botGlow = ctx.createLinearGradient(0, y + h - 14, 0, y + h); botGlow.addColorStop(0, 'rgba(255,45,111,0)'); botGlow.addColorStop(1, 'rgba(255,45,111,0.14)'); ctx.fillStyle = botGlow; ctx.fillRect(x, y + h - 14, w, 14); const vin = ctx.createLinearGradient(0, y + h * 0.55, 0, y + h); vin.addColorStop(0, 'rgba(0,0,0,0)'); vin.addColorStop(1, 'rgba(0,0,0,0.3)'); ctx.fillStyle = vin; ctx.fillRect(x, y, w, h); // Diagonal hatch — faint technical texture across the whole panel. ctx.strokeStyle = hexA(c.hatch, 0.05); ctx.lineWidth = 1; ctx.beginPath(); for (let i = -h; i < w; i += 18) { ctx.moveTo(x + i, y); ctx.lineTo(x + i + h, y + h); } ctx.stroke(); ctx.restore(); // 4) The energy rail — cyan→magenta across the top edge (the deck's // signature line), with a fainter magenta floor line below. const rail = ctx.createLinearGradient(x, 0, x + w, 0); rail.addColorStop(0, 'rgba(0,229,255,0.05)'); rail.addColorStop(0.12, 'rgba(0,229,255,0.9)'); rail.addColorStop(0.5, 'rgba(170,255,255,0.95)'); rail.addColorStop(0.88, 'rgba(255,45,111,0.85)'); rail.addColorStop(1, 'rgba(255,45,111,0.05)'); ctx.fillStyle = rail; ctx.fillRect(x + cut, y, w - cut * 2, 2); ctx.fillStyle = 'rgba(255,45,111,0.3)'; ctx.fillRect(x + cut, y + h - 1.5, w - cut * 2, 1.5); // 5) Ruler ticks just under the rail. ctx.fillStyle = 'rgba(0,229,255,0.22)'; for (let tx = x + 30; tx < x + w - 26; tx += 22) { ctx.fillRect(tx, y + 5, 1, 4); } // 6) Viewfinder corner brackets — HUD chrome on each cut corner. ctx.strokeStyle = 'rgba(0,229,255,0.5)'; ctx.lineWidth = 2; const L = 11; const corner = (px, py, sx, sy) => { ctx.beginPath(); ctx.moveTo(px + sx * L, py); ctx.lineTo(px, py); ctx.lineTo(px, py + sy * L); ctx.stroke(); }; corner(x - 3, y - 3, 1, 1); corner(x + w + 3, y - 3, -1, 1); corner(x - 3, y + h + 3, 1, -1); corner(x + w + 3, y + h + 3, -1, -1); }