220 lines
8.4 KiB
JavaScript
220 lines
8.4 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.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
|
|
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 (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
|
|
|
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');
|
|
});
|
|
}
|
|
}
|