816 lines
30 KiB
JavaScript
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);
|
|
}
|