diff --git a/data/shopui.json b/data/shopui.json index da04321..747d7f8 100644 --- a/data/shopui.json +++ b/data/shopui.json @@ -1,13 +1,11 @@ { - "_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.", + "_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) RIGHT NEXT TO it with just a bit of padding between (grid.centerW) — the panel is pure selection. The trade ACTIONS (Confirm/Clear) + the live NET-credits readout ride on the TAB SUB-BAR (→ tabs/preview/buttons), 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 (wraps to two lines, never over the price/stepper) + 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": { - "_comment": "The tab sub-bar (two MenuButtons, the deck's cyberpunk style — js/ui/MenuButton.js), EXTENDED at its right end by the live NET readout (→ preview) past a hairline divider. Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). Size/palette come from data/save.json → subBar + colors (the Menu sub-bar recipe); this section just holds the labels + Buy/Sell accent. The sub-bar rests above the command deck, anchored to the deck's Shop button (SurfaceScene.shopAnchor).", + "_comment": "The tab sub-bar (the deck's cyberpunk style — js/ui/MenuButton.js): [Buy/Sell][Buyback] │ [Clear][Confirm] │ NET — the tabs, the trade ACTIONS (→ buttons), and the live NET readout (→ preview), past hairline dividers, on ONE console. Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). Size/palette come from data/save.json → subBar + colors (the Menu sub-bar recipe); this section just holds the labels + Buy/Sell accent. The sub-bar rests above the command deck, anchored to the deck's Shop button (SurfaceScene.shopAnchor).", "buySell": "Buy/Sell", "buyback": "Buyback", - "w": 184, "h": 46, "gap": 14, - "y": 44, "activeAccent": "#00e5ff", "disabledAlpha": 0.34 }, @@ -22,10 +20,10 @@ }, "grid": { - "_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.)", + "_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 small PADDING gap between the SELL grid and the BUY list (the panels sit side by side; the trade actions live on the tab sub-bar — → buttons). (The BUY side is a list now — → buyList.)", "cols": 4, "rows": 4, "slot": 96, "gap": 10, - "centerW": 208 + "centerW": 16 }, "slot": { @@ -42,15 +40,15 @@ }, "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.", + "_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — [icon] NAME / description (the good's flavor, data/goods.json, wraps to up to desc.maxLines lines and NEVER over the price/stepper) … 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. `rowH` is tall enough for the two-line description. `pad` insets rows from the panel edge; the title bar reuses panel.title.", "w": 560, "pad": 14, - "rowH": 64, + "rowH": 72, "rowGap": 6, "icon": 44, "iconGap": 12, "name": { "fontSize": 14, "color": "#eaf6ff" }, - "desc": { "fontSize": 12, "color": "#7d92c4" }, + "desc": { "fontSize": 11, "color": "#7d92c4", "maxLines": 2 }, "price": { "fontSize": 14, "color": "#ffc94d", "w": 64 }, "rowBg": "#0e1728", "rowBgAlpha": 0.55, @@ -94,19 +92,15 @@ }, "buttons": { - "_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — they appear once the player starts checking/stepping items. `disabled` dims Confirm while the trade is invalid. `back` is a reserved return-to-menu label (the deck's Leave Shop is the real exit). Separate *Label (text) and *Accent (color) keys so they don't collide.", - "w": 158, "h": 54, + "_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — on the TAB SUB-BAR (→ tabs), between the tabs and the NET readout: they appear once the player starts checking/stepping items, and serve BOTH the trade and the buyback tabs. Confirm is grayed (disabled) while the trade is invalid. Button size/palette come from data/save.json → subBar (the same MenuButtons as the tabs); this section just holds the labels + accents.", "confirmLabel": "Confirm", "clearLabel": "Clear", - "backLabel": "‹ Menu", - "accent": "#00e5ff", "confirmAccent": "#7ce8a4", - "clearAccent": "#7d92c4", - "disabled": "#7d92c4" + "clearAccent": "#7d92c4" }, "animation": { - "_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.", + "_comment": "Reveal: the SELL grid slides in from the left, the BUY list from the right (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/phaser-stub.mjs b/dev/phaser-stub.mjs index 8ebc18d..836b530 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -21,6 +21,8 @@ class GameObject { setTint(t) { this.tint = t; return this; } setBlendMode() { return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } + setX(x) { this.x = x; return this; } + setY(y) { this.y = y; return this; } setStrokeStyle() { return this; } setSize(w, h) { this.width = w; this.height = h; return this; } set() { return this; } diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 5e1968f..f7c7579 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -83,6 +83,7 @@ function stubObject(extra = {}) { setColor(c) { this.color = c; return this; }, setFontSize() { return this; }, setWordWrap() { return this; }, + setWordWrapWidth(w) { this._wrapW = w; return this; }, setLineSpacing() { return this; }, setPadding() { return this; }, setScrollFactor() { return this; }, @@ -198,6 +199,19 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals assert.ok(view.y === 0, 'ShopView root stays at the screen origin (0,0) so the trade panel renders in screen space'); assert.ok(view.subBarW > 0 && view.subBarH > 0, 'sub-bar has a measured width + height'); + // The trade ACTIONS live on the sub-bar too — [tabs] │ [Clear][Confirm] │ + // [NET value] — created up front (persistent across tabs), hidden until a + // selection exists (_syncPreview reveals them), Confirm grayed while the + // trade is invalid. + assert.ok(view._clearBtn, 'Clear lives on the sub-bar (persistent, not in the panel)'); + assert.ok(view._confirmBtn, 'Confirm lives on the sub-bar (persistent, not in the panel)'); + assert.ok(view._clearBtn.x < view._confirmBtn.x, 'Clear sits left of Confirm on the bar'); + assert.ok(view._tabBuyback.x < view._clearBtn.x, '… after the tabs'); + assert.ok(view._confirmBtn.x < view._netLabel.x, '… and the NET readout rides to their right'); + assert.equal(view._clearBtn.alpha, 0, 'entry state: Clear is hidden (no selection yet)'); + assert.equal(view._confirmBtn.alpha, 0, 'entry state: Confirm is hidden (no selection yet)'); + assert.equal(view._confirmBtn.disabled, true, 'entry state: Confirm is grayed'); + // 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'); @@ -259,6 +273,12 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal 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)'); + + // The two panels sit NEXT TO each other with just a bit of padding between + // (grid.centerW — the actions moved onto the sub-bar, so there's no center + // column anymore). + const gapPx = view.buyList.x - (view.sellGrid.x + view.sellGrid.width); + assert.ok(Math.abs(gapPx - 16) < 1, `BUY is right next to SELL, small padding (got gap ${gapPx.toFixed(1)} px, want ~16)`); } console.log('INTERACTION: OK'); @@ -309,9 +329,24 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==' 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.length >= 3, 'row carries the description'); assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)'); + // DESCRIPTION WRAP: smaller font (11px), wraps to up to 2 lines, and the + // wrap width stops BEFORE the price/stepper so a wrapped line can never + // overlap them (the char-count budget is the headless fail-safe). + assert.equal(row0.objs.desc._style?.fontSize, '11px', 'desc uses the smaller 11px font'); + assert.ok(row0.objs.desc._wrapW > 0, 'desc has a word-wrap width set (real Phaser wraps inside the text column)'); + const descMaxW = view.buyList._descMaxW(); + assert.ok(descMaxW > 0, 'the text column has a positive width'); + assert.ok(row0.objs.desc._wrapW <= descMaxW + 0.001, `… and the wrap width fits inside it (wrap=${row0.objs.desc._wrapW}, col=${descMaxW.toFixed(1)})`); + const budget = Math.max(4, Math.floor(descMaxW / (11 * 0.55))) * 2 + 1; // 2 lines + ellipsis room + assert.ok(row0.objs.desc.text.length <= budget, `desc stays inside the two-line budget (len=${row0.objs.desc.text.length} ≤ ${budget})`); + // The wrapped desc never bleeds into the price: name/desc start at the text + // column's left edge, well clear of the price's right-aligned x. + const priceX = view.buyList._btnXs().price; + assert.ok(view.buyList._textX() + descMaxW < priceX, 'text column + max desc width ends before the price'); + // THEME FONTS — the shop UI speaks the project's type (data/theme.json): // Ethnocentric for section headers, Centauri for body text + buttons. const fam = (o) => o?._style?.fontFamily ?? ''; @@ -340,10 +375,14 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold assert.equal(pv.valid, true, '… and it\u2019s legal'); // The live NET readout rides on the TAB SUB-BAR (not the center column) — - // it mirrors the pending trade's credit delta, one glance from the tabs. + // it mirrors the pending trade's credit delta, one glance from the tabs — + // alongside the Confirm/Clear actions (which light up with a selection). assert.ok(view._netValue, 'the sub-bar carries the NET value readout'); assert.ok(view._netLabel, '… with the NET label'); assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr'); + assert.ok(view._confirmBtn.alpha > 0.5, 'a selection reveals the sub-bar Confirm'); + assert.ok(view._clearBtn.alpha > 0.5, '… and Clear'); + assert.equal(view._confirmBtn.disabled, false, '… and Confirm is live (valid trade)'); const before = ship.credits; assert.equal(view.confirm(), true, 'Confirm goes through'); @@ -352,6 +391,8 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible'); assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed'); assert.equal(view._netValue.text, '0 cr', '… and the bar’s NET settles back to 0 cr'); + assert.equal(view._confirmBtn.alpha, 0, '… and the Confirm/Clear actions hide again (selection gone)'); + assert.equal(view._clearBtn.alpha, 0, '… Clear hides too'); } console.log('SELL: OK'); @@ -425,6 +466,7 @@ console.log('\n== CAPS — blocked when negative or over the hold =='); assert.equal(pv.valid, false, 'buying with 0 credits is INVALID'); assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits'); assert.equal(vb.confirm(), false, '… and Confirm is refused'); + assert.equal(vb._confirmBtn.disabled, true, '… and the sub-bar Confirm is grayed (invalid trade)'); assert.equal(broke.credits, 0, '… nothing spent'); assert.equal(broke.cargo.used, 0, '… nothing bought'); @@ -443,6 +485,7 @@ console.log('\n== CAPS — blocked when negative or over the hold =='); pv = vf.preview(); assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits'); assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)'); + assert.equal(vf._confirmBtn.disabled, false, '… and Confirm un-grays (the trade is valid again)'); const before = full.credits; assert.equal(vf.confirm(), true, '… and it goes through'); assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)'); @@ -501,6 +544,8 @@ console.log('\n== BUYBACK is locked until a sale + Clear resets the selection == view.clear(); assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection'); assert.equal(view.preview().net, 0, '… and the net returns to zero'); + assert.equal(view._confirmBtn.alpha, 0, '… and the sub-bar Confirm hides again'); + assert.equal(view._clearBtn.alpha, 0, '… and Clear'); } console.log('LOCK + CLEAR: OK'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index b408a98..23a1b4c 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -300,70 +300,81 @@ it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` 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), -extended at its right end by the live **NET** readout (dim `0 cr` until you start picking), the -same style + placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above +is up — one console-blue bar in the deck's cyberpunk **MenuButton** style +(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade +actions, and the live **NET** readout, past hairline dividers; Buyback grayed, the +actions hidden until you start picking, the readout dim `0 cr`), the 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 panels slide in). +**Buy/Sell** reveals the trade panel in the middle of the screen: the **SELL grid** and the +**BUY list** side by side with just a bit of padding between (the panels slide in). ``` # first click — shop video + the tab sub-bar (rests above the deck): ┌─────────────────────────────────────────────────────────────────┐ │ (clip plays) │ -│ ┌──────────────────────────────────┐ │ -│ │ BUY/SELL │ BUYBACK │ NET 0 cr │ ← sub-bar │ -│ └──────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET 0 cr │ │ +│ └────────────────────────────────────────────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│ └─────────────────────────────────────────────────────────────────┘ # after clicking Buy/Sell — the trade panel reveals (middle of the screen): +│ SELL and BUY sit NEXT TO each other (a small gap between); +│ the trade actions + NET readout ride on the sub-bar below. ┌─────────────────────────────────────────────────────────────────┐ -│ ┌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) … │ │ -│ └──────────────────────────────┘ │ +│ ┌─────────────────┐ ┌───────────────────────────────────┐ │ +│ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │ +│ │ your cargo │ │ Crisp, clean, alive. A small mir… │ │ +│ │ (tap = sell) │ │[i] Refined Fuel 41 cr − 1 +│ │ +│ │ │ │ The lifeblood of the fleet. │ │ +│ └─────────────────┘ │ … (scrolls) … │ │ +│ └───────────────────────────────────┘ │ ├─────────────────────────────────────────────────────────────────┤ -│ ┌──────────────────────────────────┐ │ -│ │ BUY/SELL │ BUYBACK │ NET +420 cr │ ← sub-bar │ -│ └──────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET +420 cr │ │ +│ └────────────────────────────────────────────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│ └─────────────────────────────────────────────────────────────────┘ ``` -- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons are `MenuButton`s (the deck's - cyberpunk style: cut-corner panel, neon edge, hover sweep, press punch) in a console-blue - panel that rests above the command deck, anchored to the Shop button — the exact recipe of - 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. +- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear** + trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk + style: cut-corner panel, neon edge, hover sweep, press punch) in one console-blue panel + that rests above the command deck, anchored to the Shop button — the exact recipe of + the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this + visit. The bar is **extended** past hairline dividers to carry the actions *and* the + **NET** readout — one glance sees the tabs, the pending trade's credit delta, and the + button that executes it. - **Deck lock (while the shop is up)** — the command deck's other slots (Build, Ship, Quests, Take Off, Menu) go **standby**: dim, no hover, no press — the same inert language as the comm-hold pause (`ActionBar.setSlotEnabled`). **LEAVE SHOP is the only live button on the deck**; the press that opened the shop is the only way out. They re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the deck-wide comm hold still owns input if it fires mid-shop.) -- **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. +- **Confirm / Clear** — the same `MenuButton` style, on the **tab sub-bar** between the + tabs and the NET readout (they serve BOTH the trade and the buyback tabs, and persist + while you switch between them); Confirm is **disabled** (grayed) while the trade is + invalid (can't go negative, hold would overflow), and both appear once you start + picking. - **SELL (left)** — your **cargo bays**. 16 cells (the fixed grid); the first `cargoHold` 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`) 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. +- **BUY (right, next to the SELL grid)** — 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 **description** (the good's `flavor`, a smaller font that + **wraps to up to two lines** and never overlaps the price/stepper), 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, at the bar's right end past the + second divider (right of the tabs and the Confirm/Clear actions). 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). - **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm diff --git a/js/ui/shop/BuyList.js b/js/ui/shop/BuyList.js index 679d4a3..8e7142d 100644 --- a/js/ui/shop/BuyList.js +++ b/js/ui/shop/BuyList.js @@ -59,10 +59,15 @@ export class BuyList extends Phaser.GameObjects.Container { 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.rowH = bl.rowH ?? 72; this.rowGap = bl.rowGap ?? 6; this.iconSize = bl.icon ?? 44; this.iconGap = bl.iconGap ?? 12; + // The description may WRAP onto up to `maxLines` lines (real Phaser + // word-wraps it at the text column's width — see _addRow; the headless + // char-count fit below budgets maxLines lines so it never bleeds into + // the price/stepper either way). + this.descLines = Math.max(1, Math.floor(Number(bl.desc?.maxLines) || 2)); this.st = config.section('shopui.stepper', {}); // shared stepper knobs this.stepperW = this.st.w ?? 88; this.priceW = bl.price?.w ?? 64; @@ -213,6 +218,7 @@ export class BuyList extends Phaser.GameObjects.Container { } // name + description (left of the price/stepper block) + const descFont = Number(bl.desc?.fontSize) || 12; const textX = this._textX(); const nameEl = this._makeText( textX, 0, name, @@ -220,12 +226,21 @@ export class BuyList extends Phaser.GameObjects.Container { ); 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); + row.desc = this._fitText(flavor, descFont, this.descLines); const descEl = this._makeText( textX, 0, row.desc, - { fontSize: `${bl.desc?.fontSize ?? 12}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' }, + { fontSize: `${descFont}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); } + if (descEl) { + // Word-wrap inside the text column — the wrap width stops BEFORE the + // price and stepper (_descMaxW()), so a wrapped second line can never + // overlap them. The stub no-ops these (the char-count fit keeps it + // bounded there). + descEl.setWordWrap?.(true); + descEl.setWordWrapWidth?.(this._descMaxW()); + 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( @@ -296,12 +311,15 @@ export class BuyList extends Phaser.GameObjects.Container { 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) { + // Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize + // (deterministic char estimate — no text metrics needed; the headless + // stubs have none). Real Phaser word-wraps at _descMaxW() per line; + // this cap is the fail-safe that keeps the total inside maxLines lines. + _fitText(str, fontSize, maxLines = 1) { const s = String(str ?? ''); if (!s) return ''; - const maxChars = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55))); + const perLine = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55))); + const maxChars = perLine * Math.max(1, Math.floor(Number(maxLines) || 1)); if (s.length <= maxChars) return s; return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index d748fee..77e5f17 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -142,11 +142,12 @@ export class ShopView extends Phaser.GameObjects.Container { // command deck, right-aligned to the deck's "Shop" button (this.anchor), // so it reads as the same family of UI as the Menu drawer. // - // EXTENDED (data/shopui.json → preview): the live NET credit readout — - // the pending trade's credit delta — sits at the bar's right end past a - // hairline divider. Tabs AND the credit math on ONE console: the readout - // is one glance away from the tabs at every moment (entry state included, - // where it dims to "0 cr"). + // EXTENDED (data/shopui.json → preview + buttons): the trade ACTIONS + // (Clear + Confirm) and the live NET credit readout ride on the same + // console — [tabs] │ [clear][confirm] │ [NET value]. Tabs AND the trade + // math AND its actions on ONE bar: one glance sees state, the pending + // credit delta, and the button that executes it (entry state included, + // where the actions hide and the readout dims to "0 cr"). const a = this.anchor; const H = config.get('save.subBar.height', 58); const pad = config.get('save.subBar.padding', 16); @@ -163,8 +164,8 @@ export class ShopView extends Phaser.GameObjects.Container { // Build the buttons first (they measure their own width) so the row // layout uses the REAL widths — no second, divergent text-measure pass. - const mkBtn = (item) => new MenuButton( - this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id), + const mkBtn = (label, accent, onClick) => new MenuButton( + this.scene, 0, -H / 2, String(label), onClick, { screenFixed: true, fontSize: btnCfg.fontSize ?? 13, @@ -175,14 +176,27 @@ export class ShopView extends Phaser.GameObjects.Container { textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', hoverColor: c.cardBg ?? '#0a1222', - borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'), + borderColor: toCss(accent ?? c.neon ?? '#00e5ff'), }, ); - const btns = items.map(mkBtn); + const btns = items.map((it) => mkBtn(String(it.label ?? it.id), it.accent, () => this._onTabTap(it.id))); const widths = btns.map((b) => b.style?.width ?? 120); const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1); + // The trade actions — Clear (secondary, dim) + Confirm (primary, green) + // — live on the bar too (both trade AND buyback confirm here, since + // preview()/confirm() already branch on the tab). + const buttons = config.section('shopui.buttons', {}); + this._clearBtn = mkBtn(buttons.clearLabel ?? 'Clear', buttons.clearAccent ?? c.dim ?? '#7d92c4', () => this.clear()); + this._confirmBtn = mkBtn(buttons.confirmLabel ?? 'Confirm', buttons.confirmAccent ?? '#7ce8a4', () => this.confirm()); + this._confirmBtn.setAlpha?.(0); // hidden until a selection exists (_syncPreview) + this._clearBtn.setAlpha?.(0); + this._confirmBtn.setDisabled?.(true); // + invalid-trade gray, via _syncPreview + const clearW = this._clearBtn.style?.width ?? 90; + const confirmW = this._confirmBtn.style?.width ?? 110; + const actionsW = clearW + gap + confirmW; + // The NET readout — created BEFORE the panel body so its REAL width feeds // the bar width (a headless fake without text metrics falls back to an // estimate). The value is RIGHT-aligned at the bar's end, so longer @@ -204,8 +218,11 @@ export class ShopView extends Phaser.GameObjects.Container { const divW = Math.max(1, Number(pv.divider?.width) || 2); const divH = Math.max(10, Number(pv.divider?.height) || Math.round(H * 0.45)); - // Row: [ BUY/SELL ][ BUYBACK ] │ NET value - const W = Math.min(this._W() - 24, pad * 2 + tabsW + gap + divW + gap + labelW + gap + valueW); + // Row: [ BUY/SELL ][ BUYBACK ] │ [ CLEAR ][ CONFIRM ] │ NET value + const W = Math.min( + this._W() - 24, + pad * 2 + tabsW + gap + divW + gap + actionsW + gap + divW + gap + labelW + gap + valueW, + ); // Position: bottom edge on the deck's top rail (a.y), right edge on the // Shop button's right edge — the MenuSubBar recipe. The sub-bar is a @@ -224,15 +241,15 @@ export class ShopView extends Phaser.GameObjects.Container { this.add?.(bar); // The body FIRST (v4 paints children in list order — the opaque panel - // fill must come before the buttons or it would cover them). The hairline - // divider between the tabs and the NET block is part of the body, so it - // paints under both. - const divX = -W / 2 + pad + tabsW + gap + divW / 2; + // fill must come before the buttons or it would cover them). The + // hairline dividers are part of the body, so they paint under both. + const divX1 = -W / 2 + pad + tabsW + gap + divW / 2; + const divX2 = divX1 + divW / 2 + gap + actionsW + gap + divW / 2; const bodyG = this.scene.add?.graphics?.(); if (bodyG) { bodyG.setScrollFactor?.(0); bar.add?.(bodyG); - this._drawSubBarBody(bodyG, W, H, c, divX, divH); + this._drawSubBarBody(bodyG, W, H, c, [divX1, divX2], divH); } // The buttons LAST (above the body), left-anchored in the row. @@ -246,9 +263,17 @@ export class ShopView extends Phaser.GameObjects.Container { bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0); }); - // The NET block: the label right after the divider, the value right- - // aligned to the bar's end (so a growing number never shifts the label). - this._netLabel.setPosition?.(divX + divW / 2 + gap + labelW / 2, -H / 2); + // The actions: Clear then Confirm, right after the first divider. + const ax0 = divX1 + divW / 2 + gap; + this._clearBtn.setX?.(ax0 + clearW / 2); + this._confirmBtn.setX?.(ax0 + clearW + gap + confirmW / 2); + bar.add?.(this._clearBtn); + bar.add?.(this._confirmBtn); + + // The NET block: the label right after the second divider, the value + // right-aligned to the bar's end (so a growing number never shifts the + // label). + this._netLabel.setPosition?.(divX2 + divW / 2 + gap + labelW / 2, -H / 2); this._netValue.setPosition?.(W / 2 - pad - valueW / 2, -H / 2); bar.add?.(this._netLabel); bar.add?.(this._netValue); @@ -259,10 +284,11 @@ export class ShopView extends Phaser.GameObjects.Container { /** * The sub-bar panel body (the MenuSubBar recipe at full height). - * `divX`/`divH` (optional) add the hairline divider between the tabs and - * the NET readout — drawn as body so it paints under the buttons + text. + * `dividers` (optional) — hairline divider x's (one between the tabs and + * the actions, one between the actions and the NET readout) — drawn as + * body so they paint under the buttons + text. */ - _drawSubBarBody(g, W, H, c, divX, divH) { + _drawSubBarBody(g, W, H, c, dividers, divH) { if (!g || typeof g.clear !== 'function') return; const panelTop = toColor(c.panelTop ?? '#0e1930'); const panelBottom = toColor(c.panelBottom ?? '#050912'); @@ -289,9 +315,10 @@ export class ShopView extends Phaser.GameObjects.Container { g.fillStyle(ink, 0.8); g.fillRect(-W / 2 + cut, -H + 0.5, W - cut * 2, 0.8); g.fillStyle(magenta, 0.5); g.fillRect(-W / 2 + cut, -H + rail, W - cut * 2, 1); g.fillStyle(magenta, 0.35); g.fillRect(-W / 2, -1.5, W, 1.5); // seam - if (typeof divX === 'number' && divH > 0) { + const divs = Array.isArray(dividers) ? dividers : (typeof dividers === 'number' ? [dividers] : []); + if (divs.length > 0 && divH > 0) { g.fillStyle(toColor(c.border ?? '#22405f'), 0.9); - g.fillRect(divX - 1, -H / 2 - divH / 2, 2, divH); // tabs │ NET + for (const dx of divs) g.fillRect(dx - 1, -H / 2 - divH / 2, 2, divH); // section dividers } } } @@ -309,11 +336,14 @@ export class ShopView extends Phaser.GameObjects.Container { } // --------------------------------------------------------------------- - // CONTENT — the trade panel (SELL grid + BUY list + buttons) + // CONTENT — the trade panel (SELL grid + BUY list, side by side) // --------------------------------------------------------------------- _showTrade() { const grid = config.get('shopui.grid', {}); - const centerW = grid.centerW ?? 208; + // The two panels sit NEXT TO each other with just a bit of padding + // between (the trade actions + NET readout ride on the tab sub-bar — + // see _buildTabs — so the panel is pure selection). + const centerW = grid.centerW ?? 16; const W = this._W(); const sellGrid = new TradeGrid(this.scene, { @@ -333,7 +363,7 @@ export class ShopView extends Phaser.GameObjects.Container { this.sellGrid = sellGrid; this.buyList = buyList; - // Layout: [SELL grid] [center] [BUY list]. The panel's TOP edge sits at + // Layout: [SELL grid] [gap] [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). @@ -345,7 +375,6 @@ export class ShopView extends Phaser.GameObjects.Container { // 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; buyList.setPosition(rightX, this.topY); @@ -355,41 +384,8 @@ export class ShopView extends Phaser.GameObjects.Container { content.add?.(sellGrid); 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. - // (The live NET preview now rides on the tab sub-bar — see _buildTabs.) - const cx = leftX + sellGrid.width + centerW / 2; - const cy = this.topY + gridH / 2; - - // Confirm / Clear — the same cyberpunk MenuButton as the tab bar. - const c = config.section('save.colors', {}); - const btnCfg = config.section('save.subBar.button', {}); - this._confirmBtn = new MenuButton( - this.scene, cx, cy + 16, config.get('shopui.buttons.confirmLabel', 'Confirm'), () => this.confirm(), - { - screenFixed: true, - fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12, - letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, - textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', - hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.confirmAccent', '#7ce8a4')), - }, - ); - this._confirmBtn.setAlpha?.(0); - content.add?.(this._confirmBtn); - - this._clearBtn = new MenuButton( - this.scene, cx, cy + 86, config.get('shopui.buttons.clearLabel', 'Clear'), () => this.clear(), - { - screenFixed: true, - fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12, - letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, - textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', - hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.clearAccent', '#7d92c4')), - }, - ); - this._clearBtn.setAlpha?.(0); - content.add?.(this._clearBtn); - + // Confirm / Clear live on the tab sub-bar (persistent — see + // _buildTabs); the panel is pure selection. this._content = content; this.add?.(content); @@ -436,16 +432,9 @@ export class ShopView extends Phaser.GameObjects.Container { y += rowH; } - const cy = y + 24; - // (The live NET readout rides on the tab sub-bar — see _buildTabs.) - - const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54); - this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4')); - this._confirmBtn.setPosition(cx, cy + 40); - this._confirmBtn.tap(() => this.confirm()); - this._confirmBtn.setAlpha(0); - content.add?.(this._confirmBtn.container); - + // Confirm / Clear / the live NET readout all ride on the tab sub-bar + // (persistent — see _buildTabs); the buyback panel is the pure list of + // sold goods. this._content = content; this.add?.(content); this._syncPreview(); @@ -621,6 +610,8 @@ export class ShopView extends Phaser.GameObjects.Container { } _destroyContent() { + // The panel content goes; the sub-bar (tabs + actions + NET readout) + // PERSISTS across tab switches — it's not part of the content. if (this._content) { this._content.destroy?.(); this._content = null; @@ -629,18 +620,24 @@ export class ShopView extends Phaser.GameObjects.Container { this.buyList?.destroy?.(); this.sellGrid = null; this.buyList = null; - this._confirmBtn = null; - this._clearBtn = null; } destroy() { this._destroyContent(); this._tabTrade?.destroy?.(); this._tabBuyback?.destroy?.(); + this._confirmBtn?.destroy?.(); + this._clearBtn?.destroy?.(); this._netLabel?.destroy?.(); this._netValue?.destroy?.(); + this._tabTrade = null; + this._tabBuyback = null; + this._confirmBtn = null; + this._clearBtn = null; this._netLabel = null; this._netValue = null; + this.subBar?.destroy?.(); + this.subBar = null; if (typeof super.destroy === 'function') super.destroy(); }