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