diff --git a/data/shopui.json b/data/shopui.json index a3e150a..da04321 100644 --- a/data/shopui.json +++ b/data/shopui.json @@ -1,5 +1,5 @@ { - "_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), Confirm/Clear sit in the center column, the live NET-credits readout rides on the TAB SUB-BAR (right of the Buy/Sell + Buyback buttons, past a hairline divider), and the tab sub-bar toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.", + "_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the SELL grid (the player's cargo bays) sits on the LEFT, the BUY list (the shop's stock, as a scrollable list of wide rows) on the RIGHT, Confirm/Clear sit in the center column, the live NET-credits readout rides on the TAB SUB-BAR (right of the Buy/Sell + Buyback buttons, past a hairline divider), and the tab sub-bar toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. SELL CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. BUY ROWS carry their name + description + price INLINE (no tooltip — the row is wide enough). GEOMETRY (slot/gap/grid/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.", "enabled": true, "tabs": { @@ -13,7 +13,7 @@ }, "panel": { - "_comment": "The semi-transparent panel behind each side's grid — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.", + "_comment": "The semi-transparent panel behind each side (the SELL grid's bays / the BUY list's rows) — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.", "bgColor": "#0a1120", "bgAlpha": 0.70, "border": "#22405f", @@ -22,27 +22,46 @@ }, "grid": { - "_comment": "The 4×4 grid shared by both sides (data/economy.json → cargo.maxSlots = 16 slots). SELL: the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). BUY: the shop's stock (12–16) fills the first N, the rest are empty.", + "_comment": "The SELL side's 4×4 bay grid (data/economy.json → cargo.maxSlots = 16 slots): the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). `centerW` is the center column (Confirm/Clear) shared by the trade-panel layout. (The BUY side is a list now — → buyList.)", "cols": 4, "rows": 4, "slot": 96, "gap": 10, "centerW": 208 }, "slot": { - "_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which now fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors still apply — they feed the tooltip's name/price lines + the buy stepper. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).", + "_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).", "bgColor": "#0e1728", "border": "#1d2e4c", "selected": "#00e5ff", "lockAlpha": 0.30, "iconScale": 0.62, "name": { "fontSize": 11, "color": "#eaf6ff" }, - "price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" }, + "price": { "fontSize": 12, "sell": "#7ce8a4" }, "emptyLabel": "—", "lockLabel": "locked" }, + "buyList": { + "_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — [icon] NAME / one-line description (the good's flavor, data/goods.json) … PRICE, − qty +. Sized to the SELL grid's height so the panels align top AND bottom; the list scrolls (drag on the body / mouse wheel) when the stock outgrows the viewport — rows outside it are hidden (row-based clipping; this Phaser build has no camera/mask clip). A row in the pending trade (qty > 0) gets the accent border + lit qty. `pad` insets rows from the panel edge; the title bar reuses panel.title.", + "w": 560, + "pad": 14, + "rowH": 64, + "rowGap": 6, + "icon": 44, + "iconGap": 12, + "name": { "fontSize": 14, "color": "#eaf6ff" }, + "desc": { "fontSize": 12, "color": "#7d92c4" }, + "price": { "fontSize": 14, "color": "#ffc94d", "w": 64 }, + "rowBg": "#0e1728", + "rowBgAlpha": 0.55, + "rowBorder": "#1d2e4c", + "rowSelBg": "#12283a", + "rowSelBgAlpha": 0.85, + "selected": "#00e5ff" + }, + "tooltip": { - "_comment": "The hover tooltip — one per grid (shared), revealed when the pointer is over a usable cell: the good's NAME + the price line (\"sell for N cr\" / \"buy for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri).", + "_comment": "The hover tooltip — one per SELL grid (shared), revealed when the pointer is over a usable bay: the good's NAME + the sell-price line (\"sell for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri). (The BUY list has no tooltip — its rows carry the name/price inline.)", "bgColor": "#0a1120", "bgAlpha": 0.96, "border": "#22405f", @@ -53,8 +72,9 @@ }, "stepper": { - "_comment": "The BUY-side quantity stepper (− n +). Clicking −/+ adjusts that good's buy quantity; the live preview recalcs. The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.", + "_comment": "The BUY list's per-row quantity stepper (− n +): clicking −/+ adjusts that good's buy quantity (the qty tap = +1); the live preview recalcs. `w` is the stepper block's width (sizes the price gap). The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.", "fontSize": 19, + "w": 88, "color": "#eaf6ff", "btn": "#7d92c4", "activeColor": "#00e5ff" @@ -86,7 +106,7 @@ }, "animation": { - "_comment": "Reveal: the SELL grid slides in from the left, the BUY grid from the right, the center column fades up (data/shopui.json → animation). A short stagger per row keeps it feeling assembled rather than popped.", + "_comment": "Reveal: the SELL grid slides in from the left, the BUY list from the right, the center column fades up (data/shopui.json → animation). A short stagger keeps it feeling assembled rather than popped.", "slideMs": 340, "staggerMs": 26, "ease": "Cubic.Out" diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs index 0cc7657..c609208 100644 --- a/dev/shop-shot.mjs +++ b/dev/shop-shot.mjs @@ -126,7 +126,6 @@ const bounds = (o) => { // 1) structure: the old per-cell name/price text is GONE from usable cells const sellCell = sv.sellGrid.cells[0]; - const buyCell = sv.buyGrid.cells[0]; const struct = { sellCellName: sellCell.name === undefined ? 'undefined' : 'present', sellCellPrice: sellCell.price === undefined ? 'undefined' : 'present', @@ -134,7 +133,7 @@ const bounds = (o) => { sellLockedLabel: { text: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.text ?? null, alpha: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.alpha ?? null }, sellIcon: { alpha: sellCell.icon?.alpha ?? null, scale: round((sellCell.icon?.scaleX ?? 0) * 100) / 100, pos: { x: sellCell.icon?.x, y: sellCell.icon?.y } }, titleFont: sv.sellGrid.title?.style?.fontFamily ?? sv.sellGrid.title?._style?.fontFamily ?? null, - stepperFont: sv.buyGrid.cells[0].stepQty?.style?.fontFamily ?? sv.buyGrid.cells[0].stepQty?._style?.fontFamily ?? null, + buyQtyFont: sv.buyList.rows[0]?.objs?.qty?.style?.fontFamily ?? sv.buyList.rows[0]?.objs?.qty?._style?.fontFamily ?? null, }; out.tooltip = { struct }; @@ -142,21 +141,24 @@ const bounds = (o) => { const tipSellA = sv.sellGrid.hoverCell(0); out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 0) }; - // 3) REAL mouse hover over a BUY cell — row 1 → tip flips ABOVE - const buyIdxAbove = 6; // row 1, col 2 (stock is filled there) - const bc = cellWorldCenter(sv.buyGrid, buyIdxAbove); - out.tooltip.mouseAbove = { cell: bc, move: mouseMove(bc.x, bc.y), state: tipState(sv.buyGrid) }; + // 3) BUY side: the WIDE ROW carries name + description + price INLINE (no + // tooltip — the row is wide enough). + const row0 = sv.buyList.rows[0]; + const rowNameEl = row0?.objs?.name; + out.tooltip.buyRow = { + name: rowNameEl?.text ?? null, + desc: row0?.objs?.desc?.text ?? null, + price: row0?.objs?.price?.text ?? null, + qty: row0?.objs?.qty?.text ?? null, + nameFont: rowNameEl?.style?.fontFamily ?? rowNameEl?._style?.fontFamily ?? null, + rowWorld: rowNameEl ? { x: round(sv.buyList.x + rowNameEl.x), y: round(sv.buyList.y + rowNameEl.y) } : null, + }; - // 4) REAL mouse hover over a BUY cell — row 0 → tip flips BELOW - const buyIdxBelow = 0; - const bc0 = cellWorldCenter(sv.buyGrid, buyIdxBelow); - out.tooltip.mouseBelow = { cell: bc0, move: mouseMove(bc0.x, bc0.y), state: tipState(sv.buyGrid) }; - - // 5) move the pointer OFF the panel → pointerout hides the tip - const off = toClient(Math.max(4, sv.buyGrid.x - 30), 360); + // 4) move the pointer OFF the panel → pointerout hides the tip + const off = toClient(Math.max(4, sv.buyList.x - 30), 360); game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window })); await sleep(150); - out.tooltip.mouseOff = { state: tipState(sv.buyGrid) }; + out.tooltip.mouseOff = { state: tipState(sv.sellGrid) }; // 6) end with the SELL tooltip visible for the screenshot sv.sellGrid.hoverCell(0); @@ -166,7 +168,7 @@ const bounds = (o) => { out.topY = sv.topY ?? null; out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })(); out.sellGrid = { x: round(sv.sellGrid.x), y: round(sv.sellGrid.y), w: sv.sellGrid.width, h: sv.sellGrid.height }; - out.buyGrid = { x: round(sv.buyGrid.x), y: round(sv.buyGrid.y), w: sv.buyGrid.width, h: sv.buyGrid.height }; + out.buyList = { x: round(sv.buyList.x), y: round(sv.buyList.y), w: sv.buyList.width, h: sv.buyList.height, rows: sv.buyList.rows.length, maxScroll: sv.buyList.maxScroll }; window.__SHOP_SHOT__ = out; console.info('[shop-shot] OK:', JSON.stringify(out)); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 5dd186b..5e1968f 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -8,12 +8,16 @@ * CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's * verified in the browser) — it tests the LOGIC the player feels: * - * - CONSTRUCT — the view builds both grids over a real shop (16 sell bays, - * 12–16 buy slots) and starts on the Buy/Sell tab. + * - CONSTRUCT — the view builds the SELL grid + the BUY list over a real + * shop (16 sell bays; one wide row per stocked good) and starts on the + * Buy/Sell tab. * - SELL — checking a cargo bay sells one unit at the shop's sell price; * Confirm earns the credits and drops the good out of the hold. - * - BUY — stepping a stock good buys it at the shop's buy price; Confirm - * spends the credits and fills the bays. + * - BUY — stepping a stock row buys that good at the shop's buy price; + * Confirm spends the credits and fills the bays. + * - SCROLL — the stock list outgrows the viewport: scroll clamps at both + * ends, off-window rows are clipped, and selection is data (stepping an + * off-screen row still works). * - CAPS — Confirm is BLOCKED when the trade would go negative (not enough * credits) or overflow the hold (cargo-full); selling to free room for a * buy lands in one confirm. @@ -180,7 +184,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals // must click "Buy/Sell" to reveal it (showcase the clip before the UI). assert.equal(view.tab, null, 'starts unopened (entry state)'); assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)'); - assert.equal(view.buyGrid, null, 'no BUY grid yet'); + assert.equal(view.buyList, null, 'no BUY list yet'); assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state'); assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)'); assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale'); @@ -197,7 +201,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals // Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear. view.setTab('trade'); assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab'); - assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist after reveal'); + assert.ok(view.sellGrid && view.buyList, 'both the SELL grid and BUY list exist after reveal'); // The panel's TOP edge sits at topY (just under the planet text). A // TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a // Container's (x,y) is a pure translation of that local space (verified @@ -210,8 +214,8 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`); assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`); assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays'); - assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots'); - assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock'); + assert.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock'); + assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good'); assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold'); assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet'); } @@ -225,21 +229,18 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal const { view } = makeView(ship); const goodById = (id) => goods.find((g) => g.id === id); - // Each BUY cell's icon must carry the correct spritesheet frame (set via + // Each BUY row's icon must carry the correct spritesheet frame (set via // setTexture(key, frame) — NOT the read-only `.frame = n` assignment that // corrupted the texture source and crashed the real WebGL render). let checkedIcons = 0; - for (let i = 0; i < view.buyGrid.cells.length; i++) { - const cell = view.buyGrid.cells[i]; - const id = view.buyGrid.stock[i]?.id; - if (!id) continue; - const idx = goodById(id)?.icon; - assert.ok(typeof idx === 'number', `good ${id} has an icon index`); - assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet'); - assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`); + for (const row of view.buyList.rows) { + const idx = goodById(row.id)?.icon; + assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`); + assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet'); + assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`); checkedIcons += 1; } - assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound'); + assert.ok(checkedIcons >= 1, 'at least one buy row got its icon bound'); // Every tappable element must be interactive WITH a hitAreaCallback — a bare // setInteractive() leaves no callback and crashes Phaser v4's input (the @@ -247,15 +248,17 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal // real hitArea/hitAreaCallback path is exercised. assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback'); assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect'); - assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback'); + assert.equal(typeof view.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback'); + assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback'); // The two panels must settle at DIFFERENT layout positions — the slide-in // tween targets each grid's set position (the ShopView centers // [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels // at the left edge so the cargo/sell panel sat hidden behind the buy panel. assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`); - assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`); - assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin'); + assert.ok(view.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`); + assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin'); + assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)'); } console.log('INTERACTION: OK'); @@ -267,8 +270,8 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==' const { view } = makeView(ship); const nameA = goods.find((g) => g.id === A).name; const sellA = E.sellPrice(A, cls, sysId, galaxy); - const buyId = view.buyGrid.stock[0]?.id; - const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name; + const buyId = view.buyList.stock[0]?.id; + const nameB = view.buyList.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name; const buyB = E.buyPrice(buyId, cls, sysId, galaxy); // Cells no longer paint their own name/price text — the info lives in the @@ -300,11 +303,14 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==' assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip'); assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down'); - // BUY side: name + "buy for N cr". - view.buyGrid.hoverCell(0); - assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip'); - assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name'); - assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`); + // BUY side: no tooltip — the WIDE ROW carries name + description + price + // inline (the row is wide enough, unlike the icon-only sell cells). + const row0 = view.buyList.rows[0]; + assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline'); + assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price inline'); + const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? ''; + assert.ok(row0.objs.desc.text.length >= 3, 'row carries the one-line description'); + assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)'); // THEME FONTS — the shop UI speaks the project's type (data/theme.json): // Ethnocentric for section headers, Centauri for body text + buttons. @@ -312,7 +318,7 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==' assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`); assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`); assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face'); - assert.ok(/Centauri/.test(fam(view.buyGrid.cells[0].stepQty)), 'buy stepper uses the body face'); + assert.ok(/Centauri/.test(fam(view.buyList.rows[0].objs.qty)), 'buy row stepper uses the body face'); assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face'); } console.log('HOVER TOOLTIP: OK'); @@ -357,8 +363,8 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays const buyC = E.buyPrice(C, cls, sysId, galaxy); assert.ok(buyC > 0, 'the buy price is positive'); - view.buyGrid.bumpBuy(C, 2); - assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C'); + view.buyList.bumpBuy(C, 2); + assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C'); let pv = view.preview(); assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price'); assert.equal(pv.net, -buyC * 2, '… net = the spend'); @@ -372,6 +378,41 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays } console.log('BUY: OK'); +// =========================================================================== +console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip =='); +{ + const ship = makeShip(10000); + const { view } = makeView(ship); + const list = view.buyList; + assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`); + // 64+6 px per row vs a ~420 px viewport → the content is TALLER than the + // window, so the list must scroll (rows that don't fit were the old bug). + assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`); + + // Scrolling clamps at both ends. + list.scrollBy(100000); + assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom'); + list.scrollBy(-100000); + assert.equal(list._scroll, 0, 'scroll clamps back at the top'); + + // Row-based clipping: mid-scroll, only the window's rows are shown. + list.scrollTo(list.maxScroll / 2); + const shown = list.rows.filter((r) => r.shown); + const hidden = list.rows.filter((r) => !r.shown); + assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`); + assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)'); + + // Selection is DATA, not position: stepping a good works whether its row + // is on screen or not (the player can scroll, then adjust, freely). + const last = list.stock[list.stock.length - 1].id; + list.bumpBuy(last, 1); + assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects'); + assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates'); + list.clearSelection(); + assert.equal(list.hasSelection(), false, 'Clear resets the list selection'); +} +console.log('SCROLL: OK'); + // =========================================================================== console.log('\n== CAPS — blocked when negative or over the hold =='); { @@ -379,7 +420,7 @@ console.log('\n== CAPS — blocked when negative or over the hold =='); const broke = makeShip(0); const buyC = E.buyPrice(C, cls, sysId, galaxy); const { view: vb } = makeView(broke); - vb.buyGrid.bumpBuy(C, 1); + vb.buyList.bumpBuy(C, 1); let pv = vb.preview(); assert.equal(pv.valid, false, 'buying with 0 credits is INVALID'); assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits'); @@ -391,7 +432,7 @@ console.log('\n== CAPS — blocked when negative or over the hold =='); const full = makeShip(100000); full.cargo.add([A, B, A, B]); // 4 bays, no room const { view: vf } = makeView(full); - vf.buyGrid.bumpBuy(C, 1); + vf.buyList.bumpBuy(C, 1); pv = vf.preview(); assert.equal(pv.valid, false, 'buying with a full hold is INVALID'); assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index bc1076d..aeb0c05 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -247,7 +247,8 @@ That's flavor, not management. | `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). | | `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. | | `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. | -| `js/ui/shop/TradeGrid.js` | One side of the shop: a 4×4 grid of goods. SELL mode = the player's cargo bays (tap to check); BUY mode = the shop's stock (−/qty/+ stepper). | +| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays (tap to check off, hover tooltip with name + price). | +| `js/ui/shop/BuyList.js` | The shop's BUY side: the stock as a scrollable list of WIDE ROWS (icon + name + one-line description + price + −/qty/+ stepper; drag/wheel to scroll). | | `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. | | `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. | | `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). | @@ -295,7 +296,8 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr Where the loop is actually played. You open it from the surface deck's **SHOP** button (the existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over** -it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid`s. +it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL +side) + a `BuyList` (the BUY side). **Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar** is up — the cyberpunk **MenuButton** pair `[ BUY/SELL | BUYBACK ]` (Buyback grayed out), @@ -303,7 +305,7 @@ extended at its right end by the live **NET** readout (dim `0 cr` until you star same style + placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above the command deck**, right-aligned to the deck's **Shop** button — so it reads as the same family of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking -**Buy/Sell** reveals the trade panel in the middle of the screen (the grids slide in). +**Buy/Sell** reveals the trade panel in the middle of the screen (the panels slide in). ``` # first click — shop video + the tab sub-bar (rests above the deck): @@ -318,10 +320,13 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl # after clicking Buy/Sell — the trade panel reveals (middle of the screen): ┌─────────────────────────────────────────────────────────────────┐ -│ ┌SELL 4×4┐ [ CONFIRM ] ┌BUY 4×4┐ │ -│ │ bays │ [ CLEAR ] │ stock │ │ -│ │(cargo) │ │(12–16) │ │ -│ └────────┘ └────────┘ │ +│ ┌SELL 4×4┐ [ CONFIRM ] ┌──────────────────────────────┐ │ +│ │ bays │ [ CLEAR ] │[i] Fresh Produce 24 cr − 0 +│ │ +│ │(cargo) │ │ Crisp, clean, alive… │ │ +│ └────────┘ │[i] Refined Fuel 41 cr − 1 +│ │ +│ │ The lifeblood of the fleet │ │ +│ │ … (scrolls) … │ │ +│ └──────────────────────────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────┐ │ │ │ BUY/SELL │ BUYBACK │ NET +420 cr │ ← sub-bar │ @@ -337,7 +342,7 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit. The bar is **extended** past a hairline divider to carry the live **NET** readout — one glance sees the tabs *and* the pending trade's credit delta. -- **Confirm / Clear** — the same `MenuButton` style, in the center column between the grids; +- **Confirm / Clear** — the same `MenuButton` style, in the center column between the panels; Confirm is **disabled** (grayed) while the trade is invalid (can't go negative, hold would overflow), and both appear once you start picking. @@ -345,8 +350,13 @@ of UI as the Menu drawer. The screen stays open on the shop video so the clip pl are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the quantity is simply the number of its bays that are checked. -- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`). Each - cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room). +- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`) as a + **scrollable list of WIDE ROWS**: each row is the good's **icon**, its **name** + a + one-line **description** (the good's `flavor`), and the **price** + a **−/qty/+ stepper** + on the right. The stock is longer than the panel, so the list **scrolls** (drag on the + list body or the mouse wheel) — rows outside the window are clipped; the selection is + data, so stepping a good works whether its row is on screen or not. Buy as many as you + like (capped by credits + hold room). - **NET readout (tab sub-bar)** — the live credit delta, right of the tabs past the divider. Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check bays / step goods (it's the same `preview()` the Confirm button is gated on). @@ -366,7 +376,7 @@ the same stock from `(seed, placeId, economyVersion)`. The only session state is list (explicitly reset on leave). So the economy stays "interact, don't control": you trade against fixed regional prices, never against your own history. -**Tested headlessly** (`dev/shop-ui.test.mjs`): construct both grids, sell a bay and earn the +**Tested headlessly** (`dev/shop-ui.test.mjs`): construct the SELL grid + BUY list, sell a bay and earn the sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout and the slide-in animation are verified in the browser (the stub doesn't render). diff --git a/js/ui/shop/BuyList.js b/js/ui/shop/BuyList.js new file mode 100644 index 0000000..679d4a3 --- /dev/null +++ b/js/ui/shop/BuyList.js @@ -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; +} diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index f01c938..d748fee 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -1,26 +1,31 @@ /** * ShopView — the buy/sell (and buyback) screen over the shop clip video. * - * ┌────────────────────────────────────────────────────────────┐ - * │ ┌SELL 4×4┐ [ Confirm ] ┌BUY 4×4┐ │ - * │ │ bays │ [ Clear ] │ stock │ │ - * │ │ (cargo)│ │ (12–16)│ │ - * │ └────────┘ └────────┘ │ - * └────────────────────────────────────────────────────────────┘ - * ┌────────────────────────────────────────────────────────────┐ - * │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │ - * └────────────────────────────────────────────────────────────┘ + * ┌────────────────────────────────────────────────────────────────┐ + * │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │ + * │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │ + * │ │ (cargo)│ │ Crisp, clean… − 0 +│ │ + * │ └────────┘ │ [i] Refined Fuel 41cr│ │ + * │ │ The lifeblood − 1 +│ │ + * │ │ … (scrolls) … │ │ + * │ └────────────────────────┘ │ + * └────────────────────────────────────────────────────────────────┘ + * ┌────────────────────────────────────────────────────────────────┐ + * │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │ + * └────────────────────────────────────────────────────────────────┘ * - * The two sides are TradeGrids (js/ui/shop/TradeGrid.js): - * · SELL (left) — the player's cargo bays. Tap a filled bay to check it - * off (a bay = 1 unit). Locked bays are the hold's upgrade room. - * · BUY (right) — the shop's stock, each with a −/qty/+ stepper. - * The live NET preview (green ≥ 0, red < 0) sits on the TAB SUB-BAR, right - * of the Buy/Sell + Buyback buttons past a hairline divider (tabs AND the - * pending trade's credit math on one console); Confirm/Clear stay in the - * center. Confirm is disabled while the trade is invalid (can't go negative, - * hold would overflow). SELL→BUY happens atomically (sell to free room for a - * buy). + * The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the + * player's cargo bays — tap a filled bay to check it off (a bay = 1 unit); + * locked bays are the hold's upgrade room. + * The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's + * stock as WIDE ROWS — icon + name + one-line description (the good's + * flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a + * full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0) + * sits on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons past a + * hairline divider (tabs AND the pending trade's credit math on one + * console); Confirm/Clear stay in the center. Confirm is disabled while + * the trade is invalid (can't go negative, hold would overflow). SELL→BUY + * happens atomically (sell to free room for a buy). * * BUYBACK (session-local only) — the second tab. After the player SELLs * something, the goods they sold are listed here and can be bought BACK — @@ -41,6 +46,7 @@ import { MenuButton } from '../MenuButton.js'; import { toColor, toCss } from '../../utils/Color.js'; import { fontStack } from '../../utils/Theme.js'; import { TradeGrid } from './TradeGrid.js'; +import { BuyList } from './BuyList.js'; export class ShopView extends Phaser.GameObjects.Container { /** @@ -83,7 +89,7 @@ export class ShopView extends Phaser.GameObjects.Container { this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source) this.buybackSel = new Map(); // goodId -> qty to buy back this.sellGrid = null; - this.buyGrid = null; + this.buyList = null; this._content = null; // the active content container this._netLabel = null; // the sub-bar NET label (persistent) this._netValue = null; // the sub-bar NET value (live) @@ -303,7 +309,7 @@ export class ShopView extends Phaser.GameObjects.Container { } // --------------------------------------------------------------------- - // CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons) + // CONTENT — the trade panel (SELL grid + BUY list + buttons) // --------------------------------------------------------------------- _showTrade() { const grid = config.get('shopui.grid', {}); @@ -314,38 +320,40 @@ export class ShopView extends Phaser.GameObjects.Container { side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity, cls: this.cls, systemId: this.systemId, sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy), - buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy), - }); - const buyGrid = new TradeGrid(this.scene, { - side: 'buy', maxSlots: this.maxSlots, - cls: this.cls, systemId: this.systemId, - sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy), - buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy), }); + // The BUY side: a scrollable LIST of stock rows (wide bars — icon + + // name + description + price + −/qty/+ stepper). Sized to the SELL + // grid's height so the two panels align top AND bottom; the list scrolls + // when the stock outgrows the viewport (it always does — 8–16 rows). + const buyW = Math.min( + Number(config.get('shopui.buyList.w')) || 560, + Math.max(440, W - 24 - sellGrid.width - centerW), + ); + const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height }); this.sellGrid = sellGrid; - this.buyGrid = buyGrid; + this.buyList = buyList; - // Layout: [SELL grid] [center] [BUY grid]. The panel's TOP edge sits at + // Layout: [SELL grid] [center] [BUY list]. The panel's TOP edge sits at // this.topY — just under the surface HUD's planet text (upper-left) — so // the whole panel reads as one unit near the top of the screen (not // hanging down to the deck/sub-bar). // - // IMPORTANT: a TradeGrid lays its plate/cells out in local (0,0)→(w,h) + // IMPORTANT: a TradeGrid/BuyList lays its plate out in local (0,0)→(w,h) // and a Container's (x,y) is a pure TRANSLATION of that local space - // (verified with dev/cbox-test.html) — so the grid's position IS the - // panel's top-left corner. Treating it as a centered origin (topY + h/2) - // drops the panel half a grid lower and clips the bottom rows. - const totalW = sellGrid.width + centerW + buyGrid.width; + // (verified with dev/cbox-test.html) — so the panel's position IS its + // top-left corner. Treating it as a centered origin (topY + h/2) drops + // the panel half a grid lower and clips the bottom rows. + const totalW = sellGrid.width + centerW + buyList.width; const leftX = Math.max(8, (W - totalW) / 2); const gridH = sellGrid.height; sellGrid.setPosition(leftX, this.topY); const rightX = leftX + sellGrid.width + centerW; - buyGrid.setPosition(rightX, this.topY); + buyList.setPosition(rightX, this.topY); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); content.setScrollFactor?.(0); content.add?.(sellGrid); - content.add?.(buyGrid); + content.add?.(buyList); // center column: Confirm + Clear — vertically centered with the grids, // so the whole panel is one cohesive block near the top of the screen. @@ -387,14 +395,14 @@ export class ShopView extends Phaser.GameObjects.Container { // Data + live preview wiring. sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); - buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy }); + buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy }); sellGrid.onSelectionChange = () => this._syncPreview(); - buyGrid.onSelectionChange = () => this._syncPreview(); + buyList.onSelectionChange = () => this._syncPreview(); this._syncPreview(); // Reveal: SELL slides in from the left, BUY from the right. sellGrid.slideIn(); - buyGrid.slideIn(); + buyList.slideIn(); } // --------------------------------------------------------------------- @@ -479,7 +487,7 @@ export class ShopView extends Phaser.GameObjects.Container { cls: this.cls, systemId: this.systemId, galaxy: this.galaxy, cargo, capacity, credits, sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {}, - buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {}, + buyMap: this.buyList ? this.buyList.getBuyMap() : {}, }); } // buyback — priced at the sale price (buyOverrides), a neutral undo. @@ -509,7 +517,7 @@ export class ShopView extends Phaser.GameObjects.Container { clear() { if (this.tab === 'trade') { this.sellGrid?.clearSelection(); - this.buyGrid?.clearSelection(); + this.buyList?.clearSelection(); } else { this.buybackSel = new Map(); } @@ -529,7 +537,7 @@ export class ShopView extends Phaser.GameObjects.Container { if (this.tab === 'trade') { for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name); this.sellGrid?.clearSelection(); - this.buyGrid?.clearSelection(); + this.buyList?.clearSelection(); } else { for (const b of pv.buy) this._reduceSold(b.id, b.qty); this.buybackSel = new Map(); @@ -588,7 +596,7 @@ export class ShopView extends Phaser.GameObjects.Container { } _hasAnySelection() { - if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false); + if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false); return [...this.buybackSel.values()].some((q) => q > 0); } @@ -618,9 +626,9 @@ export class ShopView extends Phaser.GameObjects.Container { this._content = null; } this.sellGrid?.destroy?.(); - this.buyGrid?.destroy?.(); + this.buyList?.destroy?.(); this.sellGrid = null; - this.buyGrid = null; + this.buyList = null; this._confirmBtn = null; this._clearBtn = null; } diff --git a/js/ui/shop/TradeGrid.js b/js/ui/shop/TradeGrid.js index dbdb347..0bde0bb 100644 --- a/js/ui/shop/TradeGrid.js +++ b/js/ui/shop/TradeGrid.js @@ -1,29 +1,36 @@ /** - * TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells. + * TradeGrid — the shop's SELL side: the player's cargo bays as a 4×4 grid. * - * Two modes, one component: - * · SELL (the left panel) — the grid is the player's CARGO. Each cell is a - * BAY (one good each, data/economy.json → cargo). The first `capacity` - * cells are live bays (filled with a good, or empty); the rest are LOCKED - * (the hold's upgrade room — the ship can't carry there yet). Tapping a - * filled bay checks it off to sell (a bay = 1 unit); the sell quantity for - * a good is just the number of its bays that are checked. - * · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct - * goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy - * quantity is whatever the stepper says. + * ┌────────────┐ + * │ SELL │ + * │ ┌────┐┌────┐ │ + * │ │icon││icon│ │ filled bay → good icon + * │ └────┘└────┘ │ empty bay → dim plate + * │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room) + * │ │ ││lock│ │ checked → cyan selection ring (1 unit queued) + * │ └────┘└────┘ │ + * └────────────┘ * - * Cells show the good's ICON only — the name + price are revealed in a - * shared hover tooltip (pointerover the cell; one tooltip per grid). + * A BAY is the unit (not a good): each filled bay holds 1 unit of its good + * and checking it off queues 1 unit for sale. The first `capacity` bays are + * live (filled with a good, or empty); the rest are LOCKED — the hold's + * upgrade room (the ship can't carry there yet). Hovering a cell reveals + * the good's name + sell price in a shared tooltip (icon-only cells — no + * text over the icon). * - * The PRICING lives in js/economy/Economy.js (buyPrice/sellPrice — the fixed - * per-(good, shop) price). This component only shows the price and tracks the - * SELECTION (which bays to sell / how many of each to buy). The ShopView - * (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade + * (The BUY side is no longer a grid — the shop's stock is now a scrollable + * list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side + * by side.) + * + * The PRICING lives in js/economy/Economy.js (sellPrice — the fixed + * per-(good, shop) price). This grid only shows the price and tracks the + * SELECTION (which bays are checked off). The ShopView + * (js/ui/shop/ShopView.js) composes it with the BuyList, runs the trade * preview (Economy.tradePreview) and applies it to the ship. * * Headless-safe: the Phaser rendering is best-effort (guarded), and the - * selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data - * so dev/shop-ui.test.mjs can drive it with no scene. + * selection LOGIC (toggleBay / getSellMap) is plain data so + * dev/shop-ui.test.mjs can drive it with no scene. */ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; @@ -33,21 +40,19 @@ import { fontStack } from '../../utils/Theme.js'; export class TradeGrid extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). - * @param {object} o — { side: 'sell'|'buy', maxSlots, capacity, - * cls, systemId, galaxy, sellUnit(id), buyUnit(id) } - * sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so - * the grid can show "sell for N" / "buy for N" without re-deriving. + * @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) } + * sellUnit is the per-unit price lookup (from the ShopView) so the grid + * can show "sell for N" without re-deriving. */ constructor(scene, o) { super(scene, 0, 0); this.scene = scene; - this.side = o.side; + this.side = 'sell'; // SELL-only now (the BUY side moved to BuyList.js) this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16)); this.capacity = Math.max(0, Math.floor(o.capacity ?? 0)); this.cls = o.cls; this.systemId = o.systemId; this.sellUnit = (id) => o.sellUnit?.(id) ?? 0; - this.buyUnit = (id) => o.buyUnit?.(id) ?? 0; const g = config.get('shopui.grid', {}); this.cols = g.cols ?? 4; @@ -66,19 +71,16 @@ export class TradeGrid extends Phaser.GameObjects.Container { this.height = this.titleH + this.cellH + pad * 2; // --- state ----------------------------------------------------------- - // SELL: bays in fill order (goodId | null), length = maxSlots; usable = - // index < capacity. BUY: stock entries { id, name, price }, length = - // stock size (≤ maxSlots). + // bays in fill order (goodId | null), length = maxSlots; usable = + // index < capacity (locked bays are the hold's upgrade room). this.bays = []; - this.stock = []; - this.sellSel = new Set(); // sell: checked bay indices - this.buyQty = new Map(); // buy: goodId -> qty + this.sellSel = new Set(); // checked bay indices (the pending sell) // --- objects --------------------------------------------------------- this._objs = []; // everything we create (destroy()) this.plate = null; // the semi-transparent panel background this.title = null; - this.cells = []; // per cell: { plate, sel, icon, label, stepQty, stepMinus, stepPlus } + this.cells = []; // per cell: { plate, sel, icon, label } this.tip = null; // shared hover tooltip (name + price) — the this.tipPlate = null; // per-cell name/price text is gone; hovering this.tipName = null; // a cell reveals it beside the icon @@ -122,7 +124,7 @@ export class TradeGrid extends Phaser.GameObjects.Container { const gr = this.plate; if (!gr || typeof gr.clear !== 'function') return; const p = this.plateStyle ?? {}; - const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7); + const a = p.bgAlpha ?? 0.7; gr.clear?.(); if (typeof gr.fillStyle === 'function') { gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a); @@ -133,8 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container { _buildTitle() { const t = this.scene.add?.text?.( - this.width / 2, 8, - this.side === 'sell' ? 'SELL' : 'BUY', + this.width / 2, 8, 'SELL', { fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center', fontFamily: fontStack('header') }, // Ethnocentric — the theme's section face ); if (t) { @@ -178,37 +179,11 @@ export class TradeGrid extends Phaser.GameObjects.Container { }); if (label) { label.setOrigin?.(0.5, 0.5); label.setAlpha?.(0); cell.label = label; cell.objects.push(label); } - // BUY stepper: − n + (bottom of the cell) - if (this.side === 'buy') { - const mk = (dx, str) => { - const el = this.scene.add?.text?.(x + dx, y + 26, str, { - fontSize: `${config.get('shopui.stepper.fontSize', 19)}px`, - color: this.sl.price?.buy ?? '#ffc94d', align: 'center', - fontFamily: fontStack('body'), - }); - if (el) { el.setOrigin?.(0.5, 0.5); return el; } - return null; - }; - const minus = mk(-26, '−'); - const qty = mk(0, '0'); - const plus = mk(26, '+'); - if (minus) cell.objects.push(minus); - if (qty) { cell.stepQty = qty; cell.objects.push(qty); } - if (plus) cell.objects.push(plus); - cell.stepMinus = minus; cell.stepPlus = plus; - const hit = [-18, -14, 36, 28]; - this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit); - this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit); - this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit); - } - - // interaction: the plate is the whole cell's surface — tap (SELL: toggle - // the bay) + hover (either side: reveal the name/price tooltip). + // interaction: the plate is the whole cell's surface — tap (toggle the + // bay) + hover (reveal the name/price tooltip). const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot]; if (this._interactive(cell.plate, hit)) { - if (this.side === 'sell') { - cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); }); - } + cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); }); cell.plate.on('pointerover', () => this.hoverCell(i)); cell.plate.on('pointerout', () => this.unhover()); } @@ -218,15 +193,12 @@ export class TradeGrid extends Phaser.GameObjects.Container { if (cell.sel) this.add?.(cell.sel); if (cell.icon) this.add?.(cell.icon); if (cell.label) this.add?.(cell.label); - if (cell.stepMinus) this.add?.(cell.stepMinus); - if (cell.stepQty) this.add?.(cell.stepQty); - if (cell.stepPlus) this.add?.(cell.stepPlus); } } // A tap only works when the object supports interactivity (real Phaser); a // headless fake returns no-op objects, and the test drives the logic - // methods (toggleBay / bumpBuy) directly. + // method (toggleBay) directly. _bindTap(obj, fn, hitRect) { if (!this._interactive(obj, hitRect)) return; obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); }); @@ -252,36 +224,26 @@ export class TradeGrid extends Phaser.GameObjects.Container { // --- DATA (the testable core) ----------------------------------------- /** - * Set the grid's contents. SELL: `cargo` is the filled-bay id list (order - * preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list - * ({ id, name, price } | { id }). Resets the selection (a fresh shop view). + * Set the grid's contents: `cargo` is the filled-bay id list (order + * preserved — bay i holds cargo[i]). Resets the selection (a fresh shop + * view). */ refresh(o = {}) { this.unhover(); - if (this.side === 'sell') { - const cargo = Array.isArray(o.cargo) ? o.cargo : []; - this.bays = []; - for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null); - this.sellSel = new Set(); - } else { - const stock = Array.isArray(o.stock) ? o.stock : []; - this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price })); - this.buyQty = new Map(); - } + const cargo = Array.isArray(o.cargo) ? o.cargo : []; + this.bays = []; + for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null); + this.sellSel = new Set(); this._renderAll(); return this; } - _stockIdAt(i) { - return this.stock[i]?.id ?? null; - } _bayIdAt(i) { return this.bays[i] ?? null; } - /** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */ + /** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */ toggleBay(i) { - if (this.side !== 'sell') return this; if (i < 0 || i >= this.capacity) return this; // locked bay const id = this._bayIdAt(i); if (!id) return this; // empty bay @@ -292,9 +254,8 @@ export class TradeGrid extends Phaser.GameObjects.Container { return this; } - /** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */ + /** Check / uncheck by good id (the Nth unchecked/checked bay of it). */ toggleGood(id) { - if (this.side !== 'sell') return this; const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity); if (idx >= 0) this.toggleBay(idx); else { @@ -305,20 +266,7 @@ export class TradeGrid extends Phaser.GameObjects.Container { return this; } - /** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */ - bumpBuy(id, d = 1) { - if (this.side !== 'buy' || !id) return this; - if (!this.stock.some((s) => s.id === id)) return this; // not in stock - const cur = this.buyQty.get(id) ?? 0; - const next = Math.max(0, cur + Math.floor(Number(d) || 0)); - if (next === 0) this.buyQty.delete(id); - else this.buyQty.set(id, next); - this._renderStockRow(id); - this._emitSelChange(); - return this; - } - - /** SELL: { goodId: qty } from the checked bays. */ + /** { goodId: qty } from the checked bays. */ getSellMap() { const m = {}; for (const i of this.sellSel) { @@ -328,20 +276,12 @@ export class TradeGrid extends Phaser.GameObjects.Container { return m; } - /** BUY: { goodId: qty } from the steppers. */ - getBuyMap() { - const m = {}; - for (const [id, q] of this.buyQty) if (q > 0) m[id] = q; - return m; - } - hasSelection() { - return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0; + return this.sellSel.size > 0; } clearSelection() { - if (this.side === 'sell') this.sellSel = new Set(); - else this.buyQty = new Map(); + this.sellSel = new Set(); this._renderAll(); this._emitSelChange(); return this; @@ -368,19 +308,14 @@ export class TradeGrid extends Phaser.GameObjects.Container { let goodId = null; let locked = false; - if (this.side === 'sell') { - goodId = this._bayIdAt(i); - locked = i >= this.capacity; - } else { - goodId = this._stockIdAt(i); - locked = false; - } + goodId = this._bayIdAt(i); + locked = i >= this.capacity; const usable = !locked && !!goodId; // plate border (selected sell bays get the cyan ring) if (cell.plate && typeof cell.plate.clear === 'function') { - const selected = this.side === 'sell' && this.sellSel.has(i); + const selected = this.sellSel.has(i); cell.plate.clear?.(); if (typeof cell.plate.fillStyle === 'function') { cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3)); @@ -389,46 +324,28 @@ export class TradeGrid extends Phaser.GameObjects.Container { cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot); } - // icon — the cell's only content for a usable bay/stock slot (the - // name+price live in the hover tooltip). Centered; the BUY side lifts it - // a touch so the stepper under it reads as a row of its own. + // icon — the cell's only content for a usable bay (the name+price live + // in the hover tooltip). Centered. if (cell.icon) { const show = usable; if (show) { cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId)); cell.icon.setAlpha?.(1); cell.icon.setScale?.(this.sl.iconScale ?? 0.62); - cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y); + cell.icon.setPosition?.(x, y); } else { cell.icon.setAlpha?.(0); } } - // state label — locked bays / empty slots only; usable cells stay clean + // state label — locked bays / empty bays only; usable cells stay clean if (cell.label && typeof cell.label.setText === 'function') { - cell.label.setText(usable - ? '' - : (this.side === 'sell' - ? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')) - : (sl.emptyLabel ?? '—'))); + cell.label.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))); cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3)); } - - // buy stepper - if (this.side === 'buy' && cell.stepQty) { - const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0; - if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q)); - const on = usable; - for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0); - } - } - - _renderStockRow(id) { - for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i); } _goodName(id) { - if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id; const list = config.get('goods.goods', []); return list.find((g) => g.id === id)?.name ?? id; } @@ -462,7 +379,7 @@ export class TradeGrid extends Phaser.GameObjects.Container { }; const plate = this.scene.add?.graphics?.() ?? null; const name = mk(this.tipNameSize, this.sl.name?.color ?? '#eaf6ff'); - const price = mk(this.tipPriceSize, this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d')); + const price = mk(this.tipPriceSize, this.sl.price?.sell ?? '#7ce8a4'); if (!plate && !name && !price) return; if (plate) this._objs.push(plate); if (name) this._objs.push(name); @@ -501,17 +418,12 @@ export class TradeGrid extends Phaser.GameObjects.Container { hoverCell(i) { const cell = this.cells[i]; if (!cell || !this.tip) return this; - let goodId = null; - if (this.side === 'sell') { - if (i < this.capacity) goodId = this._bayIdAt(i); - } else { - goodId = this._stockIdAt(i); - } + const goodId = i < this.capacity ? this._bayIdAt(i) : null; if (!goodId) return this.unhover(); - const unit = Math.round(this.side === 'sell' ? this.sellUnit(goodId) : this.buyUnit(goodId)); + const unit = Math.round(this.sellUnit(goodId)); if (this.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId); - if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`${this.side === 'sell' ? 'sell' : 'buy'} for ${unit} cr`); + if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`sell for ${unit} cr`); // size from the text metrics (real Phaser or the test stubs) const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18;