orbit/js/ui/shop/ShopView.js

1104 lines
52 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.

/**
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
*
* ┌────────────────────────────────────────────────────────────────┐
* │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │
* │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │
* │ │ (cargo)│ │ Crisp, clean… − 0 +│ │
* │ └────────┘ │ [i] Refined Fuel 41cr│ │
* │ │ The lifeblood − 1 +│ │
* │ │ … (scrolls) … │ │
* │ └────────────────────────┘ │
* └────────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ BUY/SELL BUYBACK ···console··· CLEAR CONFIRM │ NET +420 cr │
* │ ← spans the FULL command deck (left + right edges) → │
* └────────────────────────────────────────────────────────────────┘
*
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): 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. ONE bay is not a commodity: the
* 'Special Delivery' package (tradeable:false) is NOT FOR SALE anywhere
* EXCEPT the home world — where it sells for zero credits, and THAT sale
* is the quest's step 3 (confirm() hands it to GameScene.onQuestPackageSold;
* it is not buyback-eligible).
* The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's
* stock as WIDE ROWS — icon + name + one-line description (the good's
* flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
* full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0)
* sits on the TAB SUB-BAR at its RIGHT edge: the tabs pack at the bar's
* LEFT, the trade actions (Clear + Confirm) and the credit readout pack
* at the RIGHT — the value right-aligned at the bar's end, so a growing
* number never shifts the label, the buttons, or the tabs (no overlap).
* 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 * as Stock from '../../economy/Stock.js';
import { scoutGood } from '../../economy/TradeScout.js';
import { MenuButton } from '../MenuButton.js';
import { toColor, toCss } from '../../utils/Color.js';
import { fontStack } from '../../utils/Theme.js';
import { TradeGrid } from './TradeGrid.js';
import { BuyList } from './BuyList.js';
import { ScoutPanel } from './ScoutPanel.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).
* worldName — this WORLD (the planet the player landed on): the
* QUEST CARGO's delivery gate — the 'Special Delivery'
* package is saleable ONLY on the home world (zero
* credits), so the home check runs against it (GameScene.
* isHomeWorld; a scene without the method = the package
* is not saleable — the old behavior).
* gameScene — to refresh the credits/minerals HUD after a trade, and
* to read the TRADE gate (isTradeUnlocked — the Buy/Sell
* tab stays grayed until the 'Special Delivery' capital
* landing; a headless fake without the method = open).
* onLeave — called by close() so the SurfaceScene can restore the
* surface loop (the deck's "Leave Shop" does this too).
* anchor — { x, y, w, left, right } the sub-bar's anchor: the
* deck's "Shop" button (x, w — legacy), the deck's
* top-rail y (the bar's bottom edge rests on it), and
* left/right — the deck's left/right edges. The bar
* spans the FULL deck — the same span as the main
* action bar (see SurfaceScene.shopAnchor).
*/
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.worldName = typeof o.worldName === 'string' ? o.worldName.trim() : '';
// The QUEST CARGO's delivery (the 'Special Delivery' package —
// tradeable:false, data/goods.json): saleable ONLY on the home world,
// where it prices at its flat `sellFor` (zero credits — the delivery,
// not a commodity). That home-world sale is the quest's step 3:
// confirm() hands it to the ledger (GameScene.onQuestPackageSold).
// TradeGrid reads questSell per bay; the price comes off the good.
this.questSellable =
this.worldName !== '' &&
typeof this.gameScene?.isHomeWorld === 'function' &&
this.gameScene.isHomeWorld(this.worldName) === true;
this.onLeave = o.onLeave;
// The tab sub-bar anchor (the deck's span). Falls back to a
// bottom-center bar if the scene doesn't provide one (headless/dev).
this.anchor = o.anchor ?? { x: this._W() / 2, y: this._H() - 58, w: 120, left: 20, right: this._W() - 20 };
// The trade panel's TOP edge — just under the surface HUD's planet text
// (the scene computes it from its own hudName/hudLine). Falls back to a
// small top margin if the scene doesn't provide one (headless/dev).
this.topY = Number.isFinite(o.topY) ? o.topY : (this._H() * 0.16);
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' | null (closed)
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back
this._pendingSell = null; // { goodId: qty } — the pending trade's
this._pendingBuy = null; // SELL/BUY side, saved when the
// BUY/SELL toggle closes the panel
// (it survives until Confirm/Clear)
this.sellGrid = null;
this.buyList = null;
this._content = null; // the active content container
this._scout = null; // the trade scout (a BUY row's strategy view)
this._scoutOpen = null; // the scouted good id (null = cargo grid up)
this._netLabel = null; // the sub-bar NET label (persistent)
this._netValue = null; // the sub-bar NET value (live)
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() {
// Entry state: the shop video + tab bar only. The player must click
// "Buy/Sell" to reveal the trade panel (this keeps the screen open so the
// shop clip plays before the UI takes over). Buy/Sell is grayed while the
// trade gate is closed (GameScene.isTradeUnlocked — the 'Special
// Delivery' capital landing lifts it); Buyback is grayed until a sale
// exists this visit.
this.tab = null;
this._refreshTabs();
return this;
}
isBuybackEnabled() {
return this.soldThisSession.some((r) => r.qty > 0);
}
/** The TRADE gate (GameScene.isTradeUnlocked — the 'Special Delivery'
* capital landing lifts it; it's the shop's Buy/Sell tab that stays
* dark, the shop button itself is always live). A scene without the
* method (headless/dev) has no ledger, so the gate reads open there. */
isTradeUnlocked() {
const g = this.gameScene;
if (typeof g?.isTradeUnlocked !== 'function') return true;
return g.isTradeUnlocked() === true;
}
setTab(t) {
if (t === 'trade' && !this.isTradeUnlocked()) return this; // trade gate closed
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
// Leaving an OPEN trade panel with a pending trade? Save the selection
// (same as the toggle-close) so a tab switch never loses it; _showTrade
// re-applies it when the panel comes back.
if (this.tab === 'trade' && t !== 'trade') {
const s = this.sellGrid?.getSellMap?.() ?? {};
const b = this.buyList?.getBuyMap?.() ?? {};
this._pendingSell = Object.keys(s).length > 0 ? s : null;
this._pendingBuy = Object.keys(b).length > 0 ? b : null;
}
this.tab = t;
this._closeScout(); // the content (and the scout) is rebuilt
this._destroyContent();
if (t === 'trade') this._showTrade();
else this._showBuyback();
this._refreshTabs();
return this;
}
_buildTabs() {
// The tab sub-bar: the same recipe as the deck's Menu sub-bar
// (js/ui/MenuSubBar.js) — a console-blue panel with cut corners + the
// signature top rail, holding cyberpunk MenuButtons. It rests ABOVE the
// command deck, right-aligned to the deck's "Shop" button (this.anchor),
// so it reads as the same family of UI as the Menu drawer.
//
// EXTENDED (data/shopui.json → preview + buttons): the trade ACTIONS
// (Clear + Confirm) and the live NET credit readout ride on the same
// console, packed at the RIGHT edge —
// [tabs ···console··· actions] │ [NET value]
// while the tabs pack at the LEFT edge. One glance sees state, the
// pending credit delta, and the button that executes it (entry state
// included, where the actions hide and the readout dims to "0 cr").
const a = this.anchor;
const H = config.get('save.subBar.height', 58);
const pad = config.get('save.subBar.padding', 16);
const gap = config.get('save.subBar.gap', 12);
const tabs = config.get('shopui.tabs', {});
const c = config.section('save.colors', {});
const btnCfg = config.section('save.subBar.button', {});
const pv = config.section('shopui.preview', {});
const items = [
{ id: 'trade', label: tabs.buySell ?? 'Buy/Sell', accent: tabs.activeAccent ?? c.neon ?? '#00e5ff' },
{ id: 'buyback', label: tabs.buyback ?? 'Buyback', accent: c.dim ?? '#7d92c4' },
];
// Build the buttons first (they measure their own width) so the row
// layout uses the REAL widths — no second, divergent text-measure pass.
const mkBtn = (label, accent, onClick) => new MenuButton(
this.scene, 0, -H / 2, String(label), onClick,
{
screenFixed: true,
fontSize: btnCfg.fontSize ?? 13,
paddingX: btnCfg.paddingX ?? 18,
paddingY: btnCfg.paddingY ?? 9,
letterSpacing: btnCfg.letterSpacing ?? 2,
upper: true,
textColor: c.ink ?? '#eaf6ff',
bgColor: c.cardBg ?? '#0a1222',
hoverColor: c.cardBg ?? '#0a1222',
borderColor: toCss(accent ?? c.neon ?? '#00e5ff'),
},
);
const btns = items.map((it) => mkBtn(String(it.label ?? it.id), it.accent, () => this._onTabTap(it.id)));
const widths = btns.map((b) => b.style?.width ?? 120);
const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1);
// The trade actions — Clear (secondary, dim) + Confirm (primary, green)
// — live on the bar too (both trade AND buyback confirm here, since
// preview()/confirm() already branch on the tab).
const buttons = config.section('shopui.buttons', {});
this._clearBtn = mkBtn(buttons.clearLabel ?? 'Clear', buttons.clearAccent ?? c.dim ?? '#7d92c4', () => this.clear());
this._confirmBtn = mkBtn(buttons.confirmLabel ?? 'Confirm', buttons.confirmAccent ?? '#7ce8a4', () => this.confirm());
this._confirmBtn.setAlpha?.(0); // hidden until a selection exists (_syncPreview)
this._clearBtn.setAlpha?.(0);
this._confirmBtn.setDisabled?.(true); // + invalid-trade gray, via _syncPreview
const clearW = this._clearBtn.style?.width ?? 90;
const confirmW = this._confirmBtn.style?.width ?? 110;
const actionsW = clearW + gap + confirmW;
// The NET readout — created BEFORE the panel body so its REAL width feeds
// the bar width (a headless fake without text metrics falls back to an
// estimate). The value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's
// end, so a longer number ("−9,999 cr") extends LEFT and never spills
// past the bar or shifts the label. The label sits clear of the WIDEST
// plausible value (reserved below) so nothing overlaps at any total.
const labelStr = String(pv.label ?? 'NET');
const valueFont = Math.max(8, Number(pv.valueFontSize) || 22);
// Reserve the value slot with the widest plausible reading so the label
// never overlaps a long total (the value itself only grows leftward).
const reserveStr = String(pv.reserve ?? '\u221299,999 cr');
this._netLabel = this._makeText(0, -H / 2, labelStr, {
fontSize: `${Math.max(8, Number(pv.labelFontSize) || 12)}px`,
color: toCss(pv.labelColor ?? c.dim ?? '#7d92c4'),
align: 'left',
});
this._netValue = this._makeText(0, -H / 2, reserveStr, {
fontSize: `${valueFont}px`,
color: toCss(pv.zeroColor ?? pv.labelColor ?? '#7d92c4'),
align: 'left',
});
const labelW = (Number(this._netLabel?.width) > 0 ? this._netLabel.width : labelStr.length * 8) || 30;
const valueW = (Number(this._netValue?.width) > 0 ? this._netValue.width : reserveStr.length * valueFont * 0.55) || 90;
this._netValueW = valueW; // reserved (widest) width — the label keeps clear of it
// Reset to the live (zero) reading and anchor the RIGHT edge so the
// value stays pinned to the bar's end as its length changes.
this._netValue.setText?.('0 cr');
this._netValue.setOrigin?.(1, 0.5);
const divW = Math.max(1, Number(pv.divider?.width) || 2);
const divH = Math.max(10, Number(pv.divider?.height) || Math.round(H * 0.45));
// Row: [ BUY/SELL ][ BUYBACK ] ···console··· [ CLEAR ][ CONFIRM ] │ NET value
// The bar spans the FULL DECK (anchor.left/right — the main action
// bar's left and right edges): tabs packed at the LEFT edge, the trade
// actions + credit readout packed at the RIGHT edge, the value
// right-aligned at the bar's end (a growing number never shifts the
// label, the buttons, or the tabs — nothing overlaps on either end).
const sceneW = this._W();
const fixedW = pad * 2 + tabsW + divW * 2 + actionsW + labelW + valueW;
const span = (Number.isFinite(a.left) && Number.isFinite(a.right) && a.right - a.left > fixedW + 28)
? { left: a.left, right: a.right }
: null;
// Seven gap slots: tab1·tab2, tab2·div1, div1·clear, clear·confirm,
// confirm·div2, div2·label, label·value. If the content can't fit the
// span (a narrow deck), the gaps compress — never the button widths.
let W, g;
if (span) {
W = span.right - span.left;
g = Math.min(gap, Math.max(4, (W - fixedW) / 7));
} else {
g = gap;
W = Math.min(sceneW - 24, fixedW + 7 * g);
}
const cx = span ? (span.left + span.right) / 2 : sceneW / 2;
// All the x's, bar-local (the bar's origin is its BOTTOM CENTER, a.y):
// the right group packs inward from the bar's right edge; the tabs
// pack outward from the left; the dividers sit between the groups.
const lEdge = -W / 2 + pad;
const rEdge = W / 2 - pad;
const tX = [lEdge + widths[0] / 2, lEdge + widths[0] + g + widths[1] / 2];
const vX = rEdge; // NET value — right edge anchored at the bar's end (origin 1,0.5)
const lCx = rEdge - valueW - g - labelW / 2; // NET label, clear of the widest value
const d2X = lCx - labelW / 2 - g - divW / 2; // divider: actions │ NET
const cCx = d2X - divW / 2 - g - confirmW / 2; // Confirm
const qCx = cCx - confirmW / 2 - g - clearW / 2; // Clear
const d1X = qCx - clearW / 2 - g - divW / 2; // divider: tabs │ actions
// Position: bottom edge on the deck's top rail (a.y), centred on the
// deck's span. The sub-bar is a CHILD of the ShopView (which stays at
// the screen origin, 0,0) at (cx, a.y) — so the trade panel (a SIBLING
// at 0,0) still renders in screen space, not offset down to the deck.
const bar = new Phaser.GameObjects.Container(this.scene, cx, a.y);
bar.setScrollFactor?.(0);
this.subBar = bar;
this.subBarW = W;
this.subBarH = H;
this.add?.(bar);
// The body FIRST (v4 paints children in list order — the opaque panel
// fill must come before the buttons or it would cover them). The
// hairline dividers are part of the body, so they paint under both.
const bodyG = this.scene.add?.graphics?.();
if (bodyG) {
bodyG.setScrollFactor?.(0);
bar.add?.(bodyG);
this._drawSubBarBody(bodyG, W, H, c, [d1X, d2X], divH);
}
// The buttons LAST (above the body): tabs at the left edge, the trade
// actions + NET readout at the right edge.
items.forEach((it, i) => {
const btn = btns[i];
btn.setX?.(tX[i]);
bar.add?.(btn);
if (it.id === 'trade') this._tabTrade = btn;
else this._tabBuyback = btn;
});
this._clearBtn.setX?.(qCx);
this._confirmBtn.setX?.(cCx);
bar.add?.(this._clearBtn);
bar.add?.(this._confirmBtn);
this._netLabel.setPosition?.(lCx, -H / 2);
this._netValue.setPosition?.(vX, -H / 2);
bar.add?.(this._netLabel);
bar.add?.(this._netValue);
// Buyback starts grayed (disabled) until a sale exists this visit.
this._refreshTabs();
}
/**
* The sub-bar panel body (the MenuSubBar recipe at full height).
* `dividers` (optional) — hairline divider x's (one between the tabs and
* the actions, one between the actions and the NET readout) — drawn as
* body so they paint under the buttons + text.
*/
_drawSubBarBody(g, W, H, c, dividers, divH) {
if (!g || typeof g.clear !== 'function') return;
const panelTop = toColor(c.panelTop ?? '#0e1930');
const panelBottom = toColor(c.panelBottom ?? '#050912');
const neon = toColor(c.neon ?? '#00e5ff');
const magenta = toColor(c.magenta ?? '#ff2d6f');
const ink = toColor(c.ink ?? '#eaf6ff');
const cut = Math.max(3, Math.min(12, H * 0.18));
g.clear();
const pts = [
{ x: -W / 2 + cut, y: -H }, { x: W / 2 - cut, y: -H },
{ x: W / 2, y: -H + cut }, { x: W / 2, y: 0 },
{ x: -W / 2, y: 0 }, { x: -W / 2, y: -H + cut },
];
const has = (fn) => typeof g[fn] === 'function';
if (has('fillPoints')) {
g.fillStyle(panelBottom, 1); g.fillPoints(pts, true);
g.fillStyle(panelTop, 0.55); g.fillPoints(pts, true);
}
if (has('fillRect')) {
g.fillStyle(neon, 0.05); g.fillRect(-W / 2, -H, 26, H); // cyan left
g.fillStyle(magenta, 0.06); g.fillRect(W / 2 - 26, -H, 26, H); // magenta right
const rail = 2;
g.fillStyle(neon, 0.9); g.fillRect(-W / 2 + cut, -H, W - cut * 2, rail);
g.fillStyle(ink, 0.8); g.fillRect(-W / 2 + cut, -H + 0.5, W - cut * 2, 0.8);
g.fillStyle(magenta, 0.5); g.fillRect(-W / 2 + cut, -H + rail, W - cut * 2, 1);
g.fillStyle(magenta, 0.35); g.fillRect(-W / 2, -1.5, W, 1.5); // seam
const divs = Array.isArray(dividers) ? dividers : (typeof dividers === 'number' ? [dividers] : []);
if (divs.length > 0 && divH > 0) {
g.fillStyle(toColor(c.border ?? '#22405f'), 0.9);
for (const dx of divs) g.fillRect(dx - 1, -H / 2 - divH / 2, 2, divH); // section dividers
}
}
}
/**
* A tab button press (MenuButton → _onTabTap). The tabs are TOGGLES:
* pressing the one that's already open closes the panel (back to the
* entry state — shop clip + sub-bar); pressing it again re-opens it.
* The pending trade selection is SAVED across the close (see
* _closePanel) — hiding the panel never loses the trade.
*/
_onTabTap(id) {
if (id === 'trade' && !this.isTradeUnlocked()) return; // the trade gate (the disabled tab swallows the click too)
if (id === 'trade') {
if (this.tab === 'trade') this._closePanel();
else this.setTab('trade');
} else if (id === 'buyback' && this.isBuybackEnabled()) {
if (this.tab === 'buyback') this._closePanel();
else this.setTab('buyback');
}
}
/**
* The toggle-close: the panel above the sub-bar hides (back to the
* entry state — tab null, content gone, shop clip playing). The player's
* pending trade is NOT lost — its SELL/BUY maps are saved onto the view
* (_pendingSell/_pendingBuy) and re-applied when the panel re-opens
* (_restorePendingSelection). While closed, the sub-bar stays live over
* the saved selection: NET shows the delta, Clear cancels it, Confirm
* executes it. (The buyback selection is already session-persistent.)
*/
_closePanel() {
if (this.tab === 'trade') {
const s = this.sellGrid?.getSellMap?.() ?? {};
const b = this.buyList?.getBuyMap?.() ?? {};
this._pendingSell = Object.keys(s).length > 0 ? s : null;
this._pendingBuy = Object.keys(b).length > 0 ? b : null;
}
this.tab = null;
this._destroyContent(); // kills the panel + scout + focus
this._syncPreview(); // NET + Clear/Confirm over the saved sel
return this;
}
_refreshTabs() {
// MenuButtons carry their own state (base/hover/disabled) — what changes
// here: Buy/Sell's TRADE gate (grayed until GameScene.isTradeUnlocked —
// the 'Special Delivery' capital landing) and Buyback's locked state
// (grayed until a sale exists this visit).
this._tabTrade?.setDisabled?.(!this.isTradeUnlocked());
this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled());
}
// ---------------------------------------------------------------------
// CONTENT — the trade panel (SELL grid + BUY list, side by side)
// ---------------------------------------------------------------------
_showTrade() {
const grid = config.get('shopui.grid', {});
// The two panels sit NEXT TO each other with just a bit of padding
// between (the trade actions + NET readout ride on the tab sub-bar —
// see _buildTabs — so the panel is pure selection).
const centerW = grid.centerW ?? 16;
const W = this._W();
// The panel's LEFT edge = the surface HUD's planet text's LEFT edge —
// the same margin key (data/landing.json → landing.hud.marginX) the
// name/line are drawn at (origin 0,0), so the cargo grid lines up
// vertically with the planet name above it. The BUY list then STRETCHES
// to the right, ending at the SAME padding from the screen's right edge.
const leftPad = Math.max(8, Number(config.get('landing.hud.marginX')) || 24);
const sellGrid = new TradeGrid(this.scene, {
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
cls: this.cls, systemId: this.systemId,
// The per-unit price: ordinary goods at the shop's fixed sell price;
// quest cargo (the 'Special Delivery' package) at its flat delivery
// price — the good's `sellFor` (zero credits). The tooltip's "sell
// for N" matches what tradePreview pays for the same bay.
sellUnit: (id) =>
E.isQuestCargo(id) ? E.questCargoSellPrice(id) : E.sellPrice(id, this.cls, this.systemId, this.galaxy),
// The home world's floor: the package's bay is SALEABLE (the
// delivery). Every other world's grid refuses it (NOT FOR SALE).
questSell: this.questSellable,
});
// The BUY side: a scrollable LIST of stock rows (wide bars — icon +
// name + description + price + −/qty/+ stepper). Sized to the SELL
// grid's height so the two panels align top AND bottom; the list scrolls
// when the stock outgrows the viewport (it always does — 8–16 rows).
// Width: from just past the SELL grid out to the right padding — the
// wide row gets all the room (the 440 floor is the narrow-window
// fallback, where stretching to the right edge isn't possible).
const buyW = Math.max(440, W - leftPad - (leftPad + sellGrid.width + centerW));
const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height });
this.sellGrid = sellGrid;
this.buyList = buyList;
// Layout: [SELL grid] [gap] [BUY list]. The panel's TOP edge sits at
// this.topY — just under the surface HUD's planet text (upper-left) —
// so the whole panel reads as one unit near the top of the screen (not
// hanging down to the deck/sub-bar); its LEFT edge is the planet text's
// left edge (leftPad), and the BUY list ends at the right padding.
//
// IMPORTANT: a TradeGrid/BuyList lays its plate out in local (0,0)→(w,h)
// and a Container's (x,y) is a pure TRANSLATION of that local space
// (verified with dev/cbox-test.html) — so the panel's position IS its
// top-left corner. Treating it as a centered origin (topY + h/2) drops
// the panel half a grid lower and clips the bottom rows.
const leftX = leftPad;
sellGrid.setPosition(leftX, this.topY);
const rightX = leftX + sellGrid.width + centerW;
buyList.setPosition(rightX, this.topY);
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
content.setScrollFactor?.(0);
content.add?.(sellGrid);
content.add?.(buyList);
// Confirm / Clear live on the tab sub-bar (persistent — see
// _buildTabs); the panel is pure selection.
this._content = content;
this.add?.(content);
// Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
this._refreshBuyStock(); // rows + the "N left" strip (the daily batch)
sellGrid.onSelectionChange = () => this._syncPreview();
buyList.onSelectionChange = () => this._syncPreview();
// The BUY/SELL toggle closed the panel WITH a pending trade? Re-apply
// it onto these fresh panels (hiding the panel never loses the trade).
this._restorePendingSelection();
// Tap a BUY row's box (outside the stepper) → the TRADE SCOUT: the cargo
// grid is swapped for the row's strategy report (what wants it, where
// it's grown, the top charted exits + profit). Tap again / anywhere →
// the cargo grid comes back. See _onRowTap + js/ui/shop/ScoutPanel.js.
buyList.onRowTap = (row) => this._onRowTap(row);
this._syncPreview();
// The scout panel: sized to the SELL grid's box (it replaces the cargo
// area), added LAST so it paints above the grid while open. A BEST-EXIT
// row's tap goes to the scene (the "SET DESTINATION" popup —
// SurfaceScene.askSetDestination); anything else closes the panel.
const scout = new ScoutPanel(this.scene, {
width: sellGrid.width,
height: sellGrid.height,
onClose: () => this._closeScout(),
onExitTap: (exit) => this.scene?.askSetDestination?.(exit),
});
scout.setPosition?.(leftX, this.topY);
this._scout = scout;
this._scoutOpen = null;
content.add?.(scout);
// Reveal: SELL slides in from the left, BUY from the right.
sellGrid.slideIn();
buyList.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);
content.setScrollFactor?.(0);
const rows = this._bbRows();
const contentY = this._H() / 2;
const cx = W / 2;
const title = this._makeText(cx, contentY + 10, 'BUY BACK', {
fontSize: '21px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('header') }); // Ethnocentric — section face
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;
}
// Confirm / Clear / the live NET readout all ride on the tab sub-bar
// (persistent — see _buildTabs); the buyback panel is the pure list of
// sold goods.
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;
}
// ---------------------------------------------------------------------
// THE DAILY BATCH (model v2 — the economy's one stateful seam)
// ---------------------------------------------------------------------
/**
* The run's stock map (the origin worlds' remaining daily batches —
* GameScene.economyStock, registry-backed, save/restore in
* js/save/SaveData.js). Read LIVE on every query so a confirmed trade
* (which swaps the reference) is visible without a rebuild.
* @returns {{[systemId:string]: {day:number,left:number}}}
*/
stockMap() {
const m = this.gameScene?.economyStock;
return (m && typeof m === 'object') ? m : {};
}
/**
* The remaining batch for a good AT THIS SHOP — finite (0..dailyBatch)
* only when THIS system is the good's ORIGIN (the world's signature
* good, the one batch-limited row; its batch is per day, regenerated
* on the day clock — js/economy/Stock.js). null = unlimited (hub
* resale; the good isn't this system's signature). The buy row's
* "N left" / "SOLD OUT" strip and the stepper's cap both read this.
* @returns {number|null}
*/
stockRemaining(goodId) {
if (!goodId || !this.galaxy) return null;
if (E.originOf(goodId, this.galaxy) !== this.systemId) return null;
return Stock.batchRemaining(this.stockMap(), this.systemId);
}
/** The stockCaps map for a buy side (only batch-limited goods at their
* origin carry a cap — tradePreview's backstop; the UI clamps first). */
stockCapsFor(buyMap) {
const caps = {};
for (const id of Object.keys(buyMap ?? {})) {
const left = this.stockRemaining(id);
if (left != null) caps[id] = left;
}
return caps;
}
/** Refresh the BUY list's rows with the current batch remainders. */
_refreshBuyStock() {
if (!this.buyList) return;
const stock = E.shopFor(this.cls, this.systemId, this.galaxy).buy.map((r) => ({
...r, left: this.stockRemaining(r.id),
}));
this.buyList.refresh({ stock });
}
// ---------------------------------------------------------------------
// 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 === 'buyback') {
// 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,
});
}
// The trade — the LIVE panel selection when it's open, the SAVED
// selection when the BUY/SELL toggle closed the panel (tab === null;
// the trade is still pending, just not visible).
const sellMap = this.tab === 'trade' ? (this.sellGrid ? this.sellGrid.getSellMap() : {}) : (this._pendingSell ?? {});
const buyMap = this.tab === 'trade' ? (this.buyList ? this.buyList.getBuyMap() : {}) : (this._pendingBuy ?? {});
return E.tradePreview({
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap, buyMap,
// THE BATCH CAP (model v2) — a buy of this system's signature good
// can't exceed its remaining daily stock (the UI clamps the stepper
// at the same number; this is the backstop). Hubs' resale + buybacks
// carry no cap (the sell side is always unlimited).
stockCaps: this.stockCapsFor(buyMap),
});
}
confirm() {
const pv = this.preview();
if (!pv.valid) {
this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true);
return false;
}
this._applyTrade(pv);
// THE DAILY BATCH (model v2) — the buy side is the only stock-limited
// side: each batch-limited good bought (finite remaining = this system
// is its origin) drops the origin's remaining batch by its units.
// The SELL side never consumes stock (the market is a sink), and a
// BUYBACK returns what the shop JUST bought from the player (neutral
// undo — it never draws on the batch either).
for (const b of pv.buy) {
if (this.stockRemaining(b.id) != null) this.gameScene?.spendStock?.(this.systemId, b.qty);
}
// The 'Special Delivery' step 3 — the parcel's HOME-WORLD SALE (zero
// credits) is the delivery itself. Only quest cargo (tradeable:false
// — the package) can ride a sell here (everywhere else the grid
// refuses it), and the ledger's owner double-checks the world before
// the delivery stands (GameScene.onQuestPackageSold — the witness
// the 'packageDelivered' check reads).
if (pv.sell.some((s) => E.isQuestCargo(s.id))) {
this.gameScene?.onQuestPackageSold?.(this.worldName);
}
// The REPUTATION bump (data/reputation.json → bonuses.perGoodSold —
// 2% of the scale's top per good sold, +0.4 at the default +20): the
// world the shop stands on earns it, the sub-point fraction crediting
// across sales until it pays out whole points (five units → +2). The
// home world is pinned (creditFraction no-ops), so a sale there earns
// nothing — the delivery the package sale already stood.
const soldUnits = pv.sell.reduce((a, s) => a + Math.max(0, Math.floor(Number(s.qty) || 0)), 0);
if (soldUnits > 0) this.gameScene?.onGoodsSold?.(this.worldName, soldUnits);
this._afterTrade(pv);
return true;
}
clear() {
if (this.tab === 'buyback') {
this.buybackSel = new Map();
} else if (this.tab === 'trade') {
this.sellGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
// Closed panel — cancel the SAVED pending trade (the toggle-close kept it).
this._pendingSell = null;
this._pendingBuy = null;
}
this._syncPreview();
return this;
}
_applyTrade(pv) {
const ship = this.ship;
// THE PROFIT XP (1 XP per 1 credit earned above each good's original
// purchase price) — computed from the per-bay cost ledger BEFORE the
// bays are removed (the FIFO basis remove() uses). A sale at/below cost,
// or a bay with no cost on file (the quest package; an old save), yields
// 0 — the grant below simply doesn't fire.
const profitXp = E.tradeProfitXp(pv.sell, (id, n) =>
(ship.cargo && typeof ship.cargo.costsFor === 'function') ? ship.cargo.costsFor(id, n) : []);
this._lastProfitXp = profitXp; // the sale's XP (the CONFIRMED line shows it)
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
// THE COST RECORD — b.unit is what the player ACTUALLY paid for these
// units (the shop's buy price, or the sale price on a buyback): stamp it
// onto every bay added, so the ship console's manifest later compares
// the exit sell prices against the real purchase cost (not a guess).
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id), b.unit);
// The sale's profit, paid out in XP — the SAME pipe as kills and sidequests
// (the level-up marquee + armor bump fire downstream; the 'trade' tag is
// the ledger's source). A 0-profit sale grants nothing.
if (profitXp > 0 && this.gameScene?.combat?.xp?.grant) {
this.gameScene.combat.xp.grant(Math.floor(profitXp), 'trade');
}
}
_afterTrade(pv) {
if (this.tab === 'buyback') {
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map();
} else {
// The trade — open tab OR the closed panel (tab === null, selection in
// the _pending* store). Record the sale for buyback, reset both.
for (const s of pv.sell) {
// Quest cargo (the 'Special Delivery' package) is DELIVERED when
// it sells — the shop doesn't deal in it outside the delivery,
// so it never enters the buyback's undo list (the session is the
// shop's, and the shop's answer to the parcel is "zero, take
// your delivery", not "here, buy it back").
if (E.isQuestCargo(s.id)) continue;
this._addSold(s.id, s.qty, s.unit, s.name);
}
this.sellGrid?.clearSelection();
this.buyList?.clearSelection();
this._pendingSell = null;
this._pendingBuy = null;
}
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
// The BUY rows re-render with the batch's NEW remainder (the confirm
// just spent from it — "6 left" → "3 left" → "SOLD OUT").
this._refreshBuyStock();
this.refreshHud();
this._syncPreview();
this._refreshTabs();
// The CONFIRMED line — the credit delta, the hold, and (only when the
// sale made a profit) the XP that payout just earned (1 XP / credit
// above cost; a no-profit or buyback confirm shows no XP).
const xp = Math.max(0, Math.floor(this._lastProfitXp ?? 0));
const xpPart = xp > 0 ? ` · +${xp} XP` : '';
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr${xpPart} → 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() {
// The GAME scene's HUD (the main-flight corner readout) — and the
// SURFACE scene's HUD (this screen's own upper-right pair, which is the
// one the player actually sees over the shop clip — the GameScene is
// sleeping beneath us, so its HUD update is belt-and-braces).
this.gameScene?.refreshCreditsHud?.();
this.scene?.refreshShopHud?.();
}
// ---------------------------------------------------------------------
// PREVIEW SYNC (live NET + Confirm/Clear visibility)
// ---------------------------------------------------------------------
_syncPreview() {
const pv = this.preview();
const anySel = this._hasAnySelection();
// The live NET readout lives on the tab sub-bar (this._netValue) — one
// glance sees the tabs AND the pending trade's credit delta. Right-
// aligned, so a growing number never shifts the "NET" label.
if (this._netValue && typeof this._netValue.setText === 'function') {
const n = Math.round(pv.net);
this._netValue.setText(n === 0 ? '0 cr' : `${n > 0 ? '+' : ''}${n} cr`);
const col = n === 0
? config.get('shopui.preview.zeroColor', '#7d92c4')
: (n > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
if (typeof this._netValue.setColor === 'function') this._netValue.setColor(col);
}
const confirmOn = anySel;
const confirmEnabled = anySel && pv.valid;
// MenuButton: hide (alpha) when no selection, and GRAY (setDisabled) while
// the trade is invalid — a disabled button swallows its own clicks.
if (this._confirmBtn) {
this._confirmBtn.setAlpha?.(confirmOn ? 1 : 0);
this._confirmBtn.setDisabled?.(!confirmEnabled);
}
this._clearBtn?.setAlpha?.(confirmOn ? 1 : 0);
}
_hasAnySelection() {
if (this.tab === 'buyback') return [...this.buybackSel.values()].some((q) => q > 0);
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false);
// Closed panel: the saved pending trade is the selection.
return Object.keys(this._pendingSell ?? {}).length > 0 || Object.keys(this._pendingBuy ?? {}).length > 0;
}
/**
* Re-apply the selection _closePanel() saved (the player closed the PANEL
* via the BUY/SELL toggle, not the trade). Only called from _showTrade,
* right after the fresh panels' refresh() + handler wiring. Clears the
* store so a later confirm/clear sees the live panels as the source.
*/
_restorePendingSelection() {
const s = this._pendingSell;
const b = this._pendingBuy;
this._pendingSell = null;
this._pendingBuy = null;
if (!s && !b) return this;
if (s && Object.keys(s).length > 0) this.sellGrid?.setSellMap?.(s);
if (b && Object.keys(b).length > 0) this.buyList?.setBuyMap?.(b);
this._syncPreview();
return this;
}
// ---------------------------------------------------------------------
// THE TRADE SCOUT — a BUY row's strategy view
// ---------------------------------------------------------------------
/**
* A BUY row's box was tapped (BuyList → onRowTap), or the list's dead
* space was (row === null). The scout is that row's STRATEGY view — the
* cargo grid is swapped for the report (js/ui/shop/ScoutPanel.js, fed by
* the pure js/economy/TradeScout.js):
*
* · WHAT it is (icon, name, class, tier, flavor)
* · WHERE it's GROWN (the producer classes — cheap to buy there)
* · WHO WANTS it (the demand classes + their economy line)
* · WHERE it sells BEST — the top-N CHARTED (visited) systems ranked by
* sell price, each with the per-unit PROFIT vs. this shop's buy price
*
* TAP RULES (the player's ask): the SAME box again closes it; a DIFFERENT
* box swaps the report; ANYWHERE ELSE (list dead space, the scout panel
* itself, a tab switch) closes it and restores the cargo grid.
*/
_onRowTap(row) {
if (this.tab !== 'trade') return; // only over the trade panel
if (!row) { this._closeScout(); return; } // "anywhere else"
if (this._scoutOpen === row.id) { this._closeScout(); return; } // same box → toggle off
this._openScout(row.id, row.price);
}
_openScout(id, localBuy) {
const data = scoutGood(id, {
galaxy: this.galaxy,
systemId: this.systemId,
cls: this.cls,
buyUnit: localBuy, // the row's price here — the profit baseline
visited: this._visitedIds(),
topN: config.get('shopui.scout.topN', 3),
});
if (!data || !this._scout) return;
this._scoutOpen = id;
this.buyList?.focusRow?.(id); // the light stroke on the box
this.scene.tweens?.killTweensOf?.(this.sellGrid); // no slide-in fighting
this.sellGrid?.setAlpha?.(0); // the cargo grid steps aside…
this._setSellGridInput(false); // …and can't be tapped through
this._scout.show(data).reveal(); // the report fades in
}
_closeScout() {
if (!this._scoutOpen && this.buyList?._focusId == null) return;
this._scoutOpen = null;
this.buyList?.focusRow?.(null); // the stroke fades with the panel
this._scout?.hide();
this.sellGrid?.setAlpha?.(1); // the cargo grid is back
this._setSellGridInput(true);
}
// The systems the run has ENTERED (the "charted" set the exits rank over).
// GameScene.visitedSystems (a Set, seeded with the home system); the
// current system always counts (we're here).
_visitedIds() {
const v = this.gameScene?.visitedSystems;
if (v && typeof v[Symbol.iterator] === 'function') return [...v];
return [this.systemId].filter(Boolean);
}
// The SELL grid's bays are interactive (hover tooltips + tap-select). While
// the scout covers them, switch their input off (Phaser's input.enabled —
// inputCandidate() honors it) so a tap through the panel can't flick a bay;
// back on when the cargo grid is restored.
_setSellGridInput(on) {
for (const cell of this.sellGrid?.cells ?? []) {
const plate = cell?.plate;
if (plate?.input) plate.input.enabled = !!on;
}
}
_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. `this._lastStatus`
// always records the latest line (the tests read it; the text object is
// the on-screen surface).
this._lastStatus = msg;
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() {
// The panel content goes; the sub-bar (tabs + actions + NET readout)
// PERSISTS across tab switches — it's not part of the content.
if (this._content) {
this._content.destroy?.();
this._content = null;
}
this.sellGrid?.destroy?.();
this.buyList?.destroy?.();
this.sellGrid = null;
this.buyList = null;
this._scout = null; // the scout died with the content
this._scoutOpen = null;
}
destroy() {
this._destroyContent();
this._tabTrade?.destroy?.();
this._tabBuyback?.destroy?.();
this._confirmBtn?.destroy?.();
this._clearBtn?.destroy?.();
this._netLabel?.destroy?.();
this._netValue?.destroy?.();
this._tabTrade = null;
this._tabBuyback = null;
this._confirmBtn = null;
this._clearBtn = null;
this._netLabel = null;
this._netValue = null;
this.subBar?.destroy?.();
this.subBar = null;
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;
}
_H() {
return this.scene.scale?.height ?? 720;
}
_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', fontFamily: fontStack('body'), ...style }); // Centauri default; callers may override
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', fontFamily: fontStack('body') });
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;
}