orbit/js/ui/MiningPopup.js

282 lines
10 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'));
// 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.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;
}
}