orbit/js/ui/ActionBar.js

816 lines
30 KiB
JavaScript

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(); }
*
* A scene may pass its own slot list as `buttons` (an ordered array exactly
* like `actionbar.buttons`) — e.g. SurfaceScene's deck swaps Research for
* Shop and adds Take Off left of Menu. Layout, colors and animations stay
* shared from data/actionbar.json.
*
* Live slots fire `onAction(id, slot)` on press — behavior is the
* scene's job (the Research/Scan/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), buttons? } — `buttons` is an
* optional slot-list override (same shape as actionbar.json → buttons),
* for decks that differ per scene (SurfaceScene: shop/takeoff).
*/
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 = Array.isArray(o.buttons)
? { ...config.section('actionbar', {}), buttons: o.buttons }
: 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: 'scan', label: 'Scan', 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);
}