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'); }); } }