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. */ 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'); // 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(); const width = 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); // 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); this.ghostMagenta = scene.add.text(0, 0, labelText, { ...textStyle, color: neon2Css }) .setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD); // 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); this.labelText = scene.add.text(0, 0, labelText, textStyle).setOrigin(0.5); this.add([this.panel, this.ghostCyan, this.ghostMagenta, this.sweep, this.labelText]); this.setSize(width, height); // 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._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', () => { this.press(); if (typeof onClick === 'function') onClick(this); }); } /** Repaint the panel for a visual state: 'base' | 'hover'. */ paint(state) { const s = this.style; 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) { 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) 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.paint(this.hoverOn ? 'hover' : 'base'); }); } }