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, "enabled": true,
"tabs": { "tabs": {
@ -13,7 +13,7 @@
}, },
"panel": { "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", "bgColor": "#0a1120",
"bgAlpha": 0.70, "bgAlpha": 0.70,
"border": "#22405f", "border": "#22405f",
@ -22,27 +22,46 @@
}, },
"grid": { "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, "cols": 4, "rows": 4,
"slot": 96, "gap": 10, "slot": 96, "gap": 10,
"centerW": 208 "centerW": 208
}, },
"slot": { "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", "bgColor": "#0e1728",
"border": "#1d2e4c", "border": "#1d2e4c",
"selected": "#00e5ff", "selected": "#00e5ff",
"lockAlpha": 0.30, "lockAlpha": 0.30,
"iconScale": 0.62, "iconScale": 0.62,
"name": { "fontSize": 11, "color": "#eaf6ff" }, "name": { "fontSize": 11, "color": "#eaf6ff" },
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" }, "price": { "fontSize": 12, "sell": "#7ce8a4" },
"emptyLabel": "—", "emptyLabel": "—",
"lockLabel": "locked" "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": { "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", "bgColor": "#0a1120",
"bgAlpha": 0.96, "bgAlpha": 0.96,
"border": "#22405f", "border": "#22405f",
@ -53,8 +72,9 @@
}, },
"stepper": { "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, "fontSize": 19,
"w": 88,
"color": "#eaf6ff", "color": "#eaf6ff",
"btn": "#7d92c4", "btn": "#7d92c4",
"activeColor": "#00e5ff" "activeColor": "#00e5ff"
@ -86,7 +106,7 @@
}, },
"animation": { "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, "slideMs": 340,
"staggerMs": 26, "staggerMs": 26,
"ease": "Cubic.Out" "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 // 1) structure: the old per-cell name/price text is GONE from usable cells
const sellCell = sv.sellGrid.cells[0]; const sellCell = sv.sellGrid.cells[0];
const buyCell = sv.buyGrid.cells[0];
const struct = { const struct = {
sellCellName: sellCell.name === undefined ? 'undefined' : 'present', sellCellName: sellCell.name === undefined ? 'undefined' : 'present',
sellCellPrice: sellCell.price === 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 }, 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 } }, 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, 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 }; out.tooltip = { struct };
@ -142,21 +141,24 @@ const bounds = (o) => {
const tipSellA = sv.sellGrid.hoverCell(0); const tipSellA = sv.sellGrid.hoverCell(0);
out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 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 // 3) BUY side: the WIDE ROW carries name + description + price INLINE (no
const buyIdxAbove = 6; // row 1, col 2 (stock is filled there) // tooltip — the row is wide enough).
const bc = cellWorldCenter(sv.buyGrid, buyIdxAbove); const row0 = sv.buyList.rows[0];
out.tooltip.mouseAbove = { cell: bc, move: mouseMove(bc.x, bc.y), state: tipState(sv.buyGrid) }; 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 // 4) move the pointer OFF the panel → pointerout hides the tip
const buyIdxBelow = 0; const off = toClient(Math.max(4, sv.buyList.x - 30), 360);
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 })); game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window }));
await sleep(150); 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 // 6) end with the SELL tooltip visible for the screenshot
sv.sellGrid.hoverCell(0); sv.sellGrid.hoverCell(0);
@ -166,7 +168,7 @@ const bounds = (o) => {
out.topY = sv.topY ?? null; 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.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.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; window.__SHOP_SHOT__ = out;
console.info('[shop-shot] OK:', JSON.stringify(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 * CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
* verified in the browser) — it tests the LOGIC the player feels: * verified in the browser) — it tests the LOGIC the player feels:
* *
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays, * - CONSTRUCT — the view builds the SELL grid + the BUY list over a real
* 12–16 buy slots) and starts on the Buy/Sell tab. * 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; * - 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. * 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 * - BUY — stepping a stock row buys that good at the shop's buy price;
* spends the credits and fills the bays. * 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 * - 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 * credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm. * 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). // 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.tab, null, 'starts unopened (entry state)');
assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)'); 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._tabTrade, 'the Buy/Sell tab is present in the entry state');
assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)'); assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)');
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale'); 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. // Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
view.setTab('trade'); view.setTab('trade');
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab'); 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 // 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 // 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 // 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 > 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.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.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots'); assert.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid 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.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet'); 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 { view } = makeView(ship);
const goodById = (id) => goods.find((g) => g.id === id); 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 // setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render). // corrupted the texture source and crashed the real WebGL render).
let checkedIcons = 0; let checkedIcons = 0;
for (let i = 0; i < view.buyGrid.cells.length; i++) { for (const row of view.buyList.rows) {
const cell = view.buyGrid.cells[i]; const idx = goodById(row.id)?.icon;
const id = view.buyGrid.stock[i]?.id; assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
if (!id) continue; assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
const idx = goodById(id)?.icon; assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
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})`);
checkedIcons += 1; 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 // Every tappable element must be interactive WITH a hitAreaCallback — a bare
// setInteractive() leaves no callback and crashes Phaser v4's input (the // 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. // real hitArea/hitAreaCallback path is exercised.
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback'); 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.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 // The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position (the ShopView centers // tween targets each grid's set position (the ShopView centers
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels // [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. // 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.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.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.buyGrid.x, 'the panels do not overlap at the origin'); 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'); 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 { view } = makeView(ship);
const nameA = goods.find((g) => g.id === A).name; const nameA = goods.find((g) => g.id === A).name;
const sellA = E.sellPrice(A, cls, sysId, galaxy); const sellA = E.sellPrice(A, cls, sysId, galaxy);
const buyId = view.buyGrid.stock[0]?.id; const buyId = view.buyList.stock[0]?.id;
const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name; const nameB = view.buyList.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
const buyB = E.buyPrice(buyId, cls, sysId, galaxy); const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
// Cells no longer paint their own name/price text — the info lives in the // 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.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down'); assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
// BUY side: name + "buy for N cr". // BUY side: no tooltip — the WIDE ROW carries name + description + price
view.buyGrid.hoverCell(0); // inline (the row is wide enough, unlike the icon-only sell cells).
assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip'); const row0 = view.buyList.rows[0];
assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name'); assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline');
assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`); 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): // THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons. // 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(/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.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.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'); 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('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); const buyC = E.buyPrice(C, cls, sysId, galaxy);
assert.ok(buyC > 0, 'the buy price is positive'); assert.ok(buyC > 0, 'the buy price is positive');
view.buyGrid.bumpBuy(C, 2); view.buyList.bumpBuy(C, 2);
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C'); assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
let pv = view.preview(); let pv = view.preview();
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price'); assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
assert.equal(pv.net, -buyC * 2, '… net = the spend'); 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('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 =='); 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 broke = makeShip(0);
const buyC = E.buyPrice(C, cls, sysId, galaxy); const buyC = E.buyPrice(C, cls, sysId, galaxy);
const { view: vb } = makeView(broke); const { view: vb } = makeView(broke);
vb.buyGrid.bumpBuy(C, 1); vb.buyList.bumpBuy(C, 1);
let pv = vb.preview(); let pv = vb.preview();
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID'); assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits'); 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); const full = makeShip(100000);
full.cargo.add([A, B, A, B]); // 4 bays, no room full.cargo.add([A, B, A, B]); // 4 bays, no room
const { view: vf } = makeView(full); const { view: vf } = makeView(full);
vf.buyGrid.bumpBuy(C, 1); vf.buyList.bumpBuy(C, 1);
pv = vf.preview(); pv = vf.preview();
assert.equal(pv.valid, false, 'buying with a full hold is INVALID'); assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full'); 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/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/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/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. | | `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`. | | `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). | | `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 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** 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** **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), 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 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 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 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): # 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): # after clicking Buy/Sell — the trade panel reveals (middle of the screen):
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ ┌SELL 4×4┐ [ CONFIRM ] ┌BUY 4×4┐ │ │ ┌SELL 4×4┐ [ CONFIRM ] ┌──────────────────────────────┐ │
│ │ bays │ [ CLEAR ] │ stock │ │ │ │ bays │ [ CLEAR ] │[i] Fresh Produce 24 cr − 0 +│ │
│ │(cargo) │ │(12–16) │ │ │ │(cargo) │ │ Crisp, clean, alive… │ │
│ └────────┘ └────────┘ │ │ └────────┘ │[i] Refined Fuel 41 cr − 1 +│ │
│ │ The lifeblood of the fleet │ │
│ │ … (scrolls) … │ │
│ └──────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │ │ ┌──────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ NET +420 cr │ ← sub-bar │ │ │ 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 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 — 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. 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 Confirm is **disabled** (grayed) while the trade is invalid (can't go negative, hold would
overflow), and both appear once you start picking. 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 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 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. 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 - **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`) as a
cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room). **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. - **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 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). 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 list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
against fixed regional prices, never against your own history. 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 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 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). 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. * ShopView — the buy/sell (and buyback) screen over the shop clip video.
* *
* ┌────────────────────────────────────────────────────────────┐ * ┌────────────────────────────────────────────────────────────────┐
* │ ┌SELL 4×4┐ [ Confirm ] ┌BUY 4×4┐ │ * │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │
* │ │ bays │ [ Clear ] │ stock │ │ * │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │
* │ │ (cargo)│ │ (12–16)│ │ * │ │ (cargo)│ │ Crisp, clean… − 0 +│ │
* │ └────────┘ └────────┘ │ * │ └────────┘ │ [i] Refined Fuel 41cr│ │
* └────────────────────────────────────────────────────────────┘ * │ │ The lifeblood − 1 +│ │
* ┌────────────────────────────────────────────────────────────┐ * │ │ … (scrolls) … │ │
* │ └────────────────────────┘ │
* └────────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │ * │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │
* └────────────────────────────────────────────────────────────┘ * └────────────────────────────────────────────────────────────────┘
* *
* The two sides are TradeGrids (js/ui/shop/TradeGrid.js): * The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
* · SELL (left) — the player's cargo bays. Tap a filled bay to check it * player's cargo bays — tap a filled bay to check it off (a bay = 1 unit);
* off (a bay = 1 unit). Locked bays are the hold's upgrade room. * locked bays are the hold's upgrade room.
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper. * The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's
* The live NET preview (green ≥ 0, red < 0) sits on the TAB SUB-BAR, right * stock as WIDE ROWS — icon + name + one-line description (the good's
* of the Buy/Sell + Buyback buttons past a hairline divider (tabs AND the * flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
* pending trade's credit math on one console); Confirm/Clear stay in the * full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0)
* center. Confirm is disabled while the trade is invalid (can't go negative, * sits on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons past a
* hold would overflow). SELL→BUY happens atomically (sell to free room for a * hairline divider (tabs AND the pending trade's credit math on one
* buy). * 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 * BUYBACK (session-local only) — the second tab. After the player SELLs
* something, the goods they sold are listed here and can be bought BACK — * 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 { toColor, toCss } from '../../utils/Color.js';
import { fontStack } from '../../utils/Theme.js'; import { fontStack } from '../../utils/Theme.js';
import { TradeGrid } from './TradeGrid.js'; import { TradeGrid } from './TradeGrid.js';
import { BuyList } from './BuyList.js';
export class ShopView extends Phaser.GameObjects.Container { 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.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back this.buybackSel = new Map(); // goodId -> qty to buy back
this.sellGrid = null; this.sellGrid = null;
this.buyGrid = null; this.buyList = null;
this._content = null; // the active content container this._content = null; // the active content container
this._netLabel = null; // the sub-bar NET label (persistent) this._netLabel = null; // the sub-bar NET label (persistent)
this._netValue = null; // the sub-bar NET value (live) 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() { _showTrade() {
const grid = config.get('shopui.grid', {}); 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, side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
cls: this.cls, systemId: this.systemId, cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy), 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.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 // 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 // the whole panel reads as one unit near the top of the screen (not
// hanging down to the deck/sub-bar). // 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 // 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 // (verified with dev/cbox-test.html) — so the panel's position IS its
// panel's top-left corner. Treating it as a centered origin (topY + h/2) // top-left corner. Treating it as a centered origin (topY + h/2) drops
// drops the panel half a grid lower and clips the bottom rows. // the panel half a grid lower and clips the bottom rows.
const totalW = sellGrid.width + centerW + buyGrid.width; const totalW = sellGrid.width + centerW + buyList.width;
const leftX = Math.max(8, (W - totalW) / 2); const leftX = Math.max(8, (W - totalW) / 2);
const gridH = sellGrid.height; const gridH = sellGrid.height;
sellGrid.setPosition(leftX, this.topY); sellGrid.setPosition(leftX, this.topY);
const rightX = leftX + sellGrid.width + centerW; 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); const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
content.setScrollFactor?.(0); content.setScrollFactor?.(0);
content.add?.(sellGrid); content.add?.(sellGrid);
content.add?.(buyGrid); content.add?.(buyList);
// center column: Confirm + Clear — vertically centered with the grids, // center column: Confirm + Clear — vertically centered with the grids,
// so the whole panel is one cohesive block near the top of the screen. // 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. // Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); 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(); sellGrid.onSelectionChange = () => this._syncPreview();
buyGrid.onSelectionChange = () => this._syncPreview(); buyList.onSelectionChange = () => this._syncPreview();
this._syncPreview(); this._syncPreview();
// Reveal: SELL slides in from the left, BUY from the right. // Reveal: SELL slides in from the left, BUY from the right.
sellGrid.slideIn(); 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, cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits, cargo, capacity, credits,
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {}, 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. // buyback — priced at the sale price (buyOverrides), a neutral undo.
@ -509,7 +517,7 @@ export class ShopView extends Phaser.GameObjects.Container {
clear() { clear() {
if (this.tab === 'trade') { if (this.tab === 'trade') {
this.sellGrid?.clearSelection(); this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection(); this.buyList?.clearSelection();
} else { } else {
this.buybackSel = new Map(); this.buybackSel = new Map();
} }
@ -529,7 +537,7 @@ export class ShopView extends Phaser.GameObjects.Container {
if (this.tab === 'trade') { if (this.tab === 'trade') {
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name); for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
this.sellGrid?.clearSelection(); this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection(); this.buyList?.clearSelection();
} else { } else {
for (const b of pv.buy) this._reduceSold(b.id, b.qty); for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map(); this.buybackSel = new Map();
@ -588,7 +596,7 @@ export class ShopView extends Phaser.GameObjects.Container {
} }
_hasAnySelection() { _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); return [...this.buybackSel.values()].some((q) => q > 0);
} }
@ -618,9 +626,9 @@ export class ShopView extends Phaser.GameObjects.Container {
this._content = null; this._content = null;
} }
this.sellGrid?.destroy?.(); this.sellGrid?.destroy?.();
this.buyGrid?.destroy?.(); this.buyList?.destroy?.();
this.sellGrid = null; this.sellGrid = null;
this.buyGrid = null; this.buyList = null;
this._confirmBtn = null; this._confirmBtn = null;
this._clearBtn = 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 * │ SELL │
* 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 * │ │icon││icon│ │ filled bay → good icon
* (the hold's upgrade room — the ship can't carry there yet). Tapping a * │ └────┘└────┘ │ empty bay → dim plate
* filled bay checks it off to sell (a bay = 1 unit); the sell quantity for * │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room)
* a good is just the number of its bays that are checked. * │ │ ││lock│ │ checked → cyan selection ring (1 unit queued)
* · 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.
* *
* Cells show the good's ICON only — the name + price are revealed in a * A BAY is the unit (not a good): each filled bay holds 1 unit of its good
* shared hover tooltip (pointerover the cell; one tooltip per grid). * 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 * (The BUY side is no longer a grid — the shop's stock is now a scrollable
* per-(good, shop) price). This component only shows the price and tracks the * list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side
* SELECTION (which bays to sell / how many of each to buy). The ShopView * by side.)
* (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade *
* 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. * preview (Economy.tradePreview) and applies it to the ship.
* *
* Headless-safe: the Phaser rendering is best-effort (guarded), and the * Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data * selection LOGIC (toggleBay / getSellMap) is plain data so
* so dev/shop-ui.test.mjs can drive it with no scene. * dev/shop-ui.test.mjs can drive it with no scene.
*/ */
import Phaser from '../../vendor/phaser.js'; import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js'; import { config } from '../../config/Config.js';
@ -33,21 +40,19 @@ import { fontStack } from '../../utils/Theme.js';
export class TradeGrid extends Phaser.GameObjects.Container { export class TradeGrid extends Phaser.GameObjects.Container {
/** /**
* @param {object} scene — the SurfaceScene (or a headless fake). * @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { side: 'sell'|'buy', maxSlots, capacity, * @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) }
* cls, systemId, galaxy, sellUnit(id), buyUnit(id) } * sellUnit is the per-unit price lookup (from the ShopView) so the grid
* sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so * can show "sell for N" without re-deriving.
* the grid can show "sell for N" / "buy for N" without re-deriving.
*/ */
constructor(scene, o) { constructor(scene, o) {
super(scene, 0, 0); super(scene, 0, 0);
this.scene = scene; 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.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16));
this.capacity = Math.max(0, Math.floor(o.capacity ?? 0)); this.capacity = Math.max(0, Math.floor(o.capacity ?? 0));
this.cls = o.cls; this.cls = o.cls;
this.systemId = o.systemId; this.systemId = o.systemId;
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0; this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
this.buyUnit = (id) => o.buyUnit?.(id) ?? 0;
const g = config.get('shopui.grid', {}); const g = config.get('shopui.grid', {});
this.cols = g.cols ?? 4; this.cols = g.cols ?? 4;
@ -66,19 +71,16 @@ export class TradeGrid extends Phaser.GameObjects.Container {
this.height = this.titleH + this.cellH + pad * 2; this.height = this.titleH + this.cellH + pad * 2;
// --- state ----------------------------------------------------------- // --- state -----------------------------------------------------------
// SELL: bays in fill order (goodId | null), length = maxSlots; usable = // bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity. BUY: stock entries { id, name, price }, length = // index < capacity (locked bays are the hold's upgrade room).
// stock size (≤ maxSlots).
this.bays = []; this.bays = [];
this.stock = []; this.sellSel = new Set(); // checked bay indices (the pending sell)
this.sellSel = new Set(); // sell: checked bay indices
this.buyQty = new Map(); // buy: goodId -> qty
// --- objects --------------------------------------------------------- // --- objects ---------------------------------------------------------
this._objs = []; // everything we create (destroy()) this._objs = []; // everything we create (destroy())
this.plate = null; // the semi-transparent panel background this.plate = null; // the semi-transparent panel background
this.title = null; 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.tip = null; // shared hover tooltip (name + price) — the
this.tipPlate = null; // per-cell name/price text is gone; hovering this.tipPlate = null; // per-cell name/price text is gone; hovering
this.tipName = null; // a cell reveals it beside the icon 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; const gr = this.plate;
if (!gr || typeof gr.clear !== 'function') return; if (!gr || typeof gr.clear !== 'function') return;
const p = this.plateStyle ?? {}; 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?.(); gr.clear?.();
if (typeof gr.fillStyle === 'function') { if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a); gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a);
@ -133,8 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
_buildTitle() { _buildTitle() {
const t = this.scene.add?.text?.( const t = this.scene.add?.text?.(
this.width / 2, 8, this.width / 2, 8, 'SELL',
this.side === 'sell' ? 'SELL' : '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 { 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) { 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); } 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) // interaction: the plate is the whole cell's surface — tap (toggle the
if (this.side === 'buy') { // bay) + hover (reveal the name/price tooltip).
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).
const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot]; const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot];
if (this._interactive(cell.plate, hit)) { 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('pointerover', () => this.hoverCell(i));
cell.plate.on('pointerout', () => this.unhover()); 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.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon); if (cell.icon) this.add?.(cell.icon);
if (cell.label) this.add?.(cell.label); 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 // A tap only works when the object supports interactivity (real Phaser); a
// headless fake returns no-op objects, and the test drives the logic // headless fake returns no-op objects, and the test drives the logic
// methods (toggleBay / bumpBuy) directly. // method (toggleBay) directly.
_bindTap(obj, fn, hitRect) { _bindTap(obj, fn, hitRect) {
if (!this._interactive(obj, hitRect)) return; if (!this._interactive(obj, hitRect)) return;
obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); }); obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
@ -252,36 +224,26 @@ export class TradeGrid extends Phaser.GameObjects.Container {
// --- DATA (the testable core) ----------------------------------------- // --- DATA (the testable core) -----------------------------------------
/** /**
* Set the grid's contents. SELL: `cargo` is the filled-bay id list (order * Set the grid's contents: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list * preserved — bay i holds cargo[i]). Resets the selection (a fresh shop
* ({ id, name, price } | { id }). Resets the selection (a fresh shop view). * view).
*/ */
refresh(o = {}) { refresh(o = {}) {
this.unhover(); this.unhover();
if (this.side === 'sell') {
const cargo = Array.isArray(o.cargo) ? o.cargo : []; const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = []; this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null); for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set(); 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();
}
this._renderAll(); this._renderAll();
return this; return this;
} }
_stockIdAt(i) {
return this.stock[i]?.id ?? null;
}
_bayIdAt(i) { _bayIdAt(i) {
return this.bays[i] ?? null; 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) { toggleBay(i) {
if (this.side !== 'sell') return this;
if (i < 0 || i >= this.capacity) return this; // locked bay if (i < 0 || i >= this.capacity) return this; // locked bay
const id = this._bayIdAt(i); const id = this._bayIdAt(i);
if (!id) return this; // empty bay if (!id) return this; // empty bay
@ -292,9 +254,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this; 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) { toggleGood(id) {
if (this.side !== 'sell') return this;
const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity); const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity);
if (idx >= 0) this.toggleBay(idx); if (idx >= 0) this.toggleBay(idx);
else { else {
@ -305,20 +266,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this; return this;
} }
/** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */ /** { goodId: qty } from the checked bays. */
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. */
getSellMap() { getSellMap() {
const m = {}; const m = {};
for (const i of this.sellSel) { for (const i of this.sellSel) {
@ -328,20 +276,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return m; 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() { hasSelection() {
return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0; return this.sellSel.size > 0;
} }
clearSelection() { clearSelection() {
if (this.side === 'sell') this.sellSel = new Set(); this.sellSel = new Set();
else this.buyQty = new Map();
this._renderAll(); this._renderAll();
this._emitSelChange(); this._emitSelChange();
return this; return this;
@ -368,19 +308,14 @@ export class TradeGrid extends Phaser.GameObjects.Container {
let goodId = null; let goodId = null;
let locked = false; let locked = false;
if (this.side === 'sell') {
goodId = this._bayIdAt(i); goodId = this._bayIdAt(i);
locked = i >= this.capacity; locked = i >= this.capacity;
} else {
goodId = this._stockIdAt(i);
locked = false;
}
const usable = !locked && !!goodId; const usable = !locked && !!goodId;
// plate border (selected sell bays get the cyan ring) // plate border (selected sell bays get the cyan ring)
if (cell.plate && typeof cell.plate.clear === 'function') { 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?.(); cell.plate.clear?.();
if (typeof cell.plate.fillStyle === 'function') { if (typeof cell.plate.fillStyle === 'function') {
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3)); 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); 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 // icon — the cell's only content for a usable bay (the name+price live
// name+price live in the hover tooltip). Centered; the BUY side lifts it // in the hover tooltip). Centered.
// a touch so the stepper under it reads as a row of its own.
if (cell.icon) { if (cell.icon) {
const show = usable; const show = usable;
if (show) { if (show) {
cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId)); cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId));
cell.icon.setAlpha?.(1); cell.icon.setAlpha?.(1);
cell.icon.setScale?.(this.sl.iconScale ?? 0.62); cell.icon.setScale?.(this.sl.iconScale ?? 0.62);
cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y); cell.icon.setPosition?.(x, y);
} else { } else {
cell.icon.setAlpha?.(0); 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') { if (cell.label && typeof cell.label.setText === 'function') {
cell.label.setText(usable cell.label.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')));
? ''
: (this.side === 'sell'
? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))
: (sl.emptyLabel ?? '—')));
cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3)); 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) { _goodName(id) {
if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id;
const list = config.get('goods.goods', []); const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.name ?? id; 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 plate = this.scene.add?.graphics?.() ?? null;
const name = mk(this.tipNameSize, this.sl.name?.color ?? '#eaf6ff'); 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 && !name && !price) return;
if (plate) this._objs.push(plate); if (plate) this._objs.push(plate);
if (name) this._objs.push(name); if (name) this._objs.push(name);
@ -501,17 +418,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
hoverCell(i) { hoverCell(i) {
const cell = this.cells[i]; const cell = this.cells[i];
if (!cell || !this.tip) return this; if (!cell || !this.tip) return this;
let goodId = null; const goodId = i < this.capacity ? this._bayIdAt(i) : null;
if (this.side === 'sell') {
if (i < this.capacity) goodId = this._bayIdAt(i);
} else {
goodId = this._stockIdAt(i);
}
if (!goodId) return this.unhover(); 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.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) // size from the text metrics (real Phaser or the test stubs)
const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18; const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18;