orbit/js/ui/shop/ShopView.js

520 lines
21 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.
*
* ┌─────────────────────────────────────────────────────────────┐
* │ [ 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;
}