283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
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 (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;
|
||
}
|
||
}
|