Widen the BUY list right up against the SELL grid; the row description drops to 11px and wraps to two lines without touching the price/stepper

The center column is gone: Confirm + Clear move onto the tab sub-bar
([tabs] | [Clear][Confirm] | NET value), serving both the trade and
buyback tabs, so the two panels sit side by side with just a bit of
padding between (grid.centerW 208 -> 16). The row description keeps the
smaller font, word-wraps inside the text column (wrap width stops
before the price), and the char-count budget now spans two lines so the
headless path stays bounded; rowH grows 64 -> 72 to fit both lines.
The buttons persist across tab switches (they were content before and
died with it), Confirm stays grayed while the trade is invalid, and the
stub gains setX/setY so the bar layout is testable.
This commit is contained in:
Brian Fertig 2026-09-11 23:47:43 -06:00
parent 3fdd825570
commit cc4a15cafd
6 changed files with 202 additions and 135 deletions

View File

@ -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, "enabled": true,
"tabs": { "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 <value> — 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", "buySell": "Buy/Sell",
"buyback": "Buyback", "buyback": "Buyback",
"w": 184, "h": 46, "gap": 14,
"y": 44,
"activeAccent": "#00e5ff", "activeAccent": "#00e5ff",
"disabledAlpha": 0.34 "disabledAlpha": 0.34
}, },
@ -22,10 +20,10 @@
}, },
"grid": { "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, "cols": 4, "rows": 4,
"slot": 96, "gap": 10, "slot": 96, "gap": 10,
"centerW": 208 "centerW": 16
}, },
"slot": { "slot": {
@ -42,15 +40,15 @@
}, },
"buyList": { "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, "w": 560,
"pad": 14, "pad": 14,
"rowH": 64, "rowH": 72,
"rowGap": 6, "rowGap": 6,
"icon": 44, "icon": 44,
"iconGap": 12, "iconGap": 12,
"name": { "fontSize": 14, "color": "#eaf6ff" }, "name": { "fontSize": 14, "color": "#eaf6ff" },
"desc": { "fontSize": 12, "color": "#7d92c4" }, "desc": { "fontSize": 11, "color": "#7d92c4", "maxLines": 2 },
"price": { "fontSize": 14, "color": "#ffc94d", "w": 64 }, "price": { "fontSize": 14, "color": "#ffc94d", "w": 64 },
"rowBg": "#0e1728", "rowBg": "#0e1728",
"rowBgAlpha": 0.55, "rowBgAlpha": 0.55,
@ -94,19 +92,15 @@
}, },
"buttons": { "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.", "_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.",
"w": 158, "h": 54,
"confirmLabel": "Confirm", "confirmLabel": "Confirm",
"clearLabel": "Clear", "clearLabel": "Clear",
"backLabel": "‹ Menu",
"accent": "#00e5ff",
"confirmAccent": "#7ce8a4", "confirmAccent": "#7ce8a4",
"clearAccent": "#7d92c4", "clearAccent": "#7d92c4"
"disabled": "#7d92c4"
}, },
"animation": { "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, "slideMs": 340,
"staggerMs": 26, "staggerMs": 26,
"ease": "Cubic.Out" "ease": "Cubic.Out"

View File

@ -21,6 +21,8 @@ class GameObject {
setTint(t) { this.tint = t; return this; } setTint(t) { this.tint = t; return this; }
setBlendMode() { return this; } setBlendMode() { return this; }
setPosition(x, y) { this.x = x; this.y = y; 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; } setStrokeStyle() { return this; }
setSize(w, h) { this.width = w; this.height = h; return this; } setSize(w, h) { this.width = w; this.height = h; return this; }
set() { return this; } set() { return this; }

View File

@ -83,6 +83,7 @@ function stubObject(extra = {}) {
setColor(c) { this.color = c; return this; }, setColor(c) { this.color = c; return this; },
setFontSize() { return this; }, setFontSize() { return this; },
setWordWrap() { return this; }, setWordWrap() { return this; },
setWordWrapWidth(w) { this._wrapW = w; return this; },
setLineSpacing() { return this; }, setLineSpacing() { return this; },
setPadding() { return this; }, setPadding() { return this; },
setScrollFactor() { 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.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'); 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. // Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
view.setTab('trade'); view.setTab('trade');
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab'); assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab');
@ -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.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.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)'); 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'); 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.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'); 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 ?? ''; 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)'); 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): // THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons. // Ethnocentric for section headers, Centauri for body text + buttons.
const fam = (o) => o?._style?.fontFamily ?? ''; 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'); assert.equal(pv.valid, true, '… and it\u2019s legal');
// The live NET readout rides on the TAB SUB-BAR (not the center column) — // 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._netValue, 'the sub-bar carries the NET value readout');
assert.ok(view._netLabel, '… with the NET label'); assert.ok(view._netLabel, '… with the NET label');
assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr'); 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; const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through'); 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(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed'); 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._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'); 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.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits'); assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
assert.equal(vb.confirm(), false, '… and Confirm is refused'); 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.credits, 0, '… nothing spent');
assert.equal(broke.cargo.used, 0, '… nothing bought'); 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(); pv = vf.preview();
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits'); 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(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; const before = full.credits;
assert.equal(vf.confirm(), true, '… and it goes through'); 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)'); 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(); view.clear();
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection'); 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.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'); console.log('LOCK + CLEAR: OK');

View File

@ -300,70 +300,81 @@ it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid`
side) + a `BuyList` (the BUY side). side) + a `BuyList` (the BUY side).
**Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar** **Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar**
is up — the cyberpunk **MenuButton** pair `[ BUY/SELL | BUYBACK ]` (Buyback grayed out), is up — one console-blue bar in the deck's cyberpunk **MenuButton** style
extended at its right end by the live **NET** readout (dim `0 cr` until you start picking), the (`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade
same style + placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above 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 the command deck**, right-aligned to the deck's **Shop** button — so it reads as the same family
of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking
**Buy/Sell** reveals the trade panel in the middle of the screen (the 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): # first click — shop video + the tab sub-bar (rests above the deck):
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ (clip plays) │ │ (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│ │ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
# after clicking Buy/Sell — the trade panel reveals (middle of the screen): # after clicking Buy/Sell — the trade panel reveals (middle of the screen):
│ SELL 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 +│ │ │ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │
│ │(cargo) │ │ Crisp, clean, alive… │ │ │ │ your cargo │ │ Crisp, clean, alive. A small mir… │ │
│ └────────┘ │[i] Refined Fuel 41 cr − 1 +│ │ │ │ (tap = sell) │ │[i] Refined Fuel 41 cr − 1 +│ │
│ │ The lifeblood of the fleet │ │ │ │ │ │ The lifeblood of the fleet. │ │
│ │ … (scrolls) … │ │ │ └─────────────────┘ │ … (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│ │ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons are `MenuButton`s (the deck's - **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear**
cyberpunk style: cut-corner panel, neon edge, hover sweep, press punch) in a console-blue trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk
panel that rests above the command deck, anchored to the Shop button — the exact recipe of style: cut-corner panel, neon edge, hover sweep, press punch) in one console-blue panel
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit. that rests above the command deck, anchored to the Shop button — the exact recipe of
The bar is **extended** past a hairline divider to carry the live **NET** readout — the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this
one glance sees the tabs *and* the pending trade's credit delta. 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, - **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 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 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 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 re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the
deck-wide comm hold still owns input if it fires mid-shop.) 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 / Clear** — the same `MenuButton` style, on the **tab sub-bar** between the
Confirm is **disabled** (grayed) while the trade is invalid (can't go negative, hold would tabs and the NET readout (they serve BOTH the trade and the buyback tabs, and persist
overflow), and both appear once you start picking. 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` - **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 are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
quantity is simply the number of its bays that are checked. quantity is simply the number of its bays that are checked.
- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`) as a - **BUY (right, next to the SELL grid)** — the shop's **stock** (12–16 distinct goods from
**scrollable list of WIDE ROWS**: each row is the good's **icon**, its **name** + a `Economy.shopFor`) as a **scrollable list of WIDE ROWS**: each row is the good's
one-line **description** (the good's `flavor`), and the **price** + a **−/qty/+ stepper** **icon**, its **name** + a **description** (the good's `flavor`, a smaller font that
on the right. The stock is longer than the panel, so the list **scrolls** (drag on the **wraps to up to two lines** and never overlaps the price/stepper), and the **price** +
list body or the mouse wheel) — rows outside the window are clipped; the selection is a **−/qty/+ stepper** on the right. The stock is longer than the panel, so the list
data, so stepping a good works whether its row is on screen or not. Buy as many as you **scrolls** (drag on the list body or the mouse wheel) — rows outside the window are
like (capped by credits + hold room). clipped; the selection is data, so stepping a good works whether its row is on screen
- **NET readout (tab sub-bar)** — the live credit delta, right of the tabs past the divider. 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 Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check
bays / step goods (it's the same `preview()` the Confirm button is gated on). bays / step goods (it's the same `preview()` the Confirm button is gated on).
- **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm - **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm

View File

@ -59,10 +59,15 @@ export class BuyList extends Phaser.GameObjects.Container {
this.height = Math.max(160, Number(o.height) || 480); this.height = Math.max(160, Number(o.height) || 480);
this.pad = bl.pad ?? 14; this.pad = bl.pad ?? 14;
this.titleH = config.get('shopui.panel.title.h', 34); 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.rowGap = bl.rowGap ?? 6;
this.iconSize = bl.icon ?? 44; this.iconSize = bl.icon ?? 44;
this.iconGap = bl.iconGap ?? 12; 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.st = config.section('shopui.stepper', {}); // shared stepper knobs
this.stepperW = this.st.w ?? 88; this.stepperW = this.st.w ?? 88;
this.priceW = bl.price?.w ?? 64; 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) // name + description (left of the price/stepper block)
const descFont = Number(bl.desc?.fontSize) || 12;
const textX = this._textX(); const textX = this._textX();
const nameEl = this._makeText( const nameEl = this._makeText(
textX, 0, name, 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); } 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( const descEl = this._makeText(
textX, 0, row.desc, 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" // price (right of the text, left of the stepper) — "24 cr"
const priceEl = this._makeText( const priceEl = this._makeText(
@ -296,12 +311,15 @@ export class BuyList extends Phaser.GameObjects.Container {
return this._btnXs().price - this.priceW - 14 - this._textX(); return this._btnXs().price - this.priceW - 14 - this._textX();
} }
// Truncate `str` to fit ~maxW px at fontSize (deterministic char estimate // Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
// — no text metrics needed; the headless stubs have none). // (deterministic char estimate — no text metrics needed; the headless
_fitText(str, fontSize) { // 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 ?? ''); const s = String(str ?? '');
if (!s) return ''; 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; if (s.length <= maxChars) return s;
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
} }

View File

@ -142,11 +142,12 @@ export class ShopView extends Phaser.GameObjects.Container {
// command deck, right-aligned to the deck's "Shop" button (this.anchor), // 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. // so it reads as the same family of UI as the Menu drawer.
// //
// EXTENDED (data/shopui.json → preview): the live NET credit readout — // EXTENDED (data/shopui.json → preview + buttons): the trade ACTIONS
// the pending trade's credit delta — sits at the bar's right end past a // (Clear + Confirm) and the live NET credit readout ride on the same
// hairline divider. Tabs AND the credit math on ONE console: the readout // console — [tabs] │ [clear][confirm] │ [NET value]. Tabs AND the trade
// is one glance away from the tabs at every moment (entry state included, // math AND its actions on ONE bar: one glance sees state, the pending
// where it dims to "0 cr"). // 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 a = this.anchor;
const H = config.get('save.subBar.height', 58); const H = config.get('save.subBar.height', 58);
const pad = config.get('save.subBar.padding', 16); 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 // Build the buttons first (they measure their own width) so the row
// layout uses the REAL widths — no second, divergent text-measure pass. // layout uses the REAL widths — no second, divergent text-measure pass.
const mkBtn = (item) => new MenuButton( const mkBtn = (label, accent, onClick) => new MenuButton(
this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id), this.scene, 0, -H / 2, String(label), onClick,
{ {
screenFixed: true, screenFixed: true,
fontSize: btnCfg.fontSize ?? 13, fontSize: btnCfg.fontSize ?? 13,
@ -175,14 +176,27 @@ export class ShopView extends Phaser.GameObjects.Container {
textColor: c.ink ?? '#eaf6ff', textColor: c.ink ?? '#eaf6ff',
bgColor: c.cardBg ?? '#0a1222', bgColor: c.cardBg ?? '#0a1222',
hoverColor: 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 widths = btns.map((b) => b.style?.width ?? 120);
const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1); 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 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 // 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 // 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 divW = Math.max(1, Number(pv.divider?.width) || 2);
const divH = Math.max(10, Number(pv.divider?.height) || Math.round(H * 0.45)); const divH = Math.max(10, Number(pv.divider?.height) || Math.round(H * 0.45));
// Row: [ BUY/SELL ][ BUYBACK ] │ NET value // Row: [ BUY/SELL ][ BUYBACK ] │ [ CLEAR ][ CONFIRM ] │ NET value
const W = Math.min(this._W() - 24, pad * 2 + tabsW + gap + divW + gap + labelW + gap + valueW); 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 // 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 // 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); this.add?.(bar);
// The body FIRST (v4 paints children in list order — the opaque panel // 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 // fill must come before the buttons or it would cover them). The
// divider between the tabs and the NET block is part of the body, so it // hairline dividers are part of the body, so they paint under both.
// paints under both. const divX1 = -W / 2 + pad + tabsW + gap + divW / 2;
const divX = -W / 2 + pad + tabsW + gap + divW / 2; const divX2 = divX1 + divW / 2 + gap + actionsW + gap + divW / 2;
const bodyG = this.scene.add?.graphics?.(); const bodyG = this.scene.add?.graphics?.();
if (bodyG) { if (bodyG) {
bodyG.setScrollFactor?.(0); bodyG.setScrollFactor?.(0);
bar.add?.(bodyG); 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. // 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); 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- // The actions: Clear then Confirm, right after the first divider.
// aligned to the bar's end (so a growing number never shifts the label). const ax0 = divX1 + divW / 2 + gap;
this._netLabel.setPosition?.(divX + divW / 2 + gap + labelW / 2, -H / 2); 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); this._netValue.setPosition?.(W / 2 - pad - valueW / 2, -H / 2);
bar.add?.(this._netLabel); bar.add?.(this._netLabel);
bar.add?.(this._netValue); 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). * The sub-bar panel body (the MenuSubBar recipe at full height).
* `divX`/`divH` (optional) add the hairline divider between the tabs and * `dividers` (optional) — hairline divider x's (one between the tabs and
* the NET readout — drawn as body so it paints under the buttons + text. * 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; if (!g || typeof g.clear !== 'function') return;
const panelTop = toColor(c.panelTop ?? '#0e1930'); const panelTop = toColor(c.panelTop ?? '#0e1930');
const panelBottom = toColor(c.panelBottom ?? '#050912'); 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(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.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 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.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() { _showTrade() {
const grid = config.get('shopui.grid', {}); 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 W = this._W();
const sellGrid = new TradeGrid(this.scene, { const sellGrid = new TradeGrid(this.scene, {
@ -333,7 +363,7 @@ export class ShopView extends Phaser.GameObjects.Container {
this.sellGrid = sellGrid; this.sellGrid = sellGrid;
this.buyList = buyList; 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 // this.topY — just under the surface HUD's planet text (upper-left) — so
// the whole panel reads as one unit near the top of the screen (not // the whole panel reads as one unit near the top of the screen (not
// hanging down to the deck/sub-bar). // hanging down to the deck/sub-bar).
@ -345,7 +375,6 @@ export class ShopView extends Phaser.GameObjects.Container {
// the panel half a grid lower and clips the bottom rows. // the panel half a grid lower and clips the bottom rows.
const totalW = sellGrid.width + centerW + buyList.width; const totalW = sellGrid.width + centerW + buyList.width;
const leftX = Math.max(8, (W - totalW) / 2); const leftX = Math.max(8, (W - totalW) / 2);
const gridH = sellGrid.height;
sellGrid.setPosition(leftX, this.topY); sellGrid.setPosition(leftX, this.topY);
const rightX = leftX + sellGrid.width + centerW; const rightX = leftX + sellGrid.width + centerW;
buyList.setPosition(rightX, this.topY); buyList.setPosition(rightX, this.topY);
@ -355,41 +384,8 @@ export class ShopView extends Phaser.GameObjects.Container {
content.add?.(sellGrid); content.add?.(sellGrid);
content.add?.(buyList); content.add?.(buyList);
// center column: Confirm + Clear — vertically centered with the grids, // Confirm / Clear live on the tab sub-bar (persistent — see
// so the whole panel is one cohesive block near the top of the screen. // _buildTabs); the panel is pure selection.
// (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);
this._content = content; this._content = content;
this.add?.(content); this.add?.(content);
@ -436,16 +432,9 @@ export class ShopView extends Phaser.GameObjects.Container {
y += rowH; y += rowH;
} }
const cy = y + 24; // Confirm / Clear / the live NET readout all ride on the tab sub-bar
// (The live NET readout rides on the tab sub-bar — see _buildTabs.) // (persistent — see _buildTabs); the buyback panel is the pure list of
// sold goods.
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);
this._content = content; this._content = content;
this.add?.(content); this.add?.(content);
this._syncPreview(); this._syncPreview();
@ -621,6 +610,8 @@ export class ShopView extends Phaser.GameObjects.Container {
} }
_destroyContent() { _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) { if (this._content) {
this._content.destroy?.(); this._content.destroy?.();
this._content = null; this._content = null;
@ -629,18 +620,24 @@ export class ShopView extends Phaser.GameObjects.Container {
this.buyList?.destroy?.(); this.buyList?.destroy?.();
this.sellGrid = null; this.sellGrid = null;
this.buyList = null; this.buyList = null;
this._confirmBtn = null;
this._clearBtn = null;
} }
destroy() { destroy() {
this._destroyContent(); this._destroyContent();
this._tabTrade?.destroy?.(); this._tabTrade?.destroy?.();
this._tabBuyback?.destroy?.(); this._tabBuyback?.destroy?.();
this._confirmBtn?.destroy?.();
this._clearBtn?.destroy?.();
this._netLabel?.destroy?.(); this._netLabel?.destroy?.();
this._netValue?.destroy?.(); this._netValue?.destroy?.();
this._tabTrade = null;
this._tabBuyback = null;
this._confirmBtn = null;
this._clearBtn = null;
this._netLabel = null; this._netLabel = null;
this._netValue = null; this._netValue = null;
this.subBar?.destroy?.();
this.subBar = null;
if (typeof super.destroy === 'function') super.destroy(); if (typeof super.destroy === 'function') super.destroy();
} }