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'; import { MenuButton } from './MenuButton.js'; import { setInteractiveEnabled } from '../utils/Input.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; /** * The mining context menu — a small cut-corner panel with two stacked * buttons that opens right where the player clicked a rock: * * ▸ * ┌──────────────────┐ * │ MINE ASTEROIDS │ (STOP MINING, when this cluster is the beam's target) * │ CANCEL │ * └──────────────────┘ * * WORLD-ANCHORED (unlike the deck/sub-bar, which are screen-fixed): the * menu sits on the asteroid it refers to, so it stays with the rock while * the camera trails. A click ON a button is the button's (MenuButton's * own pointerdown listener); any click ELSEWHERE is the scene's — it * closes the menu and that click is consumed (no fly-here). Same * contract as the menu sub-bar, driven from GameScene's pointerdown. * * The panel unfolds out of the click point (scale + fade, ~150 ms). * * const pop = new MiningPopup(scene, { onAction: (id, target) => ... }); * pop.open(wx, wy, screenY, { name, primaryId, primaryLabel, target }); * pop.close(); * pop.contains(wx, wy); // WORLD coords * pop.isOpen; pop.destroy(); */ export class MiningPopup extends Phaser.GameObjects.Container { /** * @param {Phaser.Scene} scene * @param {object} [o] { onAction?: (id: 'mine'|'stop'|'cancel', target) => void } */ constructor(scene, o = {}) { super(scene, 0, 0); this.scene.add.existing(this); // v4: new'd containers are not on the display list // Above the ship (10) and the mining beam (11–12); under the console // toasts (45) and the deck (50). this.setDepth(40); this.onAction = typeof o.onAction === 'function' ? o.onAction : null; const cfg = config.section('asteroids.mining.popup', {}); this.W = cfg.width ?? 212; this.padTop = cfg.padTop ?? 10; this.padBottom = cfg.padBottom ?? 12; this.gap = cfg.gap ?? 8; this.headerH = cfg.headerHeight ?? 20; this.lift = cfg.lift ?? 14; // px between the click point and the panel const panelFill = toColor(themeColor('panel', 0x0a1120)); const border = toColor(config.get('menu.colors.buttonBorder', '#2aa9c9')); const neon = themeColor('neon', 0x00e5ff); const ink = toColor(themeColor('ink', 0xeaf6ff)); const dim = toColor(themeColor('dim', 0x7d92c4)); const btnW = this.W - 24; const mkBtn = (label, accent, text) => new MenuButton(scene, 0, 0, label, null, { width: btnW, fontSize: 13, paddingX: 12, paddingY: 9, letterSpacing: 2, upper: true, textColor: text, bgColor: panelFill, hoverColor: panelFill, borderColor: toCss(accent), }); // The primary is built at its LONGEST label ("MINE ASTEROIDS") so the // panel width is final; open() re-labels it per target. this.primaryBtn = mkBtn('MINE ASTEROIDS', neon, toCss(ink)); this.primaryBtn.style.stroke = neon; this.primaryBtn.style.neon = neon; this.primaryBtn.panel.on('pointerdown', () => this.fire(this.primaryId)); this.cancelBtn = mkBtn('CANCEL', dim, toCss(dim)); this.cancelBtn.style.stroke = toColor(0x44506e); this.cancelBtn.style.neon = dim; this.cancelBtn.panel.on('pointerdown', () => this.fire('cancel')); // The data width is a BASE, not a ceiling: MenuButton measures itself // in the real (web) font and won't shrink below its label (width = // max(requested, labelWidth + 20)). Keep the panel wide enough that the // widest button still sits fully inside it, 12 px out from each edge. this.W = Math.max( this.W, this.primaryBtn.style.width + 24, this.cancelBtn.style.width + 24, ); const h1 = this.primaryBtn.style.height; const h2 = this.cancelBtn.style.height; this.H = this.padTop + this.headerH + this.gap + h1 + this.gap + h2 + this.padBottom; this.h1 = h1; this.h2 = h2; // The panel body (redrawn only on open — it is static while up). this.panelG = scene.add.graphics(); const panel = (dir) => { this.panelG.clear(); const cy = dir * (this.H / 2); this.panelG.setPosition(0, cy); CyberShape.draw(this.panelG, this.W, this.H, { notch: Math.min(12, this.H * 0.16), fill: panelFill, fillAlpha: 0.9, stroke: border, strokeAlpha: 0.85, lineWidth: 1.5, glow: neon, glowAlpha: 0.16, }); // The top rail (the sub-bar's language): a neon edge + a magenta // counter-edge where the panel meets its header. const railY = dir < 0 ? -this.H / 2 + 0.5 : this.H / 2 - 2; this.panelG.fillStyle(neon, 0.85); this.panelG.fillRect(-this.W / 2 + 8, railY, this.W - 16, 1.5); this.panelG.fillStyle(themeColor('neon2', 0xff2d6f), 0.35); this.panelG.fillRect(-this.W / 2 + 8, dir < 0 ? railY + 2 : railY - 1, this.W - 16, 1); }; panel(-1); // The header: the target's name (dim, small, console caps). const fam = fontStack('body', FONT_FALLBACK); this.headerText = scene.add .text(0, 0, '', { fontFamily: fam, fontSize: '11px', color: toCss(dim), letterSpacing: 2, }) .setOrigin(0.5, 0.5); this.add([this.panelG, this.headerText, this.primaryBtn, this.cancelBtn]); this.layout(-1); // CLOSED = input-inert (the v4 idiom — setInteractiveEnabled). setInteractiveEnabled(this.primaryBtn.panel, false); setInteractiveEnabled(this.cancelBtn.panel, false); this.setAlpha(0); this.state = 'closed'; // 'closed' | 'open' | 'closing' this.lastTarget = null; // the scene's rock hit record (passed to onAction) this.primaryId = 'mine'; // what the primary button fires ('mine' | 'stop') // Stamped when a BUTTON press fires (its action closes the menu). The // scene's pointerdown uses this to know that very click belongs to the // menu — button handlers and the scene handler race on event order, so // the click must never fall through into "fly here". this.closedByButtonAt = null; } get isOpen() { return this.state === 'open'; } /** * Open at the click point (WORLD coords). `screenY` (the click's * SCREEN y) decides the side: near the top edge the panel drops BELOW * the click so it stays on screen. */ open(wx, wy, screenY, o = {}) { if (this.scene === null || this.active === false) return; this.scene.playSfx?.('ui_window'); // the window whoosh (the scene is the voice) this.lastTarget = o.target ?? null; const name = String(o.name ?? 'ASTEROID CLUSTER').toUpperCase(); const primaryLabel = String(o.primaryLabel ?? 'MINE ASTEROIDS').toUpperCase(); this.primaryId = o.primaryId ?? 'mine'; this.headerText.setText(`\u25b8 ${name}`); this.primaryBtn.labelText.setText(primaryLabel); const dir = screenY !== undefined && screenY < this.H + 30 ? 1 : -1; this.layout(dir); this.setPosition(wx, wy + dir * this.lift); this.rect = { x: wx - this.W / 2, y: dir < 0 ? wy - this.lift - this.H : wy + this.lift, w: this.W, h: this.H, }; this.scene.tweens.killTweensOf(this); this.state = 'open'; this.primaryBtn.hover(false); this.cancelBtn.hover(false); this.primaryBtn.paint('base'); this.cancelBtn.paint('base'); setInteractiveEnabled(this.primaryBtn.panel, true); setInteractiveEnabled(this.cancelBtn.panel, true); this.setAlpha(0).setScale(0.9); this.scene.tweens.add({ targets: this, alpha: 1, scale: 1, duration: 150, ease: 'Sine.easeOut', }); } /** Close (any click outside, a button press, or ESC). */ close() { if (this.state !== 'open') return; this.state = 'closing'; this.scene.tweens.killTweensOf(this); setInteractiveEnabled(this.primaryBtn.panel, false); setInteractiveEnabled(this.cancelBtn.panel, false); this.scene.tweens.add({ targets: this, alpha: 0, scale: 0.94, duration: 120, ease: 'Sine.easeIn', onComplete: () => { if (this.state === 'closing') this.state = 'closed'; }, }); } /** Is (wx, wy) — WORLD coords — over the panel? */ contains(wx, wy) { const r = this.rect; return !!r && wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h; } /** * Lay the panel out: dir −1 = above the anchor (default), +1 = below. * p() maps a distance from the PANEL'S TOP EDGE (0 = top edge) to a * container offset: the panel's near edge sits at the anchor, so above * it the whole panel hangs up (top edge at −H) and below it the panel * grows down (top edge at 0). Top→bottom is always * header · MINE ASTEROIDS · CANCEL, whichever side it opens on. */ layout(dir) { const p = (top) => (dir < 0 ? top - this.H : top); this.panelG.setPosition(0, dir * (this.H / 2)); this.headerText.setPosition(0, p(this.padTop + this.headerH / 2)); const top1 = this.padTop + this.headerH + this.gap + this.h1 / 2; // primary, from top const top2 = top1 + this.h1 / 2 + this.gap + this.h2 / 2; // cancel, from top this.primaryBtn.setPosition(0, p(top1)); this.cancelBtn.setPosition(0, p(top2)); } /** Fire an action id to the scene (its handler closes the menu). */ fire(id) { if (typeof this.onAction === 'function') { this.closedByButtonAt = this.scene.time.now; // this click is the menu's try { this.onAction(id, this.lastTarget); } catch (err) { console.error('[mining-popup] onAction failed', err); } } } /** * Does a SCREEN point (canvas coords, top-left origin) sit inside the * panel's current footprint — buttons included? GameScene uses this to * swallow the very click that just closed the panel through a button: * that click landed ON the panel, so it was panel business, not a * world click. Position-based, no timing. */ containsScreen(sx, sy) { const cam = this.scene.cameras.main; return this.contains(cam.scrollX + sx, cam.scrollY + sy); } destroy() { this.panelG?.destroy(); this.headerText?.destroy(); this.primaryBtn?.destroy(); this.cancelBtn?.destroy(); this.panelG = this.headerText = this.primaryBtn = this.cancelBtn = null; } }