715 lines
31 KiB
JavaScript
715 lines
31 KiB
JavaScript
/**
|
||
* 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 │ NET +420 cr ← tab sub-bar │
|
||
* └────────────────────────────────────────────────────────────────┘
|
||
*
|
||
* 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.
|
||
* 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, right of the Buy/Sell + Buyback buttons past a
|
||
* hairline divider (tabs AND the pending trade's credit math on one
|
||
* console); Confirm/Clear stay 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 { 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';
|
||
|
||
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).
|
||
* anchor — { x, y, w } the sub-bar's anchor: the deck's "Shop"
|
||
* button center x, the deck's top-rail y (the bar's bottom
|
||
* edge rests on it), and the button width (right-aligns the
|
||
* bar) — the MenuSubBar recipe (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.onLeave = o.onLeave;
|
||
// The tab sub-bar anchor (the deck's Shop button). 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 };
|
||
// 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'
|
||
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
|
||
this.buybackSel = new Map(); // goodId -> qty to buy back
|
||
this.sellGrid = null;
|
||
this.buyList = null;
|
||
this._content = null; // the active content container
|
||
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). 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);
|
||
}
|
||
|
||
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() {
|
||
// 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): the live NET credit readout —
|
||
// the pending trade's credit delta — sits at the bar's right end past a
|
||
// hairline divider. Tabs AND the credit math on ONE console: the readout
|
||
// is one glance away from the tabs at every moment (entry state included,
|
||
// where it 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 = (item) => new MenuButton(
|
||
this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id),
|
||
{
|
||
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(item.accent ?? c.neon ?? '#00e5ff'),
|
||
},
|
||
);
|
||
|
||
const btns = items.map(mkBtn);
|
||
const widths = btns.map((b) => b.style?.width ?? 120);
|
||
const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1);
|
||
|
||
// 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-aligned at the bar's end, so longer
|
||
// numbers ("+1,240 cr") never need the bar to grow.
|
||
const labelStr = String(pv.label ?? 'NET');
|
||
const valueFont = Math.max(8, Number(pv.valueFontSize) || 22);
|
||
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, '0 cr', {
|
||
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 : 4 * valueFont * 0.6) || 40;
|
||
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 ] │ NET value
|
||
const W = Math.min(this._W() - 24, pad * 2 + tabsW + gap + divW + gap + labelW + gap + valueW);
|
||
|
||
// Position: bottom edge on the deck's top rail (a.y), right edge on the
|
||
// Shop button's right edge — the MenuSubBar recipe. 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 sceneW = this._W();
|
||
let cx = a.x + a.w / 2 - W / 2;
|
||
cx = Phaser.Math.Clamp(cx, W / 2 + 12, sceneW - W / 2 - 12);
|
||
|
||
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
|
||
// divider between the tabs and the NET block is part of the body, so it
|
||
// paints under both.
|
||
const divX = -W / 2 + pad + tabsW + gap + divW / 2;
|
||
const bodyG = this.scene.add?.graphics?.();
|
||
if (bodyG) {
|
||
bodyG.setScrollFactor?.(0);
|
||
bar.add?.(bodyG);
|
||
this._drawSubBarBody(bodyG, W, H, c, divX, divH);
|
||
}
|
||
|
||
// The buttons LAST (above the body), left-anchored in the row.
|
||
let bx = -W / 2 + pad + widths[0] / 2;
|
||
items.forEach((it, i) => {
|
||
const btn = btns[i];
|
||
btn.setX?.(bx);
|
||
bar.add?.(btn);
|
||
if (it.id === 'trade') this._tabTrade = btn;
|
||
else this._tabBuyback = btn;
|
||
bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
|
||
});
|
||
|
||
// The NET block: the label right after the divider, the value right-
|
||
// aligned to the bar's end (so a growing number never shifts the label).
|
||
this._netLabel.setPosition?.(divX + divW / 2 + gap + labelW / 2, -H / 2);
|
||
this._netValue.setPosition?.(W / 2 - pad - valueW / 2, -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).
|
||
* `divX`/`divH` (optional) add the hairline divider between the tabs and
|
||
* the NET readout — drawn as body so it paints under the buttons + text.
|
||
*/
|
||
_drawSubBarBody(g, W, H, c, divX, 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
|
||
if (typeof divX === 'number' && divH > 0) {
|
||
g.fillStyle(toColor(c.border ?? '#22405f'), 0.9);
|
||
g.fillRect(divX - 1, -H / 2 - divH / 2, 2, divH); // tabs │ NET
|
||
}
|
||
}
|
||
}
|
||
|
||
/** A tab button press (MenuButton → _onTabTap). */
|
||
_onTabTap(id) {
|
||
if (id === 'trade') this.setTab('trade');
|
||
else if (id === 'buyback' && this.isBuybackEnabled()) this.setTab('buyback');
|
||
}
|
||
|
||
_refreshTabs() {
|
||
// MenuButtons carry their own state (base/hover/disabled) — the only thing
|
||
// that changes here is Buyback's locked state (grayed until a sale exists).
|
||
this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// CONTENT — the trade panel (SELL grid + BUY list + 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),
|
||
});
|
||
// 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).
|
||
const buyW = Math.min(
|
||
Number(config.get('shopui.buyList.w')) || 560,
|
||
Math.max(440, W - 24 - sellGrid.width - centerW),
|
||
);
|
||
const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height });
|
||
this.sellGrid = sellGrid;
|
||
this.buyList = buyList;
|
||
|
||
// Layout: [SELL grid] [center] [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).
|
||
//
|
||
// 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 totalW = sellGrid.width + centerW + buyList.width;
|
||
const leftX = Math.max(8, (W - totalW) / 2);
|
||
const gridH = sellGrid.height;
|
||
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);
|
||
|
||
// center column: Confirm + Clear — vertically centered with the grids,
|
||
// so the whole panel is one cohesive block near the top of the screen.
|
||
// (The live NET preview now rides on the tab sub-bar — see _buildTabs.)
|
||
const cx = leftX + sellGrid.width + centerW / 2;
|
||
const cy = this.topY + gridH / 2;
|
||
|
||
// Confirm / Clear — the same cyberpunk MenuButton as the tab bar.
|
||
const c = config.section('save.colors', {});
|
||
const btnCfg = config.section('save.subBar.button', {});
|
||
this._confirmBtn = new MenuButton(
|
||
this.scene, cx, cy + 16, config.get('shopui.buttons.confirmLabel', 'Confirm'), () => this.confirm(),
|
||
{
|
||
screenFixed: true,
|
||
fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12,
|
||
letterSpacing: btnCfg.letterSpacing ?? 2, upper: true,
|
||
textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222',
|
||
hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.confirmAccent', '#7ce8a4')),
|
||
},
|
||
);
|
||
this._confirmBtn.setAlpha?.(0);
|
||
content.add?.(this._confirmBtn);
|
||
|
||
this._clearBtn = new MenuButton(
|
||
this.scene, cx, cy + 86, config.get('shopui.buttons.clearLabel', 'Clear'), () => this.clear(),
|
||
{
|
||
screenFixed: true,
|
||
fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12,
|
||
letterSpacing: btnCfg.letterSpacing ?? 2, upper: true,
|
||
textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222',
|
||
hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.clearAccent', '#7d92c4')),
|
||
},
|
||
);
|
||
this._clearBtn.setAlpha?.(0);
|
||
content.add?.(this._clearBtn);
|
||
|
||
this._content = content;
|
||
this.add?.(content);
|
||
|
||
// Data + live preview wiring.
|
||
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
||
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
|
||
sellGrid.onSelectionChange = () => this._syncPreview();
|
||
buyList.onSelectionChange = () => this._syncPreview();
|
||
this._syncPreview();
|
||
|
||
// 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;
|
||
}
|
||
|
||
const cy = y + 24;
|
||
// (The live NET readout rides on the tab sub-bar — see _buildTabs.)
|
||
|
||
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 + 40);
|
||
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.buyList ? this.buyList.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.buyList?.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.buyList?.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();
|
||
// 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 === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.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.buyList?.destroy?.();
|
||
this.sellGrid = null;
|
||
this.buyList = null;
|
||
this._confirmBtn = null;
|
||
this._clearBtn = null;
|
||
}
|
||
|
||
destroy() {
|
||
this._destroyContent();
|
||
this._tabTrade?.destroy?.();
|
||
this._tabBuyback?.destroy?.();
|
||
this._netLabel?.destroy?.();
|
||
this._netValue?.destroy?.();
|
||
this._netLabel = null;
|
||
this._netValue = 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;
|
||
}
|