orbit/js/ui/shop/TradeGrid.js

591 lines
23 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.

/**
* 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;
}