orbit/js/ui/shop/TradeGrid.js

503 lines
19 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 — 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 → cyan selection ring (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).
*
* (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 } 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) }
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
* can show "sell for N" without re-deriving.
*/
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;
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); }
// 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); }
// 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.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
if (cell.label) this.add?.(cell.label);
}
}
// 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;
}
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
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.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;
}
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 border (selected sell bays get the cyan ring)
if (cell.plate && typeof cell.plate.clear === 'function') {
const selected = 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 (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;
}
// --- 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));
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(`sell 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;
}