Rework the BUY side: a scrollable list of wide rows (icon + name +

description + price + stepper) instead of the 4×4 icon grid

Each stock good is now ONE WIDE ROW — the good's icon, its name + a
one-line description (the good's flavor), and the price + a −/qty/+
stepper on the right — the way the user wanted the shop list to read.
The stock (12–16 goods) outgrows the panel, so the list SCROLLS:
drag on the row area or the mouse wheel; rows outside the window are
clipped (row-based alpha clipping — this Phaser build has no
ScrollContainer/camera clip), and hidden rows' steppers are
non-interactive. The selection is data (goodId→qty), so stepping a
good works whether its row is on screen or not.

  js/ui/shop/BuyList.js   new — the scrollable BUY list (rows, drag +
                          wheel scroll, per-row steppers, flavor text)
  js/ui/shop/TradeGrid.js SELL-only now — the BUY mode (steppers, stock
                          rendering, buy-selection data) is stripped;
                          one grid, one purpose
  js/ui/shop/ShopView.js  composes TradeGrid (SELL) + BuyList (BUY);
                          the BUY panel sizes itself to the SELL grid's
                          height (top- AND bottom-aligned)
  data/shopui.json        buyList row/typography knobs (name, desc,
                          price, row bg/border, selection accent)
  dev/shop-ui.test.mjs    BUY assertions rewritten for the list + a new
                          SCROLL section (scroll clamps, row clipping,
                          stepping off-screen rows)
  dev/shop-shot.mjs       BUY probe → the row's inline text (no tooltip)
  docs/ECONOMY.md §12     the BUY bullet + mockup now describe the list

The BUY side's name + price used to live in a hover tooltip because a
grid cell is too small for text; a wide row carries them inline, so the
tooltip stays on the SELL side only. All headless suites green
(shop-ui, economy, cargo, builds, ship-behavior).
This commit is contained in:
Brian Fertig 2026-09-11 23:23:15 -06:00
parent 2b860cc899
commit a2d211a981
7 changed files with 735 additions and 265 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), Confirm/Clear sit in the center column, the live NET-credits readout rides on the TAB SUB-BAR (right of the Buy/Sell + Buyback buttons, past a hairline divider), and the tab sub-bar 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.",
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the SELL grid (the player's cargo bays) sits on the LEFT, the BUY list (the shop's stock, as a scrollable list of wide rows) on the RIGHT, Confirm/Clear sit in the center column, the live NET-credits readout rides on the TAB SUB-BAR (right of the Buy/Sell + Buyback buttons, past a hairline divider), and the tab sub-bar 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. SELL CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. BUY ROWS carry their name + description + price INLINE (no tooltip — the row is wide enough). GEOMETRY (slot/gap/grid/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": {
@ -13,7 +13,7 @@
},
"panel": {
"_comment": "The semi-transparent panel behind each side's grid — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
"_comment": "The semi-transparent panel behind each side (the SELL grid's bays / the BUY list's rows) — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
"bgColor": "#0a1120",
"bgAlpha": 0.70,
"border": "#22405f",
@ -22,27 +22,46 @@
},
"grid": {
"_comment": "The 4×4 grid shared by both sides (data/economy.json → cargo.maxSlots = 16 slots). SELL: the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). BUY: the shop's stock (12–16) fills the first N, the rest are empty.",
"_comment": "The SELL side's 4×4 bay grid (data/economy.json → cargo.maxSlots = 16 slots): the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). `centerW` is the center column (Confirm/Clear) shared by the trade-panel layout. (The BUY side is a list now — → buyList.)",
"cols": 4, "rows": 4,
"slot": 96, "gap": 10,
"centerW": 208
},
"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. `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).",
"_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).",
"bgColor": "#0e1728",
"border": "#1d2e4c",
"selected": "#00e5ff",
"lockAlpha": 0.30,
"iconScale": 0.62,
"name": { "fontSize": 11, "color": "#eaf6ff" },
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" },
"price": { "fontSize": 12, "sell": "#7ce8a4" },
"emptyLabel": "—",
"lockLabel": "locked"
},
"buyList": {
"_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — [icon] NAME / one-line description (the good's flavor, data/goods.json) … PRICE, − qty +. Sized to the SELL grid's height so the panels align top AND bottom; the list scrolls (drag on the body / mouse wheel) when the stock outgrows the viewport — rows outside it are hidden (row-based clipping; this Phaser build has no camera/mask clip). A row in the pending trade (qty > 0) gets the accent border + lit qty. `pad` insets rows from the panel edge; the title bar reuses panel.title.",
"w": 560,
"pad": 14,
"rowH": 64,
"rowGap": 6,
"icon": 44,
"iconGap": 12,
"name": { "fontSize": 14, "color": "#eaf6ff" },
"desc": { "fontSize": 12, "color": "#7d92c4" },
"price": { "fontSize": 14, "color": "#ffc94d", "w": 64 },
"rowBg": "#0e1728",
"rowBgAlpha": 0.55,
"rowBorder": "#1d2e4c",
"rowSelBg": "#12283a",
"rowSelBgAlpha": 0.85,
"selected": "#00e5ff"
},
"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).",
"_comment": "The hover tooltip — one per SELL grid (shared), revealed when the pointer is over a usable bay: the good's NAME + the sell-price line (\"sell 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). (The BUY list has no tooltip — its rows carry the name/price inline.)",
"bgColor": "#0a1120",
"bgAlpha": 0.96,
"border": "#22405f",
@ -53,8 +72,9 @@
},
"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.",
"_comment": "The BUY list's per-row quantity stepper (− n +): clicking −/+ adjusts that good's buy quantity (the qty tap = +1); the live preview recalcs. `w` is the stepper block's width (sizes the price gap). The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
"fontSize": 19,
"w": 88,
"color": "#eaf6ff",
"btn": "#7d92c4",
"activeColor": "#00e5ff"
@ -86,7 +106,7 @@
},
"animation": {
"_comment": "Reveal: the SELL grid slides in from the left, the BUY grid from the right, the center column fades up (data/shopui.json → animation). A short stagger per row keeps it feeling assembled rather than popped.",
"_comment": "Reveal: the SELL grid slides in from the left, the BUY list from the right, the center column fades up (data/shopui.json → animation). A short stagger keeps it feeling assembled rather than popped.",
"slideMs": 340,
"staggerMs": 26,
"ease": "Cubic.Out"

View File

@ -126,7 +126,6 @@ const bounds = (o) => {
// 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',
@ -134,7 +133,7 @@ const bounds = (o) => {
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,
buyQtyFont: sv.buyList.rows[0]?.objs?.qty?.style?.fontFamily ?? sv.buyList.rows[0]?.objs?.qty?._style?.fontFamily ?? null,
};
out.tooltip = { struct };
@ -142,21 +141,24 @@ const bounds = (o) => {
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) };
// 3) BUY side: the WIDE ROW carries name + description + price INLINE (no
// tooltip — the row is wide enough).
const row0 = sv.buyList.rows[0];
const rowNameEl = row0?.objs?.name;
out.tooltip.buyRow = {
name: rowNameEl?.text ?? null,
desc: row0?.objs?.desc?.text ?? null,
price: row0?.objs?.price?.text ?? null,
qty: row0?.objs?.qty?.text ?? null,
nameFont: rowNameEl?.style?.fontFamily ?? rowNameEl?._style?.fontFamily ?? null,
rowWorld: rowNameEl ? { x: round(sv.buyList.x + rowNameEl.x), y: round(sv.buyList.y + rowNameEl.y) } : null,
};
// 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);
// 4) move the pointer OFF the panel → pointerout hides the tip
const off = toClient(Math.max(4, sv.buyList.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) };
out.tooltip.mouseOff = { state: tipState(sv.sellGrid) };
// 6) end with the SELL tooltip visible for the screenshot
sv.sellGrid.hoverCell(0);
@ -166,7 +168,7 @@ const bounds = (o) => {
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 };
out.buyList = { x: round(sv.buyList.x), y: round(sv.buyList.y), w: sv.buyList.width, h: sv.buyList.height, rows: sv.buyList.rows.length, maxScroll: sv.buyList.maxScroll };
window.__SHOP_SHOT__ = out;
console.info('[shop-shot] OK:', JSON.stringify(out));

View File

@ -8,12 +8,16 @@
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
* verified in the browser) — it tests the LOGIC the player feels:
*
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays,
* 12–16 buy slots) and starts on the Buy/Sell tab.
* - CONSTRUCT — the view builds the SELL grid + the BUY list over a real
* shop (16 sell bays; one wide row per stocked good) and starts on the
* Buy/Sell tab.
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
* Confirm earns the credits and drops the good out of the hold.
* - BUY — stepping a stock good buys it at the shop's buy price; Confirm
* spends the credits and fills the bays.
* - BUY — stepping a stock row buys that good at the shop's buy price;
* Confirm spends the credits and fills the bays.
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
* ends, off-window rows are clipped, and selection is data (stepping an
* off-screen row still works).
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
* credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm.
@ -180,7 +184,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
// must click "Buy/Sell" to reveal it (showcase the clip before the UI).
assert.equal(view.tab, null, 'starts unopened (entry state)');
assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)');
assert.equal(view.buyGrid, null, 'no BUY grid yet');
assert.equal(view.buyList, null, 'no BUY list yet');
assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state');
assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)');
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale');
@ -197,7 +201,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
// Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
view.setTab('trade');
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab');
assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist after reveal');
assert.ok(view.sellGrid && view.buyList, 'both the SELL grid and BUY list exist after reveal');
// The panel's TOP edge sits at topY (just under the planet text). A
// TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a
// Container's (x,y) is a pure translation of that local space (verified
@ -210,8 +214,8 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`);
assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`);
assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots');
assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock');
assert.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good');
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
}
@ -225,21 +229,18 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
const { view } = makeView(ship);
const goodById = (id) => goods.find((g) => g.id === id);
// Each BUY cell's icon must carry the correct spritesheet frame (set via
// Each BUY row's icon must carry the correct spritesheet frame (set via
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
let checkedIcons = 0;
for (let i = 0; i < view.buyGrid.cells.length; i++) {
const cell = view.buyGrid.cells[i];
const id = view.buyGrid.stock[i]?.id;
if (!id) continue;
const idx = goodById(id)?.icon;
assert.ok(typeof idx === 'number', `good ${id} has an icon index`);
assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`);
for (const row of view.buyList.rows) {
const idx = goodById(row.id)?.icon;
assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
checkedIcons += 1;
}
assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound');
assert.ok(checkedIcons >= 1, 'at least one buy row got its icon bound');
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
// setInteractive() leaves no callback and crashes Phaser v4's input (the
@ -247,15 +248,17 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
// real hitArea/hitAreaCallback path is exercised.
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback');
// The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position (the ShopView centers
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels
// at the left edge so the cargo/sell panel sat hidden behind the buy panel.
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`);
assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`);
assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)');
}
console.log('INTERACTION: OK');
@ -267,8 +270,8 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
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 buyId = view.buyList.stock[0]?.id;
const nameB = view.buyList.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
@ -300,11 +303,14 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
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})`);
// BUY side: no tooltip — the WIDE ROW carries name + description + price
// inline (the row is wide enough, unlike the icon-only sell cells).
const row0 = view.buyList.rows[0];
assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline');
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price inline');
const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? '';
assert.ok(row0.objs.desc.text.length >= 3, 'row carries the one-line description');
assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)');
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons.
@ -312,7 +318,7 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
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.buyList.rows[0].objs.qty)), 'buy row 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');
@ -357,8 +363,8 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
const buyC = E.buyPrice(C, cls, sysId, galaxy);
assert.ok(buyC > 0, 'the buy price is positive');
view.buyGrid.bumpBuy(C, 2);
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
view.buyList.bumpBuy(C, 2);
assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
let pv = view.preview();
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
assert.equal(pv.net, -buyC * 2, '… net = the spend');
@ -372,6 +378,41 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
}
console.log('BUY: OK');
// ===========================================================================
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
{
const ship = makeShip(10000);
const { view } = makeView(ship);
const list = view.buyList;
assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`);
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
// window, so the list must scroll (rows that don't fit were the old bug).
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
// Scrolling clamps at both ends.
list.scrollBy(100000);
assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom');
list.scrollBy(-100000);
assert.equal(list._scroll, 0, 'scroll clamps back at the top');
// Row-based clipping: mid-scroll, only the window's rows are shown.
list.scrollTo(list.maxScroll / 2);
const shown = list.rows.filter((r) => r.shown);
const hidden = list.rows.filter((r) => !r.shown);
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
// Selection is DATA, not position: stepping a good works whether its row
// is on screen or not (the player can scroll, then adjust, freely).
const last = list.stock[list.stock.length - 1].id;
list.bumpBuy(last, 1);
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
list.clearSelection();
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
}
console.log('SCROLL: OK');
// ===========================================================================
console.log('\n== CAPS — blocked when negative or over the hold ==');
{
@ -379,7 +420,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
const broke = makeShip(0);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
const { view: vb } = makeView(broke);
vb.buyGrid.bumpBuy(C, 1);
vb.buyList.bumpBuy(C, 1);
let pv = vb.preview();
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
@ -391,7 +432,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
const full = makeShip(100000);
full.cargo.add([A, B, A, B]); // 4 bays, no room
const { view: vf } = makeView(full);
vf.buyGrid.bumpBuy(C, 1);
vf.buyList.bumpBuy(C, 1);
pv = vf.preview();
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');

View File

@ -247,7 +247,8 @@ That's flavor, not management.
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. |
| `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. |
| `js/ui/shop/TradeGrid.js` | One side of the shop: a 4×4 grid of goods. SELL mode = the player's cargo bays (tap to check); BUY mode = the shop's stock (−/qty/+ stepper). |
| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays (tap to check off, hover tooltip with name + price). |
| `js/ui/shop/BuyList.js` | The shop's BUY side: the stock as a scrollable list of WIDE ROWS (icon + name + one-line description + price + −/qty/+ stepper; drag/wheel to scroll). |
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. |
| `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
@ -295,7 +296,8 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over**
it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid`s.
it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL
side) + a `BuyList` (the BUY side).
**Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar**
is up — the cyberpunk **MenuButton** pair `[ BUY/SELL | BUYBACK ]` (Buyback grayed out),
@ -303,7 +305,7 @@ extended at its right end by the live **NET** readout (dim `0 cr` until you star
same style + placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above
the command deck**, right-aligned to the deck's **Shop** button — so it reads as the same family
of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking
**Buy/Sell** reveals the trade panel in the middle of the screen (the grids slide in).
**Buy/Sell** reveals the trade panel in the middle of the screen (the panels slide in).
```
# first click — shop video + the tab sub-bar (rests above the deck):
@ -318,10 +320,13 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl
# after clicking Buy/Sell — the trade panel reveals (middle of the screen):
┌─────────────────────────────────────────────────────────────────┐
│ ┌SELL 4×4┐ [ CONFIRM ] ┌BUY 4×4┐ │
│ │ bays │ [ CLEAR ] │ stock │ │
│ │(cargo) │ │(12–16) │ │
│ └────────┘ └────────┘ │
│ ┌SELL 4×4┐ [ CONFIRM ] ┌──────────────────────────────┐ │
│ │ bays │ [ CLEAR ] │[i] Fresh Produce 24 cr − 0 +│ │
│ │(cargo) │ │ Crisp, clean, alive… │ │
│ └────────┘ │[i] Refined Fuel 41 cr − 1 +│ │
│ │ The lifeblood of the fleet │ │
│ │ … (scrolls) … │ │
│ └──────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ NET +420 cr │ ← sub-bar │
@ -337,7 +342,7 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit.
The bar is **extended** past a hairline divider to carry the live **NET** readout —
one glance sees the tabs *and* the pending trade's credit delta.
- **Confirm / Clear** — the same `MenuButton` style, in the center column between the grids;
- **Confirm / Clear** — the same `MenuButton` style, in the center column between the panels;
Confirm is **disabled** (grayed) while the trade is invalid (can't go negative, hold would
overflow), and both appear once you start picking.
@ -345,8 +350,13 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl
are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
quantity is simply the number of its bays that are checked.
- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`). Each
cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room).
- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`) as a
**scrollable list of WIDE ROWS**: each row is the good's **icon**, its **name** + a
one-line **description** (the good's `flavor`), and the **price** + a **−/qty/+ stepper**
on the right. The stock is longer than the panel, so the list **scrolls** (drag on the
list body or the mouse wheel) — rows outside the window are clipped; the selection is
data, so stepping a good works whether its row is on screen or not. Buy as many as you
like (capped by credits + hold room).
- **NET readout (tab sub-bar)** — the live credit delta, right of the tabs past the divider.
Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check
bays / step goods (it's the same `preview()` the Confirm button is gated on).
@ -366,7 +376,7 @@ the same stock from `(seed, placeId, economyVersion)`. The only session state is
list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
against fixed regional prices, never against your own history.
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct both grids, sell a bay and earn the
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct the SELL grid + BUY list, sell a bay and earn the
sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking
Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout
and the slide-in animation are verified in the browser (the stub doesn't render).

477
js/ui/shop/BuyList.js Normal file
View File

@ -0,0 +1,477 @@
/**
* BuyList — the shop's BUY side: a scrollable list of stock rows.
*
* Each stock good is ONE WIDE ROW (replacing the old 4×4 icon grid):
*
* ┌────────────────────────────────────────────────────────────┐
* │ BUY │
* │ ┌────────────────────────────────────────────────────────┐ │
* │ │ [icon] Fresh Produce 24 cr − 0 +│ │
* │ │ Crisp, clean, alive. A small mir… │ │
* │ │ [icon] Refined Fuel 41 cr − 1 +│ │
* │ │ The lifeblood of the fleet. │ │
* │ └────────────────────────────────────────────────────────┘ │
* └────────────────────────────────────────────────────────────┘
*
* · LEFT — the good's icon (its goods-sheet frame).
* · MIDDLE — the NAME (top line) + its one-line DESCRIPTION (the good's
* `flavor` from data/goods.json), truncated to fit.
* · RIGHT — the BUY PRICE ("24 cr") + the −/qty/+ stepper.
*
* A row with qty > 0 (in the pending trade) is highlighted — the same
* selection the old grid's steppers tracked (getBuyMap).
*
* SCROLLING — 8–16 rows don't fit in the panel, so the list scrolls:
* drag on the list body, or the mouse wheel (Phaser 4's object-level
* pointermove + wheel events, the MapWindow.js recipe). Rows outside the
* viewport are hidden (row-based clipping — this Phaser build, 4.2.1 Giedi,
* has no camera/mask clip and no ScrollContainer). Scrolling never touches
* the SELECTION: buyQty is plain data keyed by good id, so stepping a good
* works whether or not its row is on screen.
*
* The PRICING lives in js/economy/Economy.js (shopFor → the fixed
* per-(good, shop) buy price); this component only shows it and tracks the
* SELECTION. ShopView (js/ui/shop/ShopView.js) composes it beside the SELL
* grid (TradeGrid), runs the trade preview (Economy.tradePreview) and
* applies it to the ship.
*
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection + scroll LOGIC (bumpBuy / getBuyMap / scrollBy) is plain data so
* dev/shop-ui.test.mjs can drive it with no scene.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import { toColor, toCss } from '../../utils/Color.js';
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
import { fontStack } from '../../utils/Theme.js';
export class BuyList extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { width, height } (ShopView sizes it; height matches
* the SELL grid so the two panels align top AND bottom).
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene = scene;
const bl = config.section('shopui.buyList', {});
this.width = Math.max(320, Number(o.width) || bl.w || 560);
this.height = Math.max(160, Number(o.height) || 480);
this.pad = bl.pad ?? 14;
this.titleH = config.get('shopui.panel.title.h', 34);
this.rowH = bl.rowH ?? 64;
this.rowGap = bl.rowGap ?? 6;
this.iconSize = bl.icon ?? 44;
this.iconGap = bl.iconGap ?? 12;
this.st = config.section('shopui.stepper', {}); // shared stepper knobs
this.stepperW = this.st.w ?? 88;
this.priceW = bl.price?.w ?? 64;
this.bl = bl;
// The viewport (the scroll window, under the title) — the list's
// content taller than this is what scrolls.
this.vpX = this.pad;
this.vpY = this.titleH + this.pad;
this.vpW = this.width - this.pad * 2;
this.vpH = Math.max(40, this.height - this.vpY - this.pad);
// --- state -----------------------------------------------------------
this.stock = []; // [{ id, name, price }] (shopFor output)
this.buyQty = new Map(); // goodId -> qty in the pending trade
this.rows = []; // per row: { id, name, desc, price, objs, y0, shown }
this._objs = []; // everything we create (destroy())
this._surface = null; // the scroll surface (drag + wheel)
this._drag = null; // { y } while a press drags the list
// --- geometry (scroll) ----------------------------------------------
this._scroll = 0; // px scrolled from the top
this.maxScroll = 0; // max(0, contentH - vpH), set on refresh
this._buildPanel();
this._buildTitle();
this._buildSurface(); // BEFORE the rows (rows paint above it)
}
// ------------------------------------------------------------------ panel
_buildPanel() {
const p = config.get('shopui.panel', {});
const gr = this.scene.add?.graphics?.();
if (gr) {
this._objs.push(gr);
gr.clear?.();
if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), p.bgAlpha ?? 0.7);
gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), 255);
}
gr.fillRect?.(0, 0, this.width, this.height);
// Parent it to the list (same reason as TradeGrid — scene-level depth
// 0 renders BEHIND the shop video in this build; container children
// inherit the list's depth + translation). Added first = behind rows.
this.add?.(gr);
}
}
_buildTitle() {
const t = this.scene.add?.text?.(
this.width / 2, 8, 'BUY',
{
fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`,
color: config.get('shopui.panel.title.color', '#eaf6ff'),
align: 'center', fontFamily: fontStack('header'), // Ethnocentric — the theme's section face
},
);
if (t) {
t.setOrigin?.(0.5, 0);
this.title = t;
this._objs.push(t);
this.add?.(t);
}
}
// ------------------------------------------------------- scroll surface
/**
* The scroll surface: an (undrawn) interactive rect over the viewport.
* Drag (pointerdown → pointermove → pointerup/out) and wheel scroll the
* list — the MapWindow.js recipe (Phaser 4's input plugin delivers object-
* level pointermove + wheel over the hit area). The row steppers sit ABOVE
* it and stopPropagation() their clicks, so tapping −/+ never drags.
*/
_buildSurface() {
const g = this.scene.add?.graphics?.();
if (!g) return;
this._surface = g;
this._objs.push(g);
this.add?.(g);
if (typeof g.setInteractive !== 'function' || typeof g.on !== 'function') return;
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(this.vpX, this.vpY, this.vpW, this.vpH) : null;
g.setInteractive({
useHandCursor: false,
hitArea: area,
hitAreaCallback: (p, px, py) => (area ? Rect.Contains(p, px, py) : true),
});
g.on('pointerdown', (p) => { this._drag = { y: p?.y ?? 0 }; });
g.on('pointermove', (p) => {
if (this._drag && p && typeof p.y === 'number') {
this.scrollBy(p.y - this._drag.y);
this._drag = { y: p.y };
}
});
g.on('pointerup', () => { this._drag = null; });
g.on('pointerout', () => { this._drag = null; }); // left the list → end drag
g.on('wheel', (p, _dx, dy) => {
// line-mode wheels report small dy (MapWindow's deltaMode check).
const dyPx = (p?.event?.deltaMode ?? 0) === 1 ? dy * 32 : dy;
if (dyPx) this.scrollBy(dyPx);
});
}
// ------------------------------------------------------------------ rows
/**
* Set the list's contents from the shop's stock (Economy.shopFor → buy:
* [{ id, name, price, produced }]). The description is the good's `flavor`
* line (data/goods.json). Resets the selection (a fresh shop view).
*/
refresh(o = {}) {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
this.buyQty = new Map();
this._destroyRows();
for (const s of this.stock) this._addRow(s);
this._layoutRows();
this._clipRows();
return this;
}
_addRow(s) {
const bl = this.bl;
const id = s.id;
const name = s.name ?? id;
const price = Math.round(Number(s.price) || 0);
const flavor = this._flavorOf(id);
const row = { id, name, desc: '', price, objs: {}, y0: 0, shown: true };
// row plate (bg + border) — the whole row's width, in the viewport
const bg = this.scene.add?.graphics?.();
if (bg) { row.objs.bg = bg; this._objs.push(bg); this.add?.(bg); }
// icon (guarded — headless has no texture). Created with a placeholder
// frame, then bound to THIS good's frame via setTexture(key, frame) — the
// TradeGrid recipe (NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
if (this.scene.textures && typeof this.scene.textures.exists === 'function' &&
this.scene.textures.exists(GOODS_TEXTURE_KEY)) {
const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0);
if (icon) {
icon.setOrigin?.(0.5, 0.5);
icon.setScale?.(this.iconSize / 128); // the sheet's frames are 128×128
icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(id));
row.objs.icon = icon; this._objs.push(icon); this.add?.(icon);
}
}
// name + description (left of the price/stepper block)
const textX = this._textX();
const nameEl = this._makeText(
textX, 0, name,
{ fontSize: `${bl.name?.fontSize ?? 14}px`, color: bl.name?.color ?? '#eaf6ff', align: 'left' },
);
if (nameEl) { nameEl.setOrigin?.(0, 0.5); row.objs.name = nameEl; this._objs.push(nameEl); this.add?.(nameEl); }
row.desc = this._fitText(flavor, bl.desc?.fontSize ?? 12);
const descEl = this._makeText(
textX, 0, row.desc,
{ fontSize: `${bl.desc?.fontSize ?? 12}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
);
if (descEl) { descEl.setOrigin?.(0, 0.5); row.objs.desc = descEl; this._objs.push(descEl); this.add?.(descEl); }
// price (right of the text, left of the stepper) — "24 cr"
const priceEl = this._makeText(
0, 0, `${price} cr`,
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: bl.price?.color ?? '#ffc94d', align: 'right' },
);
if (priceEl) { priceEl.setOrigin?.(1, 0.5); row.objs.price = priceEl; this._objs.push(priceEl); this.add?.(priceEl); }
// stepper: − qty + (the qty tap = +1, the old grid's behavior)
const mkStep = (str) => this._makeText(0, 0, str, {
fontSize: `${this.st.fontSize ?? 19}px`,
color: this.st.btn ?? '#7d92c4', align: 'center',
});
const minus = mkStep('−');
const qty = mkStep('0');
const plus = mkStep('+');
const hit = (el) => (el && el.width > 0 ? el.width + 8 : 22);
const bx = this._btnXs();
const stepHit = Math.max(hit(minus), hit(plus), 22) / 2;
if (minus) { row.objs.minus = minus; this._objs.push(minus); this.add?.(minus); }
if (qty) { row.objs.qty = qty; this._objs.push(qty); this.add?.(qty); }
if (plus) { row.objs.plus = plus; this._objs.push(plus); this.add?.(plus); }
// The steppers sit on the scroll surface; stopPropagation keeps a
// stepper tap from starting a list drag (TradeGrid's recipe).
this._bindStep(minus, () => this.bumpBuy(id, -1), bx.minus, stepHit, row);
this._bindStep(qty, () => this.bumpBuy(id, +1), bx.qty, stepHit, row);
this._bindStep(plus, () => this.bumpBuy(id, +1), bx.plus, stepHit, row);
this.rows.push(row);
}
_bindStep(el, fn, x, half, row) {
if (!el || typeof el.setInteractive !== 'function' || typeof el.on !== 'function') return;
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(-half, -half, half * 2, half * 2) : null;
el.setInteractive({
useHandCursor: true,
hitArea: area,
// Dead when the row is scrolled out of the viewport (alpha-0 buttons
// must not be tappable — gate the hit test on row visibility too).
hitAreaCallback: (p, px, py) => (row.shown ? (area ? Rect.Contains(p, px, py) : true) : false),
});
el.on('pointerdown', (p) => {
p?.stopPropagation?.();
if (row.shown) fn();
});
}
// Text block's left edge (after the icon) — shared by name + desc.
_textX() {
return -this.vpW / 2 + this.pad + this.iconSize + this.iconGap;
}
// Stepper button x's (row-local, right end of the row).
_btnXs() {
const right = this.vpW / 2 - this.pad;
return {
plus: right - 11,
qty: right - 44,
minus: right - 77,
price: right - (this.stepperW) - 10, // price's RIGHT edge (origin 1,0.5)
};
}
// Max description width (text block → price), in px. The price reserves
// its full width ("1,240 cr") so the description never collides with it.
_descMaxW() {
return this._btnXs().price - this.priceW - 14 - this._textX();
}
// Truncate `str` to fit ~maxW px at fontSize (deterministic char estimate
// — no text metrics needed; the headless stubs have none).
_fitText(str, fontSize) {
const s = String(str ?? '');
if (!s) return '';
const maxChars = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55)));
if (s.length <= maxChars) return s;
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
}
_flavorOf(id) {
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.flavor ?? '';
}
_makeText(x, y, str, style) {
const t = this.scene.add?.text?.(x, y, str, {
fontFamily: fontStack('body'), // Centauri — the theme's body face
fontSize: style.fontSize, color: style.color, align: style.align,
});
return t ?? null;
}
// ---------------------------------------------------------------- scroll
/** Scroll by `dy` px (clamped to [0, maxScroll]); re-clips the rows. */
scrollBy(dy) {
this.scrollTo(this._scroll + Math.floor(Number(dy) || 0));
return this;
}
scrollTo(v) {
const n = Math.max(0, Math.min(this.maxScroll, Number(v) || 0));
if (n === this._scroll) return this;
this._scroll = n;
this._clipRows();
return this;
}
// Row-based clip: lay rows at (y0 − scroll), hide the ones off-viewport
// (this Phaser build has no camera/mask clip). A row counts as visible
// while it OVERLAPS the viewport, so partial rows fade in as you scroll.
_layoutRows() {
const n = this.rows.length;
const contentH = n * this.rowH + Math.max(0, n - 1) * this.rowGap;
this.maxScroll = Math.max(0, contentH - this.vpH);
this._scroll = Math.max(0, Math.min(this._scroll, this.maxScroll));
for (let i = 0; i < n; i++) {
this.rows[i].y0 = this.vpY + i * (this.rowH + this.rowGap) + this.rowH / 2;
}
}
_clipRows() {
const cx = this.vpX + this.vpW / 2;
for (const row of this.rows) {
const y = row.y0 - this._scroll;
const top = y - this.rowH / 2;
const bottom = y + this.rowH / 2;
row.shown = top < this.vpY + this.vpH && bottom > this.vpY;
const a = row.shown ? 1 : 0;
const o = row.objs;
const tx = cx + this._textX(); // row-local → list-local (row center at cx)
const bx = this._btnXs();
o.bg?.setPosition?.(cx, y);
o.icon?.setPosition?.(cx - this.vpW / 2 + this.pad + this.iconSize / 2, y);
o.name?.setPosition?.(tx, y - this.rowH * 0.18);
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
o.price?.setPosition?.(cx + bx.price, y);
o.minus?.setPosition?.(cx + bx.minus, y);
o.qty?.setPosition?.(cx + bx.qty, y);
o.plus?.setPosition?.(cx + bx.plus, y);
for (const el of Object.values(o)) el?.setAlpha?.(a);
}
this._renderRows();
}
// --------------------------------------------------------- DATA (the core)
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (!id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderRow(id);
this._emitSelChange();
return this;
}
/** { goodId: qty } from the steppers. */
getBuyMap() {
const m = {};
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
return m;
}
hasSelection() {
return this.buyQty.size > 0;
}
clearSelection() {
this.buyQty = new Map();
this._renderRows();
this._emitSelChange();
return this;
}
/** The ShopView hooks this to live-update the preview + Confirm/Clear. */
set onSelectionChange(fn) { this._onSel = fn; }
_emitSelChange() {
if (typeof this._onSel === 'function') this._onSel(this);
}
// --------------------------------------------------------------- render
_renderRows() {
for (const row of this.rows) this._renderRow(row.id);
}
_renderRow(id) {
const row = this.rows.find((r) => r.id === id);
if (!row) return;
const q = this.buyQty.get(id) ?? 0;
const o = row.objs;
if (o.qty && typeof o.qty.setText === 'function') o.qty.setText(String(q));
// in-basket highlight: brighter plate + accent border (the theme's
// selection cyan), the qty text lit.
const sel = q > 0;
const bl = this.bl;
const cx = this.vpX + this.vpW / 2;
const y = row.y0 - this._scroll;
if (o.bg && typeof o.bg.clear === 'function' && typeof o.bg.fillStyle === 'function') {
o.bg.clear?.();
o.bg.fillStyle(_hex(sel ? (bl.rowSelBg ?? '#12283a') : (bl.rowBg ?? '#0e1728')), sel ? (bl.rowSelBgAlpha ?? 0.85) : (bl.rowBgAlpha ?? 0.55));
o.bg.lineStyle?.(sel ? 2 : 1, _hex(sel ? (bl.selected ?? '#00e5ff') : (bl.rowBorder ?? '#1d2e4c')), 255);
o.bg.fillRect?.(cx - this.vpW / 2, y - this.rowH / 2, this.vpW, this.rowH);
}
if (o.qty && typeof o.qty.setColor === 'function') {
o.qty.setColor(toCss(sel ? (this.st.activeColor ?? '#00e5ff') : (this.st.color ?? '#eaf6ff')));
}
}
_destroyRows() {
for (const row of this.rows) for (const el of Object.values(row.objs)) el?.destroy?.();
this.rows = [];
}
// --------------------------------------------------------- animation
/** Slide the panel in from the right (the TradeGrid recipe). */
slideIn() {
const ms = config.get('shopui.animation.slideMs', 340);
const ease = config.get('shopui.animation.ease', 'Cubic.Out');
const off = this.width + 24;
const targetX = this.x; // the final x (set by ShopView.setPosition before this)
const targetY = this.y;
if (this.scene.tweens && typeof this.scene.tweens.add === 'function') {
this.x = targetX + off;
this.y = targetY;
this.scene.tweens.add({ targets: this, x: targetX, y: targetY, duration: ms, ease, delay: 40 });
} else {
this.x = targetX;
}
return this;
}
destroy() {
for (const o of this._objs) o?.destroy?.();
this._objs = [];
this.rows = [];
if (typeof super.destroy === 'function') super.destroy();
}
}
// '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed.
function _hex(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}

View File

@ -1,26 +1,31 @@
/**
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
*
* ┌────────────────────────────────────────────────────────────┐
* │ ┌SELL 4×4┐ [ Confirm ] ┌BUY 4×4┐ │
* │ │ bays │ [ Clear ] │ stock │ │
* │ │ (cargo)│ │ (12–16)│ │
* │ └────────┘ └────────┘ │
* └────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────┐
* │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │
* └────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │
* │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │
* │ │ (cargo)│ │ Crisp, clean… − 0 +│ │
* │ └────────┘ │ [i] Refined Fuel 41cr│ │
* │ │ The lifeblood − 1 +│ │
* │ │ … (scrolls) … │ │
* │ └────────────────────────┘ │
* └────────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │
* └────────────────────────────────────────────────────────────────┘
*
* The two sides are TradeGrids (js/ui/shop/TradeGrid.js):
* · SELL (left) — the player's cargo bays. Tap a filled bay to check it
* off (a bay = 1 unit). Locked bays are the hold's upgrade room.
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper.
* The live NET preview (green ≥ 0, red < 0) sits on the TAB SUB-BAR, right
* of the Buy/Sell + Buyback buttons past a hairline divider (tabs AND the
* pending trade's credit math on one console); Confirm/Clear stay in the
* center. Confirm is disabled while the trade is invalid (can't go negative,
* hold would overflow). SELL→BUY happens atomically (sell to free room for a
* buy).
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
* player's cargo bays — tap a filled bay to check it off (a bay = 1 unit);
* locked bays are the hold's upgrade room.
* The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's
* stock as WIDE ROWS — icon + name + one-line description (the good's
* flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
* full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0)
* sits on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons past a
* hairline divider (tabs AND the pending trade's credit math on one
* console); Confirm/Clear stay in the center. Confirm is disabled while
* the trade is invalid (can't go negative, hold would overflow). SELL→BUY
* happens atomically (sell to free room for a buy).
*
* BUYBACK (session-local only) — the second tab. After the player SELLs
* something, the goods they sold are listed here and can be bought BACK —
@ -41,6 +46,7 @@ import { MenuButton } from '../MenuButton.js';
import { toColor, toCss } from '../../utils/Color.js';
import { fontStack } from '../../utils/Theme.js';
import { TradeGrid } from './TradeGrid.js';
import { BuyList } from './BuyList.js';
export class ShopView extends Phaser.GameObjects.Container {
/**
@ -83,7 +89,7 @@ export class ShopView extends Phaser.GameObjects.Container {
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back
this.sellGrid = null;
this.buyGrid = null;
this.buyList = null;
this._content = null; // the active content container
this._netLabel = null; // the sub-bar NET label (persistent)
this._netValue = null; // the sub-bar NET value (live)
@ -303,7 +309,7 @@ export class ShopView extends Phaser.GameObjects.Container {
}
// ---------------------------------------------------------------------
// CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons)
// CONTENT — the trade panel (SELL grid + BUY list + buttons)
// ---------------------------------------------------------------------
_showTrade() {
const grid = config.get('shopui.grid', {});
@ -314,38 +320,40 @@ export class ShopView extends Phaser.GameObjects.Container {
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
});
const buyGrid = new TradeGrid(this.scene, {
side: 'buy', maxSlots: this.maxSlots,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
});
// The BUY side: a scrollable LIST of stock rows (wide bars — icon +
// name + description + price + −/qty/+ stepper). Sized to the SELL
// grid's height so the two panels align top AND bottom; the list scrolls
// when the stock outgrows the viewport (it always does — 8–16 rows).
const buyW = Math.min(
Number(config.get('shopui.buyList.w')) || 560,
Math.max(440, W - 24 - sellGrid.width - centerW),
);
const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height });
this.sellGrid = sellGrid;
this.buyGrid = buyGrid;
this.buyList = buyList;
// Layout: [SELL grid] [center] [BUY grid]. The panel's TOP edge sits at
// Layout: [SELL grid] [center] [BUY list]. The panel's TOP edge sits at
// this.topY — just under the surface HUD's planet text (upper-left) — so
// the whole panel reads as one unit near the top of the screen (not
// hanging down to the deck/sub-bar).
//
// IMPORTANT: a TradeGrid lays its plate/cells out in local (0,0)→(w,h)
// IMPORTANT: a TradeGrid/BuyList lays its plate out in local (0,0)→(w,h)
// and a Container's (x,y) is a pure TRANSLATION of that local space
// (verified with dev/cbox-test.html) — so the grid's position IS the
// panel's top-left corner. Treating it as a centered origin (topY + h/2)
// drops the panel half a grid lower and clips the bottom rows.
const totalW = sellGrid.width + centerW + buyGrid.width;
// (verified with dev/cbox-test.html) — so the panel's position IS its
// top-left corner. Treating it as a centered origin (topY + h/2) drops
// the panel half a grid lower and clips the bottom rows.
const totalW = sellGrid.width + centerW + buyList.width;
const leftX = Math.max(8, (W - totalW) / 2);
const gridH = sellGrid.height;
sellGrid.setPosition(leftX, this.topY);
const rightX = leftX + sellGrid.width + centerW;
buyGrid.setPosition(rightX, this.topY);
buyList.setPosition(rightX, this.topY);
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
content.setScrollFactor?.(0);
content.add?.(sellGrid);
content.add?.(buyGrid);
content.add?.(buyList);
// center column: Confirm + Clear — vertically centered with the grids,
// so the whole panel is one cohesive block near the top of the screen.
@ -387,14 +395,14 @@ export class ShopView extends Phaser.GameObjects.Container {
// Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
sellGrid.onSelectionChange = () => this._syncPreview();
buyGrid.onSelectionChange = () => this._syncPreview();
buyList.onSelectionChange = () => this._syncPreview();
this._syncPreview();
// Reveal: SELL slides in from the left, BUY from the right.
sellGrid.slideIn();
buyGrid.slideIn();
buyList.slideIn();
}
// ---------------------------------------------------------------------
@ -479,7 +487,7 @@ export class ShopView extends Phaser.GameObjects.Container {
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {},
buyMap: this.buyList ? this.buyList.getBuyMap() : {},
});
}
// buyback — priced at the sale price (buyOverrides), a neutral undo.
@ -509,7 +517,7 @@ export class ShopView extends Phaser.GameObjects.Container {
clear() {
if (this.tab === 'trade') {
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
this.buybackSel = new Map();
}
@ -529,7 +537,7 @@ export class ShopView extends Phaser.GameObjects.Container {
if (this.tab === 'trade') {
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map();
@ -588,7 +596,7 @@ export class ShopView extends Phaser.GameObjects.Container {
}
_hasAnySelection() {
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false);
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false);
return [...this.buybackSel.values()].some((q) => q > 0);
}
@ -618,9 +626,9 @@ export class ShopView extends Phaser.GameObjects.Container {
this._content = null;
}
this.sellGrid?.destroy?.();
this.buyGrid?.destroy?.();
this.buyList?.destroy?.();
this.sellGrid = null;
this.buyGrid = null;
this.buyList = null;
this._confirmBtn = null;
this._clearBtn = null;
}

View File

@ -1,29 +1,36 @@
/**
* TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells.
* TradeGrid — the shop's SELL side: the player's cargo bays as a 4×4 grid.
*
* Two modes, one component:
* · SELL (the left panel) — the grid is the player's CARGO. Each cell is a
* BAY (one good each, data/economy.json → cargo). The first `capacity`
* cells are live bays (filled with a good, or empty); the rest are LOCKED
* (the hold's upgrade room — the ship can't carry there yet). Tapping a
* filled bay checks it off to sell (a bay = 1 unit); the sell quantity for
* a good is just the number of its bays that are checked.
* · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct
* goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy
* quantity is whatever the stepper says.
* ┌────────────┐
* │ SELL │
* │ ┌────┐┌────┐ │
* │ │icon││icon│ │ filled bay → good icon
* │ └────┘└────┘ │ empty bay → dim plate
* │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room)
* │ │ ││lock│ │ checked → cyan selection ring (1 unit queued)
* │ └────┘└────┘ │
* └────────────┘
*
* Cells show the good's ICON only — the name + price are revealed in a
* shared hover tooltip (pointerover the cell; one tooltip per grid).
* A BAY is the unit (not a good): each filled bay holds 1 unit of its good
* and checking it off queues 1 unit for sale. The first `capacity` bays are
* live (filled with a good, or empty); the rest are LOCKED — the hold's
* upgrade room (the ship can't carry there yet). Hovering a cell reveals
* the good's name + sell price in a shared tooltip (icon-only cells — no
* text over the icon).
*
* 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
* (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade
* (The BUY side is no longer a grid — the shop's stock is now a scrollable
* list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side
* by side.)
*
* The PRICING lives in js/economy/Economy.js (sellPrice — the fixed
* per-(good, shop) price). This grid only shows the price and tracks the
* SELECTION (which bays are checked off). The ShopView
* (js/ui/shop/ShopView.js) composes it with the BuyList, runs the trade
* preview (Economy.tradePreview) and applies it to the ship.
*
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data
* so dev/shop-ui.test.mjs can drive it with no scene.
* selection LOGIC (toggleBay / getSellMap) is plain data so
* dev/shop-ui.test.mjs can drive it with no scene.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
@ -33,21 +40,19 @@ import { fontStack } from '../../utils/Theme.js';
export class TradeGrid extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { side: 'sell'|'buy', maxSlots, capacity,
* cls, systemId, galaxy, sellUnit(id), buyUnit(id) }
* sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so
* the grid can show "sell for N" / "buy for N" without re-deriving.
* @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) }
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
* can show "sell for N" without re-deriving.
*/
constructor(scene, o) {
super(scene, 0, 0);
this.scene = scene;
this.side = o.side;
this.side = 'sell'; // SELL-only now (the BUY side moved to BuyList.js)
this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16));
this.capacity = Math.max(0, Math.floor(o.capacity ?? 0));
this.cls = o.cls;
this.systemId = o.systemId;
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
this.buyUnit = (id) => o.buyUnit?.(id) ?? 0;
const g = config.get('shopui.grid', {});
this.cols = g.cols ?? 4;
@ -66,19 +71,16 @@ export class TradeGrid extends Phaser.GameObjects.Container {
this.height = this.titleH + this.cellH + pad * 2;
// --- state -----------------------------------------------------------
// SELL: bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity. BUY: stock entries { id, name, price }, length =
// stock size (≤ maxSlots).
// bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity (locked bays are the hold's upgrade room).
this.bays = [];
this.stock = [];
this.sellSel = new Set(); // sell: checked bay indices
this.buyQty = new Map(); // buy: goodId -> qty
this.sellSel = new Set(); // checked bay indices (the pending sell)
// --- objects ---------------------------------------------------------
this._objs = []; // everything we create (destroy())
this.plate = null; // the semi-transparent panel background
this.title = null;
this.cells = []; // per cell: { plate, sel, icon, label, stepQty, stepMinus, stepPlus }
this.cells = []; // per cell: { plate, sel, icon, label }
this.tip = null; // shared hover tooltip (name + price) — the
this.tipPlate = null; // per-cell name/price text is gone; hovering
this.tipName = null; // a cell reveals it beside the icon
@ -122,7 +124,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
const gr = this.plate;
if (!gr || typeof gr.clear !== 'function') return;
const p = this.plateStyle ?? {};
const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7);
const a = p.bgAlpha ?? 0.7;
gr.clear?.();
if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a);
@ -133,8 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
_buildTitle() {
const t = this.scene.add?.text?.(
this.width / 2, 8,
this.side === 'sell' ? 'SELL' : 'BUY',
this.width / 2, 8, 'SELL',
{ fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center', fontFamily: fontStack('header') }, // Ethnocentric — the theme's section face
);
if (t) {
@ -178,37 +179,11 @@ export class TradeGrid extends Phaser.GameObjects.Container {
});
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') {
const mk = (dx, str) => {
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;
};
const minus = mk(-26, '−');
const qty = mk(0, '0');
const plus = mk(26, '+');
if (minus) cell.objects.push(minus);
if (qty) { cell.stepQty = qty; cell.objects.push(qty); }
if (plus) cell.objects.push(plus);
cell.stepMinus = minus; cell.stepPlus = plus;
const hit = [-18, -14, 36, 28];
this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit);
this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
}
// interaction: the plate is the whole cell's surface — tap (SELL: toggle
// the bay) + hover (either side: reveal the name/price tooltip).
// interaction: the plate is the whole cell's surface — tap (toggle the
// bay) + hover (reveal the name/price tooltip).
const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot];
if (this._interactive(cell.plate, hit)) {
if (this.side === 'sell') {
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
}
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
cell.plate.on('pointerover', () => this.hoverCell(i));
cell.plate.on('pointerout', () => this.unhover());
}
@ -218,15 +193,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
if (cell.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
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);
}
}
// A tap only works when the object supports interactivity (real Phaser); a
// headless fake returns no-op objects, and the test drives the logic
// methods (toggleBay / bumpBuy) directly.
// method (toggleBay) directly.
_bindTap(obj, fn, hitRect) {
if (!this._interactive(obj, hitRect)) return;
obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
@ -252,36 +224,26 @@ export class TradeGrid extends Phaser.GameObjects.Container {
// --- DATA (the testable core) -----------------------------------------
/**
* Set the grid's contents. SELL: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list
* ({ id, name, price } | { id }). Resets the selection (a fresh shop view).
* Set the grid's contents: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). Resets the selection (a fresh shop
* view).
*/
refresh(o = {}) {
this.unhover();
if (this.side === 'sell') {
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set();
} else {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
this.buyQty = new Map();
}
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set();
this._renderAll();
return this;
}
_stockIdAt(i) {
return this.stock[i]?.id ?? null;
}
_bayIdAt(i) {
return this.bays[i] ?? null;
}
/** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
toggleBay(i) {
if (this.side !== 'sell') return this;
if (i < 0 || i >= this.capacity) return this; // locked bay
const id = this._bayIdAt(i);
if (!id) return this; // empty bay
@ -292,9 +254,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this;
}
/** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */
/** Check / uncheck by good id (the Nth unchecked/checked bay of it). */
toggleGood(id) {
if (this.side !== 'sell') return this;
const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity);
if (idx >= 0) this.toggleBay(idx);
else {
@ -305,20 +266,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this;
}
/** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (this.side !== 'buy' || !id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderStockRow(id);
this._emitSelChange();
return this;
}
/** SELL: { goodId: qty } from the checked bays. */
/** { goodId: qty } from the checked bays. */
getSellMap() {
const m = {};
for (const i of this.sellSel) {
@ -328,20 +276,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return m;
}
/** BUY: { goodId: qty } from the steppers. */
getBuyMap() {
const m = {};
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
return m;
}
hasSelection() {
return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0;
return this.sellSel.size > 0;
}
clearSelection() {
if (this.side === 'sell') this.sellSel = new Set();
else this.buyQty = new Map();
this.sellSel = new Set();
this._renderAll();
this._emitSelChange();
return this;
@ -368,19 +308,14 @@ export class TradeGrid extends Phaser.GameObjects.Container {
let goodId = null;
let locked = false;
if (this.side === 'sell') {
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
} else {
goodId = this._stockIdAt(i);
locked = false;
}
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
const usable = !locked && !!goodId;
// plate border (selected sell bays get the cyan ring)
if (cell.plate && typeof cell.plate.clear === 'function') {
const selected = this.side === 'sell' && this.sellSel.has(i);
const selected = this.sellSel.has(i);
cell.plate.clear?.();
if (typeof cell.plate.fillStyle === 'function') {
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3));
@ -389,46 +324,28 @@ export class TradeGrid extends Phaser.GameObjects.Container {
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
}
// 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.
// icon — the cell's only content for a usable bay (the name+price live
// in the hover tooltip). Centered.
if (cell.icon) {
const show = usable;
if (show) {
cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId));
cell.icon.setAlpha?.(1);
cell.icon.setScale?.(this.sl.iconScale ?? 0.62);
cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y);
cell.icon.setPosition?.(x, y);
} else {
cell.icon.setAlpha?.(0);
}
}
// state label — locked bays / empty slots only; usable cells stay clean
// state label — locked bays / empty bays 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.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')));
cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3));
}
// buy stepper
if (this.side === 'buy' && cell.stepQty) {
const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0;
if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q));
const on = usable;
for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0);
}
}
_renderStockRow(id) {
for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i);
}
_goodName(id) {
if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id;
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.name ?? id;
}
@ -462,7 +379,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
};
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'));
const price = mk(this.tipPriceSize, this.sl.price?.sell ?? '#7ce8a4');
if (!plate && !name && !price) return;
if (plate) this._objs.push(plate);
if (name) this._objs.push(name);
@ -501,17 +418,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
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);
}
const goodId = i < this.capacity ? this._bayIdAt(i) : null;
if (!goodId) return this.unhover();
const unit = Math.round(this.side === 'sell' ? this.sellUnit(goodId) : this.buyUnit(goodId));
const unit = Math.round(this.sellUnit(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`);
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`sell 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;