520 lines
21 KiB
JavaScript
520 lines
21 KiB
JavaScript
/**
|
||
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
|
||
*
|
||
* ┌─────────────────────────────────────────────────────────────┐
|
||
* │ [ Buy/Sell | Buyback ] ← tabs │
|
||
* │ │
|
||
* │ ┌SELL 4×4┐ ┌ NET ┐ ┌BUY 4×4┐ │
|
||
* │ │ bays │ │ +420 │ │ stock │ │
|
||
* │ │ (cargo)│ │ cr │ │ (12–16)│ │
|
||
* │ └────────┘ │[Conf]│ └────────┘ │
|
||
* │ │[Clear]│ │
|
||
* └─────────────────────────────────────────────────────────────┘
|
||
*
|
||
* The two sides are TradeGrids (js/ui/shop/TradeGrid.js):
|
||
* · SELL (left) — the player's cargo bays. Tap a filled bay to check it
|
||
* off (a bay = 1 unit). Locked bays are the hold's upgrade room.
|
||
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper.
|
||
* The live NET preview (green ≥ 0, red < 0) and Confirm/Clear live in the
|
||
* center; Confirm is disabled while the trade is invalid (can't go negative,
|
||
* hold would overflow). SELL→BUY happens atomically (sell to free room for a
|
||
* buy).
|
||
*
|
||
* BUYBACK (session-local only) — the second tab. After the player SELLs
|
||
* something, the goods they sold are listed here and can be bought BACK —
|
||
* priced at the sale price (a neutral undo, so sell→buyback can't loop into
|
||
* free credits). It disappears when the player leaves the shop (no
|
||
* persistence; data/shopui.json).
|
||
*
|
||
* This component owns the SESSION state (soldThisSession, the active tab, the
|
||
* selection). The PRICING + preview math is js/economy/Economy.js (fixed
|
||
* per-shop prices + tradePreview). The credits/cargo ledger is ship.credits
|
||
* + ship.cargo (CargoHold). Headless-testable: the logic (preview / confirm /
|
||
* clear / buyback) is plain data; the Phaser rendering is best-effort.
|
||
*/
|
||
import Phaser from '../../vendor/phaser.js';
|
||
import { config } from '../../config/Config.js';
|
||
import * as E from '../../economy/Economy.js';
|
||
import { TradeGrid } from './TradeGrid.js';
|
||
|
||
export class ShopView extends Phaser.GameObjects.Container {
|
||
/**
|
||
* @param {object} scene — the SurfaceScene (or a headless fake).
|
||
* @param {object} o — { ship, galaxy, cls, systemId, gameScene, onLeave }
|
||
* ship — the Ship (credits + cargo).
|
||
* galaxy — the generated galaxy (Economy pricing).
|
||
* cls — the shop's economic class (Economy.economicClass).
|
||
* systemId — this system (the distance term for sell prices).
|
||
* gameScene — to refresh the credits/minerals HUD after a trade.
|
||
* onLeave — called by close() so the SurfaceScene can restore the
|
||
* surface loop (the deck's "Leave Shop" does this too).
|
||
*/
|
||
constructor(scene, o) {
|
||
super(scene, 0, 0);
|
||
this.scene = scene;
|
||
this.ship = o.ship;
|
||
this.galaxy = o.galaxy;
|
||
this.cls = o.cls;
|
||
this.systemId = o.systemId;
|
||
this.gameScene = o.gameScene;
|
||
this.onLeave = o.onLeave;
|
||
|
||
this.maxSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16));
|
||
this.capacity = this.ship.cargo ? this.ship.cargo.capacity : 0;
|
||
|
||
// --- session state ---------------------------------------------------
|
||
this.tab = 'trade'; // 'trade' | 'buyback'
|
||
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
|
||
this.buybackSel = new Map(); // goodId -> qty to buy back
|
||
this.sellGrid = null;
|
||
this.buyGrid = null;
|
||
this._content = null; // the active content container
|
||
this._previewText = null;
|
||
this._confirmBtn = null;
|
||
this._clearBtn = null;
|
||
this._tabBuyback = null;
|
||
|
||
this._buildTabs();
|
||
this.enter();
|
||
|
||
// Present in the scene, above the shop clip (depth 10) + deck (50), below
|
||
// toasts. setScrollFactor(0) = screen-fixed (the SurfaceScene is a full
|
||
// overlay). Best-effort so a headless fake scene still constructs.
|
||
this.setScrollFactor?.(0);
|
||
this.setDepth?.(100);
|
||
this.scene.add?.existing?.(this);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// ENTRY + TABS
|
||
// ---------------------------------------------------------------------
|
||
enter() {
|
||
this.setTab('trade');
|
||
return this;
|
||
}
|
||
|
||
isBuybackEnabled() {
|
||
return this.soldThisSession.some((r) => r.qty > 0);
|
||
}
|
||
|
||
setTab(t) {
|
||
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
|
||
this.tab = t;
|
||
this._destroyContent();
|
||
if (t === 'trade') this._showTrade();
|
||
else this._showBuyback();
|
||
this._refreshTabs();
|
||
return this;
|
||
}
|
||
|
||
_buildTabs() {
|
||
const tabs = config.get('shopui.tabs', {});
|
||
const W = this._W();
|
||
const y = tabs.y ?? 44;
|
||
const tw = tabs.w ?? 184, th = tabs.h ?? 46, gap = tabs.gap ?? 14;
|
||
const cx = W / 2;
|
||
const sellBtn = this._makeButton(tabs.buySell ?? 'Buy/Sell', tw, th, tabs.activeAccent ?? '#00e5ff');
|
||
sellBtn.setPosition(cx - gap / 2 - tw / 2, y);
|
||
this.add?.(sellBtn.container);
|
||
sellBtn.tap(() => this.setTab('trade'));
|
||
this._tabTrade = sellBtn;
|
||
|
||
const bbBtn = this._makeButton(tabs.buyback ?? 'Buyback', tw, th, tabs.activeAccent ?? '#00e5ff');
|
||
bbBtn.setPosition(cx + gap / 2 + tw / 2, y);
|
||
this.add?.(bbBtn.container);
|
||
bbBtn.tap(() => { if (this.isBuybackEnabled()) this.setTab('buyback'); });
|
||
this._tabBuyback = bbBtn;
|
||
}
|
||
|
||
_refreshTabs() {
|
||
const tabs = config.get('shopui.tabs', {});
|
||
const dim = tabs.disabledAlpha ?? 0.34;
|
||
const accent = tabs.activeAccent ?? '#00e5ff';
|
||
// active tab = bright, inactive = dimmer; buyback disabled = dim + no-op.
|
||
const tradeOn = this.tab === 'trade';
|
||
const bbOn = this.tab === 'buyback';
|
||
const bbEnabled = this.isBuybackEnabled();
|
||
this._tabTrade?.setAccent(accent);
|
||
this._tabTrade?.setAlpha(tradeOn ? 1 : 0.55);
|
||
this._tabBuyback?.setAccent(bbOn ? accent : '#7d92c4');
|
||
this._tabBuyback?.setAlpha(bbOn ? 1 : (bbEnabled ? 0.55 : dim));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons)
|
||
// ---------------------------------------------------------------------
|
||
_showTrade() {
|
||
const grid = config.get('shopui.grid', {});
|
||
const centerW = grid.centerW ?? 208;
|
||
const W = this._W();
|
||
|
||
const sellGrid = new TradeGrid(this.scene, {
|
||
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
|
||
cls: this.cls, systemId: this.systemId,
|
||
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
|
||
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
|
||
});
|
||
const buyGrid = new TradeGrid(this.scene, {
|
||
side: 'buy', maxSlots: this.maxSlots,
|
||
cls: this.cls, systemId: this.systemId,
|
||
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
|
||
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
|
||
});
|
||
this.sellGrid = sellGrid;
|
||
this.buyGrid = buyGrid;
|
||
|
||
// Layout: [SELL grid] [center] [BUY grid], centered in the viewport.
|
||
const totalW = sellGrid.width + centerW + buyGrid.width;
|
||
const leftX = Math.max(8, (W - totalW) / 2);
|
||
const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26;
|
||
sellGrid.setPosition(leftX, contentY);
|
||
const rightX = leftX + sellGrid.width + centerW;
|
||
buyGrid.setPosition(rightX, contentY);
|
||
|
||
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
|
||
content.add?.(sellGrid);
|
||
content.add?.(buyGrid);
|
||
|
||
// center column: NET preview + Confirm + Clear
|
||
const cx = leftX + sellGrid.width + centerW / 2;
|
||
const cy = contentY + sellGrid.height / 2;
|
||
this._previewText = this._makeText(cx, cy - 30, 'NET 0 cr', {
|
||
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`,
|
||
color: config.get('shopui.preview.zeroColor' , '#7d92c4'), align: 'center',
|
||
});
|
||
this._previewLabel = this._makeText(cx, cy - 62, config.get('shopui.preview.label', 'NET'), {
|
||
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`,
|
||
color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center',
|
||
});
|
||
content.add?.(this._previewLabel);
|
||
content.add?.(this._previewText);
|
||
|
||
const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54);
|
||
this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4'));
|
||
this._confirmBtn.setPosition(cx, cy + 34);
|
||
this._confirmBtn.tap(() => this.confirm());
|
||
this._confirmBtn.setAlpha(0);
|
||
content.add?.(this._confirmBtn.container);
|
||
|
||
this._clearBtn = this._makeButton(config.get('shopui.buttons.clearLabel', 'Clear'), bw, bh, config.get('shopui.buttons.clearAccent', '#7d92c4'));
|
||
this._clearBtn.setPosition(cx, cy + 104);
|
||
this._clearBtn.tap(() => this.clear());
|
||
this._clearBtn.setAlpha(0);
|
||
content.add?.(this._clearBtn.container);
|
||
|
||
this._content = content;
|
||
this.add?.(content);
|
||
|
||
// Data + live preview wiring.
|
||
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
||
buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
|
||
sellGrid.onSelectionChange = () => this._syncPreview();
|
||
buyGrid.onSelectionChange = () => this._syncPreview();
|
||
this._syncPreview();
|
||
|
||
// Reveal: SELL slides in from the left, BUY from the right.
|
||
sellGrid.slideIn();
|
||
buyGrid.slideIn();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// CONTENT — the buyback panel (goods sold this session, buy back)
|
||
// ---------------------------------------------------------------------
|
||
_showBuyback() {
|
||
const W = this._W();
|
||
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
|
||
const rows = this._bbRows();
|
||
const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26;
|
||
const cx = W / 2;
|
||
|
||
const title = this._makeText(cx, contentY + 10, 'BUY BACK', {
|
||
fontSize: '21px', color: '#eaf6ff', align: 'center' });
|
||
content.add?.(title);
|
||
|
||
let y = contentY + 56;
|
||
const rowH = 56;
|
||
for (const r of rows) {
|
||
const line = this._makeText(cx - 120, y, `${r.name} · sold ${r.qty} @ ${Math.round(r.price)} cr`, {
|
||
fontSize: '15px', color: '#eaf6ff', align: 'left' });
|
||
content.add?.(line);
|
||
const minus = this._makeText(cx + 60, y, '−', { fontSize: '20px', color: '#7d92c4', align: 'center' });
|
||
const qty = this._makeText(cx + 110, y, String(r.sel), { fontSize: '20px', color: '#00e5ff', align: 'center' });
|
||
const plus = this._makeText(cx + 160, y, '+', { fontSize: '20px', color: '#7d92c4', align: 'center' });
|
||
for (const el of [line, minus, qty, plus]) content.add?.(el);
|
||
const hit = [-16, -14, 32, 28];
|
||
this._bindTap(minus, () => this.bumpBuyback(r.id, -1), hit);
|
||
this._bindTap(plus, () => this.bumpBuyback(r.id, +1), hit);
|
||
y += rowH;
|
||
}
|
||
|
||
const cy = y + 24;
|
||
this._previewLabel = this._makeText(cx, cy - 8, config.get('shopui.preview.label', 'NET'), {
|
||
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center' });
|
||
content.add?.(this._previewLabel);
|
||
this._previewText = this._makeText(cx, cy + 22, 'NET 0 cr', {
|
||
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center' });
|
||
content.add?.(this._previewText);
|
||
|
||
const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54);
|
||
this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4'));
|
||
this._confirmBtn.setPosition(cx, cy + 92);
|
||
this._confirmBtn.tap(() => this.confirm());
|
||
this._confirmBtn.setAlpha(0);
|
||
content.add?.(this._confirmBtn.container);
|
||
|
||
this._content = content;
|
||
this.add?.(content);
|
||
this._syncPreview();
|
||
}
|
||
|
||
_bbRows() {
|
||
return this.soldThisSession.map((r) => ({
|
||
id: r.id, name: r.name ?? r.id, qty: r.qty, price: r.price,
|
||
sel: this.buybackSel.get(r.id) ?? 0,
|
||
}));
|
||
}
|
||
|
||
bumpBuyback(id, d = 1) {
|
||
const row = this.soldThisSession.find((r) => r.id === id);
|
||
if (!row) return this;
|
||
const cur = this.buybackSel.get(id) ?? 0;
|
||
const next = Math.max(0, Math.min(row.qty, cur + Math.floor(Number(d) || 0)));
|
||
if (next === 0) this.buybackSel.delete(id);
|
||
else this.buybackSel.set(id, next);
|
||
this._syncPreview();
|
||
return this;
|
||
}
|
||
|
||
getBuybackMap() {
|
||
const m = {};
|
||
for (const [id, q] of this.buybackSel) if (q > 0) m[id] = q;
|
||
return m;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// PREVIEW + CONFIRM + CLEAR (the testable core)
|
||
// ---------------------------------------------------------------------
|
||
preview() {
|
||
const cargo = this.ship.cargo ? this.ship.cargo.snapshot() : [];
|
||
const capacity = this.capacity;
|
||
const credits = this.ship.credits;
|
||
if (this.tab === 'trade') {
|
||
return E.tradePreview({
|
||
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
|
||
cargo, capacity, credits,
|
||
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
|
||
buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {},
|
||
});
|
||
}
|
||
// buyback — priced at the sale price (buyOverrides), a neutral undo.
|
||
const buyMap = {};
|
||
const buyOverrides = {};
|
||
for (const r of this._bbRows()) {
|
||
if (r.sel > 0) { buyMap[r.id] = r.sel; buyOverrides[r.id] = r.price; }
|
||
}
|
||
return E.tradePreview({
|
||
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
|
||
cargo, capacity, credits,
|
||
sellMap: {}, buyMap, buyOverrides,
|
||
});
|
||
}
|
||
|
||
confirm() {
|
||
const pv = this.preview();
|
||
if (!pv.valid) {
|
||
this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true);
|
||
return false;
|
||
}
|
||
this._applyTrade(pv);
|
||
this._afterTrade(pv);
|
||
return true;
|
||
}
|
||
|
||
clear() {
|
||
if (this.tab === 'trade') {
|
||
this.sellGrid?.clearSelection();
|
||
this.buyGrid?.clearSelection();
|
||
} else {
|
||
this.buybackSel = new Map();
|
||
}
|
||
this._syncPreview();
|
||
return this;
|
||
}
|
||
|
||
_applyTrade(pv) {
|
||
const ship = this.ship;
|
||
if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal);
|
||
if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal);
|
||
for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first
|
||
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id));
|
||
}
|
||
|
||
_afterTrade(pv) {
|
||
if (this.tab === 'trade') {
|
||
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
|
||
this.sellGrid?.clearSelection();
|
||
this.buyGrid?.clearSelection();
|
||
} else {
|
||
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
|
||
this.buybackSel = new Map();
|
||
}
|
||
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
||
this.refreshHud();
|
||
this._syncPreview();
|
||
this._refreshTabs();
|
||
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr → hold ${pv.cargoAfter}/${this.capacity}`, false);
|
||
}
|
||
|
||
_addSold(id, qty, price, name) {
|
||
let row = this.soldThisSession.find((r) => r.id === id);
|
||
if (!row) { row = { id, name: name ?? id, qty: 0, price: price ?? 0 }; this.soldThisSession.push(row); }
|
||
row.qty += qty;
|
||
if (price) row.price = price;
|
||
}
|
||
|
||
_reduceSold(id, qty) {
|
||
const row = this.soldThisSession.find((r) => r.id === id);
|
||
if (!row) return;
|
||
row.qty -= qty;
|
||
if (row.qty <= 0) this.soldThisSession = this.soldThisSession.filter((r) => r.id !== id);
|
||
}
|
||
|
||
refreshHud() {
|
||
this.gameScene?.refreshCreditsHud?.();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// PREVIEW SYNC (live NET + Confirm/Clear visibility)
|
||
// ---------------------------------------------------------------------
|
||
_syncPreview() {
|
||
const pv = this.preview();
|
||
const anySel = this._hasAnySelection();
|
||
if (this._previewText && typeof this._previewText.setText === 'function') {
|
||
const sign = pv.net >= 0 ? '+' : '';
|
||
this._previewText.setText(`NET ${sign}${Math.round(pv.net)} cr`);
|
||
const col = pv.net === 0
|
||
? config.get('shopui.preview.zeroColor', '#7d92c4')
|
||
: (pv.net > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
|
||
if (typeof this._previewText.setColor === 'function') this._previewText.setColor(col);
|
||
}
|
||
const confirmOn = anySel;
|
||
const confirmEnabled = anySel && pv.valid;
|
||
this._confirmBtn?.setAlpha(confirmOn ? 1 : 0);
|
||
this._confirmBtn?.setAccent(confirmEnabled
|
||
? config.get('shopui.buttons.confirmAccent', '#7ce8a4')
|
||
: config.get('shopui.buttons.disabled', '#7d92c4'));
|
||
this._clearBtn?.setAlpha(confirmOn ? 1 : 0);
|
||
}
|
||
|
||
_hasAnySelection() {
|
||
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false);
|
||
return [...this.buybackSel.values()].some((q) => q > 0);
|
||
}
|
||
|
||
_setStatus(msg, bad) {
|
||
// Best-effort status line (headless no-ops). The credits HUD count-up is
|
||
// the primary feedback; this is the in-panel confirmation.
|
||
if (this._statusText && typeof this._statusText.setText === 'function') {
|
||
this._statusText.setText(msg);
|
||
if (typeof this._statusText.setColor === 'function')
|
||
this._statusText.setColor(bad ? config.get('shopui.preview.negColor', '#ff2d6f') : config.get('shopui.preview.posColor', '#7ce8a4'));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// LIFECYCLE
|
||
// ---------------------------------------------------------------------
|
||
close() {
|
||
this._destroyContent();
|
||
this.destroy();
|
||
if (typeof this.onLeave === 'function') this.onLeave();
|
||
return this;
|
||
}
|
||
|
||
_destroyContent() {
|
||
if (this._content) {
|
||
this._content.destroy?.();
|
||
this._content = null;
|
||
}
|
||
this.sellGrid?.destroy?.();
|
||
this.buyGrid?.destroy?.();
|
||
this.sellGrid = null;
|
||
this.buyGrid = null;
|
||
this._previewText = null;
|
||
this._previewLabel = null;
|
||
this._confirmBtn = null;
|
||
this._clearBtn = null;
|
||
}
|
||
|
||
destroy() {
|
||
this._destroyContent();
|
||
this._tabTrade?.destroy?.();
|
||
this._tabBuyback?.destroy?.();
|
||
if (typeof super.destroy === 'function') super.destroy();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// SMALL HELPERS (Phaser-agnostic; the headless fake provides add.text/
|
||
// graphics and the stub Container; real Phaser provides the rest)
|
||
// ---------------------------------------------------------------------
|
||
_W() {
|
||
return this.scene.scale?.width ?? 1280;
|
||
}
|
||
|
||
_makeText(x, y, str, style = {}) {
|
||
// Owned by whichever container the caller adds it to (or this) — real
|
||
// Phaser tears the whole tree down on destroy(); the headless stub doesn't
|
||
// recurse, but the tests build a fresh scene each time so that's fine.
|
||
const t = this.scene.add?.text?.(x, y, str, { align: 'center', ...style });
|
||
if (t) t.setOrigin?.(0.5, 0.5);
|
||
return t;
|
||
}
|
||
|
||
_makeButton(label, w, h, accent) {
|
||
const g = this.scene.add?.graphics?.();
|
||
const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center' });
|
||
const btn = new Phaser.GameObjects.Container(this.scene, 0, 0);
|
||
if (g) { this._drawBtn(g, w, h, accent); btn.add?.(g); }
|
||
if (t) { t.setOrigin?.(0.5, 0.5); btn.add?.(t); }
|
||
const api = {
|
||
container: btn,
|
||
setPosition: (x, y) => { btn.setPosition?.(x, y); },
|
||
setAlpha: (a) => { btn.setAlpha?.(a); },
|
||
setAccent: (color) => { this._drawBtn(g, w, h, color); },
|
||
tap: (fn) => { this._bindTap(g ?? t, fn, [-w / 2, -h / 2, w, h]); },
|
||
destroy: () => { g?.destroy?.(); t?.destroy?.(); btn.destroy?.(); },
|
||
};
|
||
return api;
|
||
}
|
||
|
||
_drawBtn(g, w, h, accent) {
|
||
if (!g || typeof g.clear !== 'function') return;
|
||
g.clear?.();
|
||
if (typeof g.fillStyle === 'function') {
|
||
const a = _hex(accent ?? '#00e5ff');
|
||
g.fillStyle(a, 0.16);
|
||
g.lineStyle?.(1, a, 1);
|
||
}
|
||
g.fillRect?.(-w / 2, -h / 2, w, h);
|
||
}
|
||
|
||
_bindTap(obj, fn, hitRect) {
|
||
if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return;
|
||
// Phaser v4 (Giedi): a bare setInteractive() leaves no hitAreaCallback and
|
||
// crashes the input (MenuButton sets an explicit hitArea + callback for this).
|
||
const opts = { useHandCursor: true };
|
||
const Rect = Phaser.Geom?.Rectangle;
|
||
if (Rect && hitRect) {
|
||
const area = new Rect(hitRect[0], hitRect[1], hitRect[2], hitRect[3]);
|
||
opts.hitArea = area;
|
||
opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py);
|
||
}
|
||
obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
|
||
}
|
||
}
|
||
|
||
function _hex(v) {
|
||
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
|
||
return m ? parseInt(m[1], 16) : 0xffffff;
|
||
}
|