589 lines
24 KiB
JavaScript
589 lines
24 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|