/** * TradeGrid — the shop's SELL side: the player's cargo bays as a 4×4 grid. * * ┌────────────┐ * │ SELL │ * │ ┌────┐┌────┐ │ * │ │icon││icon│ │ filled bay → good icon * │ └────┘└────┘ │ empty bay → dim plate * │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room) * │ │ ✓ ││lock│ │ checked → GREEN stroke + tinted fill + a green ✓ in * │ └────┘└────┘ │ the upper-left corner (1 unit queued) * └────────────┘ * * A BAY is the unit (not a good): each filled bay holds 1 unit of its good * and checking it off queues 1 unit for sale. The first `capacity` bays are * live (filled with a good, or empty); the rest are LOCKED — the hold's * upgrade room (the ship can't carry there yet). Hovering a cell reveals * the good's name + sell price in a shared tooltip (icon-only cells — no * text over the icon). A CHECKED bay is marked three ways (all revert * together when it's checked off again): the green border stroke, a * slightly lifted green-tinted fill, and the green checkmark badge in the * upper-left corner (data/shopui.json → slot: selected, selBg, check). * * (The BUY side is no longer a grid — the shop's stock is now a scrollable * list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side * by side.) * * The PRICING lives in js/economy/Economy.js (sellPrice — the fixed * per-(good, shop) price). This grid only shows the price and tracks the * SELECTION (which bays are checked off). The ShopView * (js/ui/shop/ShopView.js) composes it with the BuyList, 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 / getSellMap) 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, isQuestCargo, isTradeable } 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 — { maxSlots, capacity, cls, systemId, sellUnit(id), questSell } * sellUnit is the per-unit price lookup (from the ShopView) so the grid * can show "sell for N" without re-deriving. * questSell — true on the HOME WORLD's shop: the quest cargo (the * 'Special Delivery' package, tradeable:false) is SALEABLE here — its * one sale, the delivery (zero credits — the good's `sellFor`). * Everywhere else it stays NOT FOR SALE (toggleBay refuses it). */ constructor(scene, o) { super(scene, 0, 0); this.scene = scene; this.side = 'sell'; // SELL-only now (the BUY side moved to BuyList.js) 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.questSell = o.questSell === true; // the home world's shop — the package's delivery 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 ----------------------------------------------------------- // bays in fill order (goodId | null), length = maxSlots; usable = // index < capacity (locked bays are the hold's upgrade room). this.bays = []; this.sellSel = new Set(); // checked bay indices (the pending sell) // --- 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 } 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 = 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, 'SELL', { 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); } // corner check (sell) — the green checkmark badge when the bay is // queued (added AFTER the icon so it paints over it — see _buildCells) 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); } // interaction: the plate is the whole cell's surface — tap (toggle the // bay) + hover (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)) { 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.icon) this.add?.(cell.icon); if (cell.label) this.add?.(cell.label); if (cell.sel) this.add?.(cell.sel); // the corner check — LAST, above the icon } } // A tap only works when the object supports interactivity (real Phaser); a // headless fake returns no-op objects, and the test drives the logic // method (toggleBay) 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: `cargo` is the filled-bay id list (order * preserved — bay i holds cargo[i]). Resets the selection (a fresh shop * view). */ refresh(o = {}) { this.unhover(); 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(); this._renderAll(); return this; } _bayIdAt(i) { return this.bays[i] ?? null; } /** * Is a bay's good SALEABLE on THIS world's floor? Ordinary tradeable * goods anywhere; the quest cargo (the 'Special Delivery' package — * tradeable:false) ONLY when questSell is set (the home world's shop — * the delivery is its one sale, zero credits). The SELL grid's world * gate: the parcel reaches the home world and is handed over, never * traded. */ _sellable(id) { return isTradeable(id) || (this.questSell && isQuestCargo(id)); } /** Check / uncheck a bay (1 unit of its good). No-op if empty/locked/ * not saleable here (the special-package's bay is aboard, NOT FOR SALE * off the home world — data/goods.json → tradeable:false; the * tooltip says so on hover; the home world is the one exception, * the delivery — see _sellable). */ toggleBay(i) { if (i < 0 || i >= this.capacity) return this; // locked bay const id = this._bayIdAt(i); if (!id) return this; // empty bay if (!this._sellable(id)) return this; // not saleable here (quest cargo off the home world) if (this.sellSel.has(i)) this.sellSel.delete(i); else this.sellSel.add(i); this._renderCell(i); this._emitSelChange(); return this; } /** Check / uncheck by good id (the Nth unchecked/checked bay of it). */ toggleGood(id) { 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; } /** { 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; } /** * Restore a { goodId: qty } selection onto the CURRENT bays (the ShopView * re-applies the player's pending trade after the BUY/SELL toggle rebuilds * the panel). Checks the first N filled bays of each good; ids no longer * in the hold are dropped. Call after refresh() (which resets selection). */ setSellMap(map = {}) { this.sellSel = new Set(); for (const [id, want] of Object.entries(map ?? {})) { if (!this._sellable(id)) continue; // not saleable here (quest cargo off the home world) — dropped let need = Math.max(0, Math.floor(Number(want) || 0)); if (!need) continue; for (let i = 0; i < this.capacity && need > 0; i++) { if (this._bayIdAt(i) === id) { this.sellSel.add(i); need--; } } } this._renderAll(); this._emitSelChange(); return this; } hasSelection() { return this.sellSel.size > 0; } clearSelection() { this.sellSel = new Set(); 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; goodId = this._bayIdAt(i); locked = i >= this.capacity; const usable = !locked && !!goodId; // plate — base fill + border. A QUEUED bay (checked to sell) is marked // here: the GREEN stroke + a slightly lifted green-tinted fill (the // corner check is drawn below); unchecking re-renders the idle look. const selected = usable && this.sellSel.has(i); if (cell.plate && typeof cell.plate.clear === 'function') { cell.plate.clear?.(); if (typeof cell.plate.fillStyle === 'function') { cell.plate.fillStyle( _hex(selected ? (sl.selBgColor ?? sl.bgColor ?? '#0e1728') : (sl.bgColor ?? '#0e1728')), usable ? (selected ? (sl.selBgAlpha ?? 0.95) : 0.92) : (sl.lockAlpha ?? 0.3)); cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#7ce8a4') : (sl.border ?? '#1d2e4c')), 255); } cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot); } // the corner check (upper-left) — the selected bay's green checkmark // badge, drawn on cell.sel (added after the icon → above it); cleared // when the bay is checked off again (the render is pure state). if (cell.sel && typeof cell.sel.clear === 'function') { cell.sel.clear?.(); if (selected) this._drawCheck(cell.sel, x, y, sl); } // icon — the cell's only content for a usable bay (the name+price live // in the hover tooltip). Centered. 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, y); } else { cell.icon.setAlpha?.(0); } } // state label — locked bays / empty bays only; usable cells stay clean if (cell.label && typeof cell.label.setText === 'function') { cell.label.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))); cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3)); } } _goodName(id) { const list = config.get('goods.goods', []); return list.find((g) => g.id === id)?.name ?? id; } /** * The checked bay's corner mark (UPPER-LEFT): a small dark badge so the * green check reads over any icon corner, then the green check itself * (two strokes — short arm down to the vertex, long arm up). Sized from * data/shopui.json → slot.check; drawn on the cell's `sel` graphics. * Headless-safe: a fake scene without fillCircle just skips the badge. */ _drawCheck(g, x, y, sl) { if (!g || typeof g.fillStyle !== 'function' || typeof g.lineStyle !== 'function' || typeof g.lineBetween !== 'function') return; const chk = sl.check ?? {}; const r = Math.max(6, Number(chk.size) || 11); // the badge radius const inset = Math.max(4, Number(chk.inset) || 7); // the badge's corner inset const cx = x - this.slot / 2 + inset + r; // badge centre (upper-left) const cy = y - this.slot / 2 + inset + r; if (chk.badgeBg != null && typeof g.fillCircle === 'function') { g.fillStyle(_hex(chk.badgeBg), chk.badgeAlpha != null ? Number(chk.badgeAlpha) : 0.85); g.fillCircle(cx, cy, r); } // the check — sized to the badge, green (the selection color) const s = r * 0.62; g.lineStyle?.(Math.max(2, Math.round(r * 0.3)), _hex(chk.color ?? sl.selected ?? '#7ce8a4'), 1); g.lineBetween(cx - s * 0.9, cy + s * 0.05, cx - s * 0.25, cy + s * 0.62); g.lineBetween(cx - s * 0.25, cy + s * 0.62, cx + s * 0.95, cy - s * 0.55); } // --- 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.sl.price?.sell ?? '#7ce8a4'); 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; const goodId = i < this.capacity ? this._bayIdAt(i) : null; if (!goodId) return this.unhover(); const unit = Math.round(this.sellUnit(goodId)); const saleable = this._sellable(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(saleable ? `sell for ${unit} cr` : 'NOT FOR SALE'); // 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; }