orbit/js/ui/MenuButton.js

218 lines
8.2 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 { CyberShape } from './CyberShape.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The cyberpunk text button: a cut-corner panel (CyberShape), a neon
* edge, a light-streak sweep on hover, an RGB-split ghost on the label
* while hovered, and a white flash + scale punch on click.
*
* Drop-in for the old button — same constructor:
*
* new MenuButton(scene, x, y, 'New Game', () => ..., { fontSize: 14 });
*
* Colors/sizes come from data/menu.json (menu.colors + per-button
* overrides) with the palette in data/theme.json underneath.
*
* o.screenFixed (default false): the button lives in a scene whose camera
* MOVES (the game world). Screen-fixed UI must pin EVERY child to the
* screen (scrollFactor 0 — the v4 input quirk, see ActionBar), so this
* flag is passed by the in-game UI (MenuSubBar, SavePanel, …). The main
* menu's camera never scrolls, so it keeps the default.
*
* setDisabled(on): the grayed-out state (Load Game with no saves) —
* dim paint, no hover/press, clicks swallowed.
*/
export class MenuButton extends Phaser.GameObjects.Container {
constructor(scene, x, y, label, onClick, o = {}) {
super(scene, x, y);
const menuColors = config.section('menu.colors', {});
const fontFamily = o.fontFamily ?? fontStack('body', FONT_FALLBACK);
const fontSize = o.fontSize ?? config.get('menu.buttonFontSize', 22);
const paddingX = o.paddingX ?? 34;
const paddingY = o.paddingY ?? 14;
const upper = o.upper ?? config.get('menu.buttons.upper', true);
const letterSpacing = o.letterSpacing ?? 2;
const fill = toColor(o.bgColor ?? menuColors.buttonBg ?? '#0a1120');
const hoverFill = toColor(o.hoverColor ?? menuColors.buttonHoverBg ?? '#13233f');
const stroke = toColor(menuColors.buttonBorder ?? '#2aa9c9');
const neon = themeColor('neon', 0x00e5ff);
const neon2 = themeColor('neon2', 0xff2d6f);
const ink = toColor(o.textColor ?? menuColors.buttonText ?? '#eaf6ff');
// In-game (scrolling camera) UI pins every child to the screen — the
// v4 per-child scrollFactor quirk (ActionBar's note). Menu default 1
// leaves the existing menu buttons exactly as they were.
const sf = o.screenFixed === true ? 0 : 1;
// v4 quirk: Text styles go straight to canvas fillStyle — must be CSS
// strings, or the text renders black (see toCss in utils/Color.js).
const neonCss = toCss(neon);
const neon2Css = toCss(neon2);
const inkCss = toCss(ink);
const labelText = upper ? label.toUpperCase() : label;
const textStyle = {
fontFamily,
fontSize: `${fontSize}px`,
color: inkCss,
letterSpacing,
};
const w0 = scene.add.text(0, 0, labelText, textStyle);
const textW = w0.width;
const textH = w0.height;
w0.destroy();
// o.width (optional): a fixed panel width (the label is centred in it) —
// used by ConfirmOverlay so the confirm button fits its longest label.
const width = o.width ? Math.max(o.width, textW + 20) : textW + paddingX * 2;
const height = textH + paddingY * 2;
const notch = Math.min(12, height * 0.3);
// Panel (redrawn on state changes — that's the whole styling system).
this.panel = scene.add.graphics().setPosition(0, 0).setScrollFactor(sf);
// RGB-split ghosts behind the label (additive), revealed on hover.
this.ghostCyan = scene.add.text(0, 0, labelText, { ...textStyle, color: neonCss })
.setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(sf);
this.ghostMagenta = scene.add.text(0, 0, labelText, { ...textStyle, color: neon2Css })
.setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(sf);
// Light streak that sweeps across the panel on hover.
this.sweep = scene.add.rectangle(0, 0, 26, Math.max(6, height - 10), neon, 0).setOrigin(0.5).setScrollFactor(sf);
this.labelText = scene.add.text(0, 0, labelText, textStyle).setOrigin(0.5).setScrollFactor(sf);
this.add([this.panel, this.ghostCyan, this.ghostMagenta, this.sweep, this.labelText]); this.setSize(width, height);
this.setScrollFactor(sf);
// Phaser v4: 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.style = { width, height, notch, fill, hoverFill, stroke, neon, neon2, ink, inkCss, labelText, textStyle };
this.hoverOn = false;
this.pressing = false;
this.disabled = false;
this._sweepTween = null;
this.paint('base');
// Hit-test the whole rect with an explicit area (independent of the
// Graphics' draw state, so repainting never breaks interaction).
this.panel.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
});
this.panel.on('pointerover', () => this.hover(true));
this.panel.on('pointerout', () => this.hover(false));
this.panel.on('pointerdown', () => {
if (this.disabled) return;
this.press();
if (typeof onClick === 'function') onClick(this);
});
}
/**
* The grayed-out state (Load Game while no saves exist): dim paint, no
* fringe, no hover/press, clicks swallowed.
*/
setDisabled(on) {
this.disabled = !!on;
if (on) this.hoverOn = false;
this.paint(on ? 'disabled' : (this.hoverOn ? 'hover' : 'base'));
}
/** Repaint the panel for a visual state: 'base' | 'hover' | 'disabled'. */
paint(state) {
const s = this.style;
if (state === 'disabled') {
this.panel.clear();
CyberShape.draw(this.panel, s.width, s.height, {
notch: s.notch,
fill: 0x0a0e16,
fillAlpha: 0.6,
stroke: 0x2a3242,
strokeAlpha: 0.5,
lineWidth: 1.5,
});
this.ghostCyan.setAlpha(0);
this.ghostMagenta.setAlpha(0);
// v4: Text colors are CSS strings (see toCss).
this.labelText.setColor('#5a6478');
return;
}
const hover = state === 'hover';
this.panel.clear();
CyberShape.draw(this.panel, s.width, s.height, {
notch: s.notch,
fill: hover ? s.hoverFill : s.fill,
fillAlpha: hover ? 0.92 : 0.72,
stroke: hover ? s.neon : s.stroke,
strokeAlpha: hover ? 1 : 0.8,
lineWidth: 1.5,
glow: hover ? s.neon : undefined,
glowAlpha: 0.22,
});
this.ghostCyan.setAlpha(hover ? 0.8 : 0).setPosition(-2.2, 0);
this.ghostMagenta.setAlpha(hover ? 0.8 : 0).setPosition(2.2, 0);
// v4: Text.setColor() stores the value verbatim for the canvas fillStyle —
// CSS string only.
this.labelText.setColor(hover ? '#ffffff' : s.inkCss);
}
hover(on) {
if (this.disabled) return;
this.hoverOn = on;
this.paint(on ? 'hover' : 'base');
if (this._sweepTween) {
this._sweepTween.remove();
this._sweepTween = null;
}
if (on) {
const half = this.style.width / 2 - 16;
this.sweep.setX(-half).setAlpha(0.4);
this._sweepTween = this.scene.tweens.add({
targets: this.sweep,
x: half,
duration: 380,
ease: 'Sine.easeOut',
onComplete: () => this.sweep.setAlpha(0),
});
} else {
this.sweep.setAlpha(0);
}
}
/** Click feedback: white flash + scale punch, then restore. */
press() {
if (this.pressing || this.disabled) return;
this.pressing = true;
const s = this.style;
this.panel.clear();
CyberShape.draw(this.panel, s.width, s.height, {
notch: s.notch,
fill: 0xdff6ff,
fillAlpha: 0.92,
stroke: 0xffffff,
strokeAlpha: 1,
lineWidth: 1.5,
});
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
this.scene.time.delayedCall(120, () => {
this.pressing = false;
if (this.active !== false && !this.disabled) this.paint(this.hoverOn ? 'hover' : 'base');
});
}
}