orbit/js/ui/shop/TradeGrid.js

465 lines
18 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.
*
* 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';
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, name, price, stepQty, stepMinus, stepPlus }
this._buildPanel();
this._buildTitle();
this._buildCells();
}
// --- 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' },
);
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); }
}
// name
const name = this.scene.add?.text?.(x, y - 4, '', {
fontSize: `${this.sl.name?.fontSize ?? 11}px`, color: this.sl.name?.color ?? '#eaf6ff',
align: 'center', wordWrap: { width: this.slot - 8 },
});
if (name) { name.setOrigin?.(0.5, 0.5); name.setAlpha?.(0); cell.name = name; cell.objects.push(name); }
// price (sell price green / buy price gold)
const price = this.scene.add?.text?.(x, y + (this.side === 'buy' ? 16 : 20), '', {
fontSize: `${this.sl.price?.fontSize ?? 12}px`,
color: this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d'),
align: 'center',
});
if (price) { price.setOrigin?.(0.5, 0.5); price.setAlpha?.(0); cell.price = price; cell.objects.push(price); }
// 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',
});
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);
}
// SELL: the whole cell is a tappable bay
if (this.side === 'sell') {
const tap = (cell.plate ?? cell.name) || null;
this._bindTap(tap, () => this.toggleBay(i), [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot]);
}
this.cells.push(cell);
this.add?.(cell.plate);
if (cell.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
if (cell.name) this.add?.(cell.name);
if (cell.price) this.add?.(cell.price);
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 (!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(); });
}
// --- 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 = {}) {
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;
let unitPrice = 0;
if (this.side === 'sell') {
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
unitPrice = goodId ? this.sellUnit(goodId) : 0;
} else {
goodId = this._stockIdAt(i);
locked = false;
unitPrice = goodId ? this.buyUnit(goodId) : 0;
}
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
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.58);
cell.icon.setPosition?.(x, y - 12);
} else {
cell.icon.setAlpha?.(0);
}
}
// name
if (cell.name && typeof cell.name.setText === 'function') {
const label = usable ? (this._goodName(goodId) || goodId)
: (this.side === 'sell' ? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))
: (sl.emptyLabel ?? '—'));
cell.name.setText(label);
cell.name.setAlpha?.(usable ? 1 : (sl.lockAlpha ?? 0.3));
}
// price
if (cell.price && typeof cell.price.setText === 'function') {
cell.price.setText(usable ? `${Math.round(unitPrice)} cr` : '');
cell.price.setAlpha?.(usable ? 1 : 0);
}
// 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;
}
// --- 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?.();
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;
}