/** * 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. * 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 { 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, 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.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' 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 + 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). */ _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, 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, 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). // 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() : [] }); 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; } // 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; } // --------------------------------------------------------------------- // 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() { // 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; } 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; }