orbit/js/ui/MiningPopup.js

252 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
*
* ▸ <cluster name>
* ┌──────────────────┐
* │ 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 (1112); 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'));
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.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. */
layout(dir) {
this.panelG.setPosition(0, dir * (this.H / 2));
this.headerText.setPosition(0, dir * (this.H - this.padTop - this.headerH / 2));
const y1 = this.H - this.padTop - this.headerH - this.gap - this.h1 / 2;
const y2 = y1 + this.h1 / 2 + this.gap + this.h2 / 2;
this.primaryBtn.setPosition(0, dir * y1);
this.cancelBtn.setPosition(0, dir * y2);
}
/** 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);
}
}
}
destroy() {
this.panelG?.destroy();
this.headerText?.destroy();
this.primaryBtn?.destroy();
this.cancelBtn?.destroy();
this.panelG = this.headerText = this.primaryBtn = this.cancelBtn = null;
}
}