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:
parent
77d649276c
commit
36ef43119d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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 ==');
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue