Show shop cell name + price in a hover tooltip instead of over the icon

- TradeGrid cells now render only the good's icon; hovering a usable cell reveals a shared per-grid tooltip with the name and "sell/buy for N cr" price line, anchored above (or below on the top row) and clamped to the panel.
- Per-cell name/price text objects are removed; a single `label` remains only for locked/empty state ("locked", "—").
- data/shopui.json: add `tooltip` section (bg, border, pad, gap, font sizes) and bump `iconScale` to 0.62 so icons fill the now-cleaner cell.
- Apply theme fonts throughout the shop UI — Ethnocentric for section titles, Centauri for body text, tooltip, stepper, and buttons via `fontStack()`.
- dev/shop-shot.mjs: replace old layout probe with a hover-tooltip probe (structure check, programmatic + real mousemove hovers on sell/buy cells, pointerout hide) and await web fonts before boot so screenshots use the real typefaces.
- dev/shop-ui.test.mjs: new HOVER TOOLTIP suite asserting tooltip visibility, text content, locked/empty suppression, unhover, buy-side pricing, and theme font application.
This commit is contained in:
Brian Fertig 2026-09-11 21:39:51 -06:00
parent 77d649276c
commit 36ef43119d
6 changed files with 345 additions and 79 deletions

View File

@ -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,

View File

@ -8,6 +8,18 @@
working even if the project is served from a subdirectory.) -->
<base href="../" />
<style>
/* Same typefaces as index.html (the probe page doesn't include it), so
screenshots show the real Ethnocentric/Centauri canvas text. */
@font-face {
font-family: 'Ethnocentric';
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
font-weight: normal; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Centauri';
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
font-weight: normal; font-style: normal; font-display: swap;
}
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>

View File

@ -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)));

View File

@ -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 ==');
{

View File

@ -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); }

View File

@ -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();