orbit/js/ui/shop/TradeGrid.js

589 lines
24 KiB
JavaScript
Raw Permalink 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 → 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;
}