diff --git a/data/shopui.json b/data/shopui.json
index d3a24b2..ca86464 100644
--- a/data/shopui.json
+++ b/data/shopui.json
@@ -1,5 +1,5 @@
{
- "_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), a center column carries the live NET preview + Confirm/Clear, and a tab bar up top toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
+ "_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), a center column carries the live NET preview + Confirm/Clear, and a tab bar up top toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
"enabled": true,
"tabs": {
@@ -29,18 +29,29 @@
},
"slot": {
- "_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).",
+ "_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which now fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors still apply — they feed the tooltip's name/price lines + the buy stepper. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).",
"bgColor": "#0e1728",
"border": "#1d2e4c",
"selected": "#00e5ff",
"lockAlpha": 0.30,
- "iconScale": 0.58,
+ "iconScale": 0.62,
"name": { "fontSize": 11, "color": "#eaf6ff" },
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" },
"emptyLabel": "—",
"lockLabel": "locked"
},
+ "tooltip": {
+ "_comment": "The hover tooltip — one per grid (shared), revealed when the pointer is over a usable cell: the good's NAME + the price line (\"sell for N cr\" / \"buy for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri).",
+ "bgColor": "#0a1120",
+ "bgAlpha": 0.96,
+ "border": "#22405f",
+ "pad": 10,
+ "gap": 8,
+ "nameFontSize": 14,
+ "priceFontSize": 13
+ },
+
"stepper": {
"_comment": "The BUY-side quantity stepper (− n +). Clicking −/+ adjusts that good's buy quantity; the live preview recalcs. The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
"fontSize": 19,
diff --git a/dev/shop-shot.html b/dev/shop-shot.html
index 6e2c1bb..659c118 100644
--- a/dev/shop-shot.html
+++ b/dev/shop-shot.html
@@ -8,6 +8,18 @@
working even if the project is served from a subdirectory.) -->
diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs
index 793798f..0cc7657 100644
--- a/dev/shop-shot.mjs
+++ b/dev/shop-shot.mjs
@@ -26,6 +26,16 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const data = await ConfigLoader.load();
config.init(data);
+// Await the theme web fonts (mirrors js/main.js) so canvas text draws with
+// Ethnocentric/Centauri, not the fallback stack. Hard timeout: boot never
+// hangs on a flaky font.
+{ const fonts = globalThis.document?.fonts; if (fonts?.load) {
+ const fams = ['Ethnocentric', 'Centauri'];
+ await Promise.race([
+ Promise.allSettled(fams.map((f) => fonts.load(`16px "${f}"`).catch(() => {}))),
+ new Promise((r) => setTimeout(r, 2000)),
+ ]);
+} }
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
@@ -79,37 +89,85 @@ const bounds = (o) => {
sv.setTab('trade');
await sleep(900);
- const chain = (o) => o ? {
- x: o.x, y: o.y, scaleX: o.scaleX, scaleY: o.scaleY,
- ox: o.originX, oy: o.originY, dw: o.displayWidth, dh: o.displayHeight,
- active: o.active, parent: o.parent ? (o.parent.isContainer ? 'container' : o.parent.type ?? String(o.parent)) : null,
- } : null;
- const out = {
- pass: true,
- screen: { w: ss.scale.width, h: ss.scale.height },
- camera: { scrollX: ss.cameras?.main?.scrollX, scrollY: ss.cameras?.main?.scrollY, zoom: ss.cameras?.main?.zoom },
- topY: sv.topY ?? null,
- hudName: bounds(ss.hudName),
- hudLine: bounds(ss.hudLine),
- sellGrid: bounds(sv.sellGrid),
- buyGrid: bounds(sv.buyGrid),
- subBar: bounds(sv.subBar),
- panel: { w: sv.sellGrid?.width ?? null, h: sv.sellGrid?.height ?? null },
- chain: {
- shopView: chain(sv),
- content: chain(sv._content),
- sellGrid: chain(sv.sellGrid),
- buyGrid: chain(sv.buyGrid),
- },
- // Where the panel ACTUALLY renders: the plate + first cell + title are
- // real children at known local coords — their world matrices are the truth.
- actual: {
- sellTitle_world: (() => { const t = sv.sellGrid.title; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(),
- sellCellName_world: (() => { const t = sv.sellGrid.cells?.[0]?.name; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(),
- buyTitle_world: (() => { const t = sv.buyGrid.title; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(),
- net_world: (() => { const t = sv._previewText; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(),
- },
+ // ---- HOVER TOOLTIP PROBE (name + price on hover, not over the icon) ----
+ const round = (v) => Math.round(v);
+ const worldTopLeft = (o) => o?.getTopLeft ? (() => { const v = o.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; })() : null;
+ const tipState = (grid) => ({
+ visible: !!grid.tipVisible,
+ tipAlpha: grid.tip?.alpha ?? null,
+ name: grid.tipName?.text ?? null,
+ price: grid.tipPrice?.text ?? null,
+ nameFont: grid.tipName?.style?.fontFamily ?? grid.tipName?._style?.fontFamily ?? null,
+ tipTopLeft: worldTopLeft(grid.tipName),
+ tipPlateTopLeft: worldTopLeft(grid.tipPlate),
+ tipPos: grid.tip ? { x: round(grid.tip.x), y: round(grid.tip.y) } : null,
+ });
+ const cellWorldCenter = (grid, i) => {
+ const { x, y } = grid._cellXY(i);
+ const c = grid.children?.list?.find?.((o) => o === grid.plate) ?? null;
+ // grid local (x,y) → world: the grid container IS a pure translation (dev/cbox-test.html)
+ return { x: round(grid.x + x), y: round(grid.y + y) };
};
+ const toClient = (wx, wy) => {
+ const r = game.canvas.getBoundingClientRect();
+ return { cx: r.left + (wx / 1280) * r.width, cy: r.top + (wy / 720) * r.height };
+ };
+ const mouseMove = (wx, wy) => {
+ const { cx, cy } = toClient(wx, wy);
+ // This Giedi build's input listens for classic mouse events on the canvas
+ // (mousedown/up/move + mouseover/out) — NOT pointer events — so hover is
+ // driven with a real DOM mousemove (bubbles: true).
+ const el = game.canvas;
+ el.dispatchEvent(new MouseEvent('mousemove', { clientX: cx, clientY: cy, bubbles: true, view: window }));
+ return { cx: round(cx), cy: round(cy) };
+ };
+
+ const out = { pass: true, tooltip: null };
+
+ // 1) structure: the old per-cell name/price text is GONE from usable cells
+ const sellCell = sv.sellGrid.cells[0];
+ const buyCell = sv.buyGrid.cells[0];
+ const struct = {
+ sellCellName: sellCell.name === undefined ? 'undefined' : 'present',
+ sellCellPrice: sellCell.price === undefined ? 'undefined' : 'present',
+ sellLabel: { text: sellCell.label?.text ?? null, alpha: sellCell.label?.alpha ?? null },
+ sellLockedLabel: { text: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.text ?? null, alpha: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.alpha ?? null },
+ sellIcon: { alpha: sellCell.icon?.alpha ?? null, scale: round((sellCell.icon?.scaleX ?? 0) * 100) / 100, pos: { x: sellCell.icon?.x, y: sellCell.icon?.y } },
+ titleFont: sv.sellGrid.title?.style?.fontFamily ?? sv.sellGrid.title?._style?.fontFamily ?? null,
+ stepperFont: sv.buyGrid.cells[0].stepQty?.style?.fontFamily ?? sv.buyGrid.cells[0].stepQty?._style?.fontFamily ?? null,
+ };
+ out.tooltip = { struct };
+
+ // 2) programmatic hover on a filled SELL bay (row 0 → tip flips BELOW)
+ const tipSellA = sv.sellGrid.hoverCell(0);
+ out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 0) };
+
+ // 3) REAL mouse hover over a BUY cell — row 1 → tip flips ABOVE
+ const buyIdxAbove = 6; // row 1, col 2 (stock is filled there)
+ const bc = cellWorldCenter(sv.buyGrid, buyIdxAbove);
+ out.tooltip.mouseAbove = { cell: bc, move: mouseMove(bc.x, bc.y), state: tipState(sv.buyGrid) };
+
+ // 4) REAL mouse hover over a BUY cell — row 0 → tip flips BELOW
+ const buyIdxBelow = 0;
+ const bc0 = cellWorldCenter(sv.buyGrid, buyIdxBelow);
+ out.tooltip.mouseBelow = { cell: bc0, move: mouseMove(bc0.x, bc0.y), state: tipState(sv.buyGrid) };
+
+ // 5) move the pointer OFF the panel → pointerout hides the tip
+ const off = toClient(Math.max(4, sv.buyGrid.x - 30), 360);
+ game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window }));
+ await sleep(150);
+ out.tooltip.mouseOff = { state: tipState(sv.buyGrid) };
+
+ // 6) end with the SELL tooltip visible for the screenshot
+ sv.sellGrid.hoverCell(0);
+ out.tooltip.finalSell = tipState(sv.sellGrid);
+
+ out.screen = { w: ss.scale.width, h: ss.scale.height };
+ out.topY = sv.topY ?? null;
+ out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })();
+ out.sellGrid = { x: round(sv.sellGrid.x), y: round(sv.sellGrid.y), w: sv.sellGrid.width, h: sv.sellGrid.height };
+ out.buyGrid = { x: round(sv.buyGrid.x), y: round(sv.buyGrid.y), w: sv.buyGrid.width, h: sv.buyGrid.height };
+
window.__SHOP_SHOT__ = out;
console.info('[shop-shot] OK:', JSON.stringify(out));
})().catch((e) => fail(String(e?.message || e)));
diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs
index aa488f7..cd2ad31 100644
--- a/dev/shop-ui.test.mjs
+++ b/dev/shop-ui.test.mjs
@@ -259,6 +259,64 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
}
console.log('INTERACTION: OK');
+// ===========================================================================
+console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==');
+{
+ const ship = makeShip(10000);
+ ship.cargo.add([A, B]);
+ const { view } = makeView(ship);
+ const nameA = goods.find((g) => g.id === A).name;
+ const sellA = E.sellPrice(A, cls, sysId, galaxy);
+ const buyId = view.buyGrid.stock[0]?.id;
+ const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
+ const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
+
+ // Cells no longer paint their own name/price text — the info lives in the
+ // shared per-grid tooltip (one per side), revealed by hover.
+ assert.equal(view.sellGrid.cells[0].name, undefined, 'no name text over the icon anymore');
+ assert.equal(view.sellGrid.cells[0].price, undefined, 'no price text over the icon anymore');
+ assert.ok(view.sellGrid.tip, 'the SELL grid has a shared tooltip');
+ assert.equal(view.sellGrid.tipVisible, false, 'tooltip starts hidden');
+
+ // Hover a filled bay → name + "sell for N cr" appear.
+ view.sellGrid.hoverCell(0);
+ assert.equal(view.sellGrid.tipVisible, true, 'hovering a filled bay reveals the tooltip');
+ assert.ok(view.sellGrid.tip.alpha > 0.5, `tooltip is visible (alpha=${view.sellGrid.tip.alpha})`);
+ assert.equal(view.sellGrid.tipName.text, nameA, `tooltip shows the good's name (${nameA})`);
+ assert.equal(view.sellGrid.tipPrice.text, `sell for ${Math.round(sellA)} cr`, `tooltip shows the sell price (${sellA})`);
+
+ // Locked bays (past capacity) + empty bays have nothing to reveal.
+ const lockedIdx = view.sellGrid.capacity; // first locked bay
+ view.sellGrid.hoverCell(lockedIdx);
+ assert.equal(view.sellGrid.tipVisible, false, 'locked bay: no tooltip (nothing to sell)');
+ if (view.sellGrid.bays[2] == null) {
+ view.sellGrid.hoverCell(2);
+ assert.equal(view.sellGrid.tipVisible, false, 'empty bay: no tooltip (no good in it)');
+ }
+
+ // pointerout → hidden again.
+ view.sellGrid.hoverCell(0);
+ view.sellGrid.unhover();
+ assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
+ assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
+
+ // BUY side: name + "buy for N cr".
+ view.buyGrid.hoverCell(0);
+ assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip');
+ assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name');
+ assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`);
+
+ // THEME FONTS — the shop UI speaks the project's type (data/theme.json):
+ // Ethnocentric for section headers, Centauri for body text + buttons.
+ const fam = (o) => o?._style?.fontFamily ?? '';
+ assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`);
+ assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`);
+ assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face');
+ assert.ok(/Centauri/.test(fam(view.buyGrid.cells[0].stepQty)), 'buy stepper uses the body face');
+ assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face');
+}
+console.log('HOVER TOOLTIP: OK');
+
// ===========================================================================
console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold ==');
{
diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js
index 3aeaa5c..4af98a0 100644
--- a/js/ui/shop/ShopView.js
+++ b/js/ui/shop/ShopView.js
@@ -37,6 +37,7 @@ import { config } from '../../config/Config.js';
import * as E from '../../economy/Economy.js';
import { MenuButton } from '../MenuButton.js';
import { toColor, toCss } from '../../utils/Color.js';
+import { fontStack } from '../../utils/Theme.js';
import { TradeGrid } from './TradeGrid.js';
export class ShopView extends Phaser.GameObjects.Container {
@@ -369,7 +370,7 @@ export class ShopView extends Phaser.GameObjects.Container {
const cx = W / 2;
const title = this._makeText(cx, contentY + 10, 'BUY BACK', {
- fontSize: '21px', color: '#eaf6ff', align: 'center' });
+ fontSize: '21px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('header') }); // Ethnocentric — section face
content.add?.(title);
let y = contentY + 56;
@@ -612,14 +613,14 @@ export class ShopView extends Phaser.GameObjects.Container {
// Owned by whichever container the caller adds it to (or this) — real
// Phaser tears the whole tree down on destroy(); the headless stub doesn't
// recurse, but the tests build a fresh scene each time so that's fine.
- const t = this.scene.add?.text?.(x, y, str, { align: 'center', ...style });
+ const t = this.scene.add?.text?.(x, y, str, { align: 'center', fontFamily: fontStack('body'), ...style }); // Centauri default; callers may override
if (t) t.setOrigin?.(0.5, 0.5);
return t;
}
_makeButton(label, w, h, accent) {
const g = this.scene.add?.graphics?.();
- const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center' });
+ const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('body') });
const btn = new Phaser.GameObjects.Container(this.scene, 0, 0);
if (g) { this._drawBtn(g, w, h, accent); btn.add?.(g); }
if (t) { t.setOrigin?.(0.5, 0.5); btn.add?.(t); }
diff --git a/js/ui/shop/TradeGrid.js b/js/ui/shop/TradeGrid.js
index eacc94d..dbdb347 100644
--- a/js/ui/shop/TradeGrid.js
+++ b/js/ui/shop/TradeGrid.js
@@ -12,6 +12,9 @@
* 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
@@ -25,6 +28,7 @@
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 {
/**
@@ -74,11 +78,17 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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.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 ---------------------------------------------------------
@@ -125,7 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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' },
+ { 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);
@@ -160,20 +170,13 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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, '', {
+ // 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', wordWrap: { width: this.slot - 8 },
+ align: 'center', fontFamily: fontStack('body'),
});
- 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); }
+ 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') {
@@ -181,6 +184,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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;
@@ -198,18 +202,22 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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]);
+ // 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.name) this.add?.(cell.name);
- if (cell.price) this.add?.(cell.price);
+ 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);
@@ -220,9 +228,16 @@ export class TradeGrid extends Phaser.GameObjects.Container {
// 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).
+ 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) {
@@ -230,7 +245,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
opts.hitArea = area;
opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py);
}
- obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
+ obj.setInteractive(opts);
+ return true;
}
// --- DATA (the testable core) -----------------------------------------
@@ -241,6 +257,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
* ({ 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 = [];
@@ -350,16 +367,13 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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;
@@ -375,32 +389,29 @@ export class TradeGrid extends Phaser.GameObjects.Container {
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
}
- // icon
+ // 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.58);
- cell.icon.setPosition?.(x, y - 12);
+ cell.icon.setScale?.(this.sl.iconScale ?? 0.62);
+ cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y);
} 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);
+ // 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
@@ -422,6 +433,120 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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). */
@@ -451,6 +576,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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();