/** * TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells. * * Two modes, one component: * · SELL (the left panel) — the grid is the player's CARGO. Each cell is a * BAY (one good each, data/economy.json → cargo). The first `capacity` * cells are live bays (filled with a good, or empty); the rest are LOCKED * (the hold's upgrade room — the ship can't carry there yet). Tapping a * filled bay checks it off to sell (a bay = 1 unit); the sell quantity for * a good is just the number of its bays that are checked. * · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct * goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy * quantity is whatever the stepper says. * * Cells show the good's ICON only — the name + price are revealed in a * shared hover tooltip (pointerover the cell; one tooltip per grid). * * The PRICING lives in js/economy/Economy.js (buyPrice/sellPrice — the fixed * per-(good, shop) price). This component only shows the price and tracks the * SELECTION (which bays to sell / how many of each to buy). The ShopView * (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade * preview (Economy.tradePreview) and applies it to the ship. * * Headless-safe: the Phaser rendering is best-effort (guarded), and the * selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data * so dev/shop-ui.test.mjs can drive it with no scene. */ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js'; import { fontStack } from '../../utils/Theme.js'; export class TradeGrid extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). * @param {object} o — { side: 'sell'|'buy', maxSlots, capacity, * cls, systemId, galaxy, sellUnit(id), buyUnit(id) } * sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so * the grid can show "sell for N" / "buy for N" without re-deriving. */ constructor(scene, o) { super(scene, 0, 0); this.scene = scene; this.side = o.side; this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16)); this.capacity = Math.max(0, Math.floor(o.capacity ?? 0)); this.cls = o.cls; this.systemId = o.systemId; this.sellUnit = (id) => o.sellUnit?.(id) ?? 0; this.buyUnit = (id) => o.buyUnit?.(id) ?? 0; const g = config.get('shopui.grid', {}); this.cols = g.cols ?? 4; this.rows = g.rows ?? 4; this.slot = g.slot ?? 96; this.gap = g.gap ?? 10; const pad = config.get('shopui.panel.pad', 16); this.pad = pad; this.titleH = config.get('shopui.panel.title.h', 34); const sl = config.get('shopui.slot', {}); this.sl = sl; this.cellW = this.cols * this.slot + (this.cols - 1) * this.gap; this.cellH = this.rows * this.slot + (this.rows - 1) * this.gap; this.width = this.cellW + pad * 2; this.height = this.titleH + this.cellH + pad * 2; // --- state ----------------------------------------------------------- // SELL: bays in fill order (goodId | null), length = maxSlots; usable = // index < capacity. BUY: stock entries { id, name, price }, length = // stock size (≤ maxSlots). this.bays = []; this.stock = []; this.sellSel = new Set(); // sell: checked bay indices this.buyQty = new Map(); // buy: goodId -> qty // --- objects --------------------------------------------------------- this._objs = []; // everything we create (destroy()) this.plate = null; // the semi-transparent panel background this.title = null; this.cells = []; // per cell: { plate, sel, icon, label, stepQty, stepMinus, stepPlus } this.tip = null; // shared hover tooltip (name + price) — the this.tipPlate = null; // per-cell name/price text is gone; hovering this.tipName = null; // a cell reveals it beside the icon this.tipPrice = null; this.tipVisible = false; this._buildPanel(); this._buildTitle(); this._buildCells(); this._buildTooltip(); // LAST — drawn on top of every cell } // --- geometry --------------------------------------------------------- _cellXY(i) { const c = i % this.cols; const r = Math.floor(i / this.cols); const x = this.pad + c * (this.slot + this.gap) + this.slot / 2; const y = this.titleH + this.pad + r * (this.slot + this.gap) + this.slot / 2; return { x, y }; } _buildPanel() { const p = config.get('shopui.panel', {}); const gr = this.scene.add?.graphics?.(); if (gr) { this.plate = gr; this._objs.push(gr); this._drawPlate(); // Parent it to the grid so it renders with the panel: in this Phaser // build a scene-level object at depth 0 draws BEHIND the shop video // (verified in dev/cbox-test.html + screenshots) — container children // inherit the grid's depth (ShopView = 100) and its translation, so the // plate stays glued to the panel and above the video. The cells are // added later, so the plate sits behind them (added first = drawn first). this.add?.(gr); } this.plateStyle = p; } _drawPlate() { const gr = this.plate; if (!gr || typeof gr.clear !== 'function') return; const p = this.plateStyle ?? {}; const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7); gr.clear?.(); if (typeof gr.fillStyle === 'function') { gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a); gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), Math.floor(a * 255)); } gr.fillRect?.(0, 0, this.width, this.height); } _buildTitle() { const t = this.scene.add?.text?.( this.width / 2, 8, this.side === 'sell' ? 'SELL' : 'BUY', { fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center', fontFamily: fontStack('header') }, // Ethnocentric — the theme's section face ); if (t) { t.setOrigin?.(0.5, 0); this.title = t; this._objs.push(t); // Parent to the grid (same reason as the plate — scene-level depth 0 // renders behind the shop video in this build, so the SELL/BUY header // was invisible before). this.add?.(t); } } _buildCells() { for (let i = 0; i < this.cols * this.rows; i++) { const { x, y } = this._cellXY(i); const cell = { index: i, objects: [] }; // base plate const plate = this.scene.add?.graphics?.(); if (plate) { cell.plate = plate; cell.objects.push(plate); } // selection ring (sell) — cyan highlight when the bay is checked if (this.side === 'sell') { const sel = this.scene.add?.graphics?.(); if (sel) { cell.sel = sel; cell.objects.push(sel); } } // icon (guarded — headless has no texture) if (this.scene.textures && typeof this.scene.textures.exists === 'function' && this.scene.textures.exists(GOODS_TEXTURE_KEY)) { const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0); if (icon) { icon.setOrigin?.(0.5, 0.5); icon.setAlpha?.(0); cell.icon = icon; cell.objects.push(icon); } } // state label — ONLY for non-usable cells ("locked" bay / "—" empty). // The good's name + price moved to the shared hover tooltip (this.tip). const label = this.scene.add?.text?.(x, y, '', { fontSize: `${this.sl.name?.fontSize ?? 11}px`, color: this.sl.name?.color ?? '#eaf6ff', align: 'center', fontFamily: fontStack('body'), }); if (label) { label.setOrigin?.(0.5, 0.5); label.setAlpha?.(0); cell.label = label; cell.objects.push(label); } // BUY stepper: − n + (bottom of the cell) if (this.side === 'buy') { const mk = (dx, str) => { const el = this.scene.add?.text?.(x + dx, y + 26, str, { fontSize: `${config.get('shopui.stepper.fontSize', 19)}px`, color: this.sl.price?.buy ?? '#ffc94d', align: 'center', fontFamily: fontStack('body'), }); if (el) { el.setOrigin?.(0.5, 0.5); return el; } return null; }; const minus = mk(-26, '−'); const qty = mk(0, '0'); const plus = mk(26, '+'); if (minus) cell.objects.push(minus); if (qty) { cell.stepQty = qty; cell.objects.push(qty); } if (plus) cell.objects.push(plus); cell.stepMinus = minus; cell.stepPlus = plus; const hit = [-18, -14, 36, 28]; this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit); this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit); this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit); } // interaction: the plate is the whole cell's surface — tap (SELL: toggle // the bay) + hover (either side: reveal the name/price tooltip). const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot]; if (this._interactive(cell.plate, hit)) { if (this.side === 'sell') { cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); }); } cell.plate.on('pointerover', () => this.hoverCell(i)); cell.plate.on('pointerout', () => this.unhover()); } this.cells.push(cell); this.add?.(cell.plate); if (cell.sel) this.add?.(cell.sel); if (cell.icon) this.add?.(cell.icon); if (cell.label) this.add?.(cell.label); if (cell.stepMinus) this.add?.(cell.stepMinus); if (cell.stepQty) this.add?.(cell.stepQty); if (cell.stepPlus) this.add?.(cell.stepPlus); } } // A tap only works when the object supports interactivity (real Phaser); a // headless fake returns no-op objects, and the test drives the logic // methods (toggleBay / bumpBuy) directly. _bindTap(obj, fn, hitRect) { if (!this._interactive(obj, hitRect)) return; obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); }); } // Make `obj` pointer-targetable over `hitRect` (Phaser v4 (Giedi): a bare // setInteractive() leaves no hitAreaCallback and crashes the input — // MenuButton sets an explicit hitArea + callback for this). Returns true // when the object is now interactive (hover + tap bindings can attach). _interactive(obj, hitRect) { if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return false; 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); return true; } // --- DATA (the testable core) ----------------------------------------- /** * Set the grid's contents. SELL: `cargo` is the filled-bay id list (order * preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list * ({ id, name, price } | { id }). Resets the selection (a fresh shop view). */ refresh(o = {}) { this.unhover(); if (this.side === 'sell') { const cargo = Array.isArray(o.cargo) ? o.cargo : []; this.bays = []; for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null); this.sellSel = new Set(); } else { const stock = Array.isArray(o.stock) ? o.stock : []; this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price })); this.buyQty = new Map(); } this._renderAll(); return this; } _stockIdAt(i) { return this.stock[i]?.id ?? null; } _bayIdAt(i) { return this.bays[i] ?? null; } /** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */ toggleBay(i) { if (this.side !== 'sell') return this; if (i < 0 || i >= this.capacity) return this; // locked bay const id = this._bayIdAt(i); if (!id) return this; // empty bay if (this.sellSel.has(i)) this.sellSel.delete(i); else this.sellSel.add(i); this._renderCell(i); this._emitSelChange(); return this; } /** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */ toggleGood(id) { if (this.side !== 'sell') return this; const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity); if (idx >= 0) this.toggleBay(idx); else { // already all checked → uncheck one const idx2 = [...this.sellSel].find((i) => this.bays[i] === id); if (idx2 !== undefined) this.toggleBay(idx2); } return this; } /** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */ bumpBuy(id, d = 1) { if (this.side !== 'buy' || !id) return this; if (!this.stock.some((s) => s.id === id)) return this; // not in stock const cur = this.buyQty.get(id) ?? 0; const next = Math.max(0, cur + Math.floor(Number(d) || 0)); if (next === 0) this.buyQty.delete(id); else this.buyQty.set(id, next); this._renderStockRow(id); this._emitSelChange(); return this; } /** SELL: { goodId: qty } from the checked bays. */ getSellMap() { const m = {}; for (const i of this.sellSel) { const id = this._bayIdAt(i); if (id) m[id] = (m[id] ?? 0) + 1; } return m; } /** BUY: { goodId: qty } from the steppers. */ getBuyMap() { const m = {}; for (const [id, q] of this.buyQty) if (q > 0) m[id] = q; return m; } hasSelection() { return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0; } clearSelection() { if (this.side === 'sell') this.sellSel = new Set(); else this.buyQty = new Map(); this._renderAll(); this._emitSelChange(); return this; } /** The ShopView hooks this to live-update the preview + Confirm/Clear. */ set onSelectionChange(fn) { this._onSel = fn; } _emitSelChange() { if (typeof this._onSel === 'function') this._onSel(this); } // --- RENDERING (best-effort; the logic above is the source of truth) --- _renderAll() { for (let i = 0; i < this.cells.length; i++) this._renderCell(i); } _renderCell(i) { const cell = this.cells[i]; if (!cell) return; const { x, y } = this._cellXY(i); const sl = this.sl; let goodId = null; let locked = false; if (this.side === 'sell') { goodId = this._bayIdAt(i); locked = i >= this.capacity; } else { goodId = this._stockIdAt(i); locked = false; } const usable = !locked && !!goodId; // plate border (selected sell bays get the cyan ring) if (cell.plate && typeof cell.plate.clear === 'function') { const selected = this.side === 'sell' && this.sellSel.has(i); cell.plate.clear?.(); if (typeof cell.plate.fillStyle === 'function') { cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3)); cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#00e5ff') : (sl.border ?? '#1d2e4c')), 255); } cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot); } // icon — the cell's only content for a usable bay/stock slot (the // name+price live in the hover tooltip). Centered; the BUY side lifts it // a touch so the stepper under it reads as a row of its own. if (cell.icon) { const show = usable; if (show) { cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId)); cell.icon.setAlpha?.(1); cell.icon.setScale?.(this.sl.iconScale ?? 0.62); cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y); } else { cell.icon.setAlpha?.(0); } } // state label — locked bays / empty slots only; usable cells stay clean if (cell.label && typeof cell.label.setText === 'function') { cell.label.setText(usable ? '' : (this.side === 'sell' ? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')) : (sl.emptyLabel ?? '—'))); cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3)); } // buy stepper if (this.side === 'buy' && cell.stepQty) { const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0; if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q)); const on = usable; for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0); } } _renderStockRow(id) { for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i); } _goodName(id) { if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id; const list = config.get('goods.goods', []); return list.find((g) => g.id === id)?.name ?? id; } // --- HOVER TOOLTIP (name + price, revealed per cell) ------------------ /** * One shared tooltip per grid (cheaper than one per cell, and only one can * be visible at a time). It replaces the old name+price text painted over * every icon: the cell shows the good's icon alone, and hovering it * reveals "Name" + "sell/buy for N cr" in a small plate beside the cell. * (data/shopui.json → tooltip for size/palette.) */ _buildTooltip() { const t = config.get('shopui.tooltip', {}) ?? {}; this.tipPad = t.pad ?? 10; this.tipGap = t.gap ?? 8; this.tipNameSize = t.nameFontSize ?? 14; this.tipPriceSize = t.priceFontSize ?? 13; this.tipBg = t.bgColor ?? '#0a1120'; this.tipBgA = t.bgAlpha ?? 0.96; this.tipBorder = t.border ?? '#22405f'; const mk = (size, color) => { const el = this.scene.add?.text?.(0, 0, '', { fontFamily: fontStack('body'), // Centauri — the theme's body face fontSize: `${size}px`, color, align: 'center', }); if (el) el.setOrigin?.(0.5, 0); return el ?? null; }; const plate = this.scene.add?.graphics?.() ?? null; const name = mk(this.tipNameSize, this.sl.name?.color ?? '#eaf6ff'); const price = mk(this.tipPriceSize, this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d')); if (!plate && !name && !price) return; if (plate) this._objs.push(plate); if (name) this._objs.push(name); if (price) this._objs.push(price); this.tipPlate = plate; this.tipName = name; this.tipPrice = price; // Group the three so one container position/alpha drives the whole tip // (and it's drawn LAST in the grid → above every cell). if (Phaser.GameObjects?.Container) { const box = new Phaser.GameObjects.Container(this.scene, 0, 0); box.setScrollFactor?.(0); if (plate) box.add?.(plate); if (name) box.add?.(name); if (price) box.add?.(price); box.setAlpha?.(0); this.tip = box; this.add?.(box); // after every cell → on top } else { // headless fallback: expose the same surface over the loose pieces this.tip = { setPosition: (x, y) => { plate?.setPosition?.(x, y); name?.setPosition?.(x, y); price?.setPosition?.(x, y); }, setAlpha: (a) => { plate?.setAlpha?.(a); name?.setAlpha?.(a); price?.setAlpha?.(a); }, destroy: () => {}, }; } } /** * Show the tooltip for cell `i` (a filled bay / a stocked good). Locked * and empty cells have nothing to reveal → the tip hides. Anchored above * the cell when there's room, otherwise below (top row); centred on the * cell and clamped inside the panel. */ hoverCell(i) { const cell = this.cells[i]; if (!cell || !this.tip) return this; let goodId = null; if (this.side === 'sell') { if (i < this.capacity) goodId = this._bayIdAt(i); } else { goodId = this._stockIdAt(i); } if (!goodId) return this.unhover(); const unit = Math.round(this.side === 'sell' ? this.sellUnit(goodId) : this.buyUnit(goodId)); if (this.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId); if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`${this.side === 'sell' ? 'sell' : 'buy'} for ${unit} cr`); // size from the text metrics (real Phaser or the test stubs) const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18; const priceW = this.tipPrice?.width ?? 60, priceH = this.tipPrice?.height ?? 18; const w = Math.max(nameW, priceW) + this.tipPad * 2; const h = this.tipPad + nameH + 3 + priceH + this.tipPad; const g = this.tipPlate; if (g && typeof g.clear === 'function' && typeof g.fillStyle === 'function') { g.clear?.(); g.fillStyle(_hex(this.tipBg), this.tipBgA); g.lineStyle?.(1, _hex(this.tipBorder), 255); g.fillRect?.(0, 0, w, h); } if (this.tipName) this.tipName.setPosition?.(w / 2, this.tipPad); if (this.tipPrice) this.tipPrice.setPosition?.(w / 2, this.tipPad + nameH + 3); const { x, y } = this._cellXY(i); let ty = y - this.slot / 2 - this.tipGap - h; // above the cell… if (ty < 0) ty = y + this.slot / 2 + this.tipGap; // …or below (top row) const min = w / 2, max = this.width - w / 2; const tx = (max > min) ? Math.min(Math.max(x, min), max) : this.width / 2; this.tip.setPosition?.(tx - w / 2, ty); this.tip.setAlpha?.(1); this.tipVisible = true; return this; } /** Hide the tooltip (pointer left the cell / contents refreshed). */ unhover() { if (this.tip) this.tip.setAlpha?.(0); this.tipVisible = false; return this; } // --- ANIMATION + LIFECYCLE ------------------------------------------- /** Slide the whole grid in from its side (sell: left, buy: right). */ /** * Reveal the panel: SELL slides in from the left, BUY from the right. * Both settle at their LAYOUT position (set earlier via setPosition) — the * tween target is that position, not the origin (tweening to x:0 stacked * both panels at the left edge, hiding the cargo/sell panel behind the buy). */ slideIn() { const ms = config.get('shopui.animation.slideMs', 340); const ease = config.get('shopui.animation.ease', 'Cubic.Out'); const off = this.width + 24; const targetX = this.x; // the final x (set by ShopView.setPosition before this) const targetY = this.y; const from = this.side === 'sell' ? targetX - off : targetX + off; if (this.scene.tweens && typeof this.scene.tweens.add === 'function') { this.x = from; this.y = targetY; this.scene.tweens.add({ targets: this, x: targetX, y: targetY, duration: ms, ease, delay: this.side === 'buy' ? 40 : 0 }); } else { this.x = targetX; } return this; } destroy() { for (const o of this._objs) o?.destroy?.(); for (const c of this.cells) for (const o of c.objects) o?.destroy?.(); if (this.tip) { this.tip.destroy?.(); this.tip = null; } // after the pieces (they're in _objs) this._objs = []; this.cells = []; if (typeof super.destroy === 'function') super.destroy(); } } // '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed. function _hex(v) { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; }