From 8810ec1b3d92a3c4d2fbdc3f1f83a44b815d819f Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 14 Sep 2026 18:56:27 -0600 Subject: [PATCH] =?UTF-8?q?Rework=20BUY-list=20rows:=20title=20line=20carr?= =?UTF-8?q?ies=20name=20=C2=B7=20inventory=20=C2=B7=20price=20=C2=B7=20ste?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split each row into a title line (name, "N left"/"SOLD OUT" strip, price, stepper with `stock.gap` padding between blocks) and a full-width desc line. - Add `buyList.stock` config (w, gap, fontSize, color) for the inventory strip and use it for sizing/truncation so the name never collides with the strip. - Update `_btnXs`, `_nameMaxW`, `_descMaxW` layout helpers to place the right block on the title line and give line 2 the row's full text width. - Refresh shop-ui tests to assert title-line padding, truncation budget, and desc wrap within the full row width. --- data/shopui.json | 15 +++++-- dev/shop-ui.test.mjs | 34 +++++++++----- js/ui/shop/BuyList.js | 100 ++++++++++++++++++++++++++++-------------- 3 files changed, 100 insertions(+), 49 deletions(-) diff --git a/data/shopui.json b/data/shopui.json index b80582a..a558bf6 100644 --- a/data/shopui.json +++ b/data/shopui.json @@ -56,7 +56,7 @@ "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 / description (the good's flavor, data/goods.json, wraps to up to desc.maxLines lines and NEVER over the price/stepper) … PRICE, − qty +. Live, ShopView STRETCHES the list from just past the SELL grid out to the right padding (data/landing.json → landing.hud.marginX) — `w` is only the fallback when no width is passed (headless/dev). 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.", + "_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — line 1 (the TITLE line): [icon] NAME … (padding) INVENTORY (\"N left\" / \"SOLD OUT\", the origin's remaining daily batch — model v2) … (padding) PRICE, − qty +; line 2: the good's `flavor` (data/goods.json), wrapped to the row's full width (desc.maxLines). The name is truncated to the title line's budget so it can never collide with the inventory strip or the price (`stock.gap` is the padding between name→inventory and inventory→price; `stock.w` reserves the strip's width). Live, ShopView STRETCHES the list from just past the SELL grid out to the right padding (data/landing.json → landing.hud.marginX) — `w` is only the fallback when no width is passed (headless/dev). 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 lines. `pad` insets rows from the panel edge; the title bar reuses panel.title.", "w": 560, "pad": 14, "rowH": 72, @@ -90,6 +90,12 @@ "glow": 5, "glowAlpha": 0.3, "core": 1.5 + }, + "stock": { + "w": 78, + "gap": 16, + "fontSize": 14, + "color": "#ffc94d" } }, "tooltip": { @@ -143,7 +149,10 @@ "scout": { "_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns) — EXCEPT the best-exit rows: tapping one offers the SET DESTINATION popup (the destination names the exit's WORLD — its planet of that class, the station, or the home world — not just the system; each exit carries objectId/objectName; setDestination below; the scene's ConfirmOverlay, same dialog as the map's). Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.", "topN": 3, - "setDestination": { "width": 520, "height": 176 }, + "setDestination": { + "width": 520, + "height": 176 + }, "revealMs": 140, "pad": 18, "title": { @@ -198,4 +207,4 @@ ], "empty": "No charted exit yet — every CAPITAL buys everything. Chart a world that wants this good (or a capital) and it will rank here." } -} +} \ No newline at end of file diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 2eb227d..cac7c27 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -476,29 +476,39 @@ 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: no tooltip — the WIDE ROW carries name + description + price - // inline (the row is wide enough, unlike the icon-only sell cells). + // BUY side: no tooltip — the WIDE ROW carries the info inline. The + // TITLE LINE is name · inventory · price · stepper (one line, with + // padding between each); line 2 is the description. 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 nameText = row0.objs.name.text; + assert.ok(nameText === nameB || (nameText.endsWith('…') && nameText.startsWith(nameB.slice(0, 3))), + `row carries the good name on the title line (whole, or truncated to the title-line budget) — got "${nameText}" for "${nameB}"`); + assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price on the title line'); const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? ''; 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). + // TITLE-LINE PADDING (the layout ask): name ─ gap ─ inventory ─ gap ─ + // price, all on the product-title line. stockGap pads both gaps; the + // name's budget (_nameMaxW) stops short of the inventory strip. + const bx = view.buyList._btnXs(); + const nameW = view.buyList._nameMaxW(row0); + assert.ok(view.buyList._textX() + nameW + view.buyList.stockGap <= bx.left, + 'padding between the product title and the inventory level'); + assert.ok(bx.left + view.buyList.stockGap <= bx.price, + 'padding between the inventory level and the price'); + + // DESCRIPTION (line 2 — its own line; the right block is on the title + // line above): smaller font, wraps to up to 2 lines, full row width. 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(descMaxW > 0, 'the desc line 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'); + assert.ok(view.buyList._textX() + descMaxW <= view.buyList.vpW / 2 - view.buyList.pad + 0.001, + 'desc column fits the row (line 2 is its own line, the right block is above)'); // THEME FONTS — the shop UI speaks the project's type (data/theme.json): // Ethnocentric for section headers, Centauri for body text + buttons. diff --git a/js/ui/shop/BuyList.js b/js/ui/shop/BuyList.js index 226f5b8..2a8bfb6 100644 --- a/js/ui/shop/BuyList.js +++ b/js/ui/shop/BuyList.js @@ -6,17 +6,22 @@ * ┌────────────────────────────────────────────────────────────┐ * │ BUY │ * │ ┌────────────────────────────────────────────────────────┐ │ - * │ │ [icon] Fresh Produce 24 cr − 0 +│ │ + * │ │ [icon] Fresh Produce 3 left 24 cr − 0 + │ │ * │ │ Crisp, clean, alive. A small mir… │ │ - * │ │ [icon] Refined Fuel 41 cr − 1 +│ │ + * │ │ [icon] Refined Fuel SOLD OUT 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. + * · TITLE — line 1: the NAME, then (padding) the INVENTORY ("3 left" / + * "SOLD OUT" — the origin's remaining daily batch, model v2), + * then (padding) the BUY PRICE ("24 cr") + the −/qty/+ stepper. + * Batch-limited rows carry the strip; unlimited rows (hubs' + * resale) don't — the name/price just get the padding room. + * · DESC — line 2: the one-line DESCRIPTION (the good's `flavor` from + * data/goods.json), wrapped to fit the row's full width (the + * title line owns the right side, so this line is unshared). * * A row with qty > 0 (in the pending trade) is highlighted — the same * selection the old grid's steppers tracked (getBuyMap). @@ -76,6 +81,10 @@ export class BuyList extends Phaser.GameObjects.Container { this.st = config.section('shopui.stepper', {}); // shared stepper knobs this.stepperW = this.st.w ?? 88; this.priceW = bl.price?.w ?? 64; + // The inventory strip ("N left" / "SOLD OUT", model v2) — its reserved + // width + the TITLE-LINE paddings (name → inventory, inventory → price). + this.stockW = Number(bl.stock?.w) || 78; + this.stockGap = Number(bl.stock?.gap) || 16; this.bl = bl; // The viewport (the scroll window, under the title) — the list's @@ -463,32 +472,38 @@ export class BuyList extends Phaser.GameObjects.Container { } } - // name + description (left of the price/stepper block) + // name (TITLE LINE — the first line, left of the inventory strip + + // price). Truncated to the name's budget (_nameMaxW — it stops BEFORE + // the inventory strip, or before the price on unlimited rows), so the + // title and the strip can never collide. + const nameFont = Number(bl.name?.fontSize) || 14; + const nameText = this._fitText(name, nameFont, 1, this._nameMaxW(row)); const descFont = Number(bl.desc?.fontSize) || 12; const textX = this._textX(); const nameEl = this._makeText( - textX, 0, name, - { fontSize: `${bl.name?.fontSize ?? 14}px`, color: bl.name?.color ?? '#eaf6ff', align: 'left' }, + textX, 0, nameText, + { fontSize: `${nameFont}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, descFont, this.descLines, row.left != null ? this._descMaxW({ left: 1 }) : null); + // description (LINE 2 — its own line; the title line owns the right + // block, so the desc gets the row's full text width). + row.desc = this._fitText(flavor, descFont, this.descLines, this._descMaxW()); const descEl = this._makeText( textX, 0, row.desc, { fontSize: `${descFont}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' }, ); 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). + // Word-wrap inside the text column (the row's full width — the right + // block is on the title line above, so nothing shares this line). 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 (TITLE LINE — right of the inventory strip, left of the stepper) + const bx = this._btnXs(); const priceEl = this._makeText( 0, 0, `${price} cr`, { fontSize: `${bl.price?.fontSize ?? 14}px`, color: bl.price?.color ?? '#ffc94d', align: 'right' }, @@ -497,13 +512,14 @@ export class BuyList extends Phaser.GameObjects.Container { // REMAINING BATCH (model v2 — the origin world's daily stock, the one // stateful number in the economy): "3 left" sits just left of the price - // (right-aligned, like it); 0 = SOLD OUT (magenta, the stepper is - // clamped at the same number). Only batch-limited rows have one — - // hubs' resale is unlimited (left === null → no element). + // on the TITLE LINE (right-aligned, like it), with the stockGap padding + // on both sides; 0 = SOLD OUT (magenta, the stepper is clamped at the + // same number). Only batch-limited rows have one — hubs' resale is + // unlimited (left === null → no element). if (row.left != null) { const leftEl = this._makeText( 0, 0, row.left > 0 ? `${row.left} left` : 'SOLD OUT', - { fontSize: `${bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' }, + { fontSize: `${bl.stock?.fontSize ?? bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.stock?.color ?? bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' }, ); if (leftEl) { leftEl.setOrigin?.(1, 0.5); row.objs.left = leftEl; this._objs.push(leftEl); this.add?.(leftEl); } } @@ -577,25 +593,40 @@ export class BuyList extends Phaser.GameObjects.Container { return -this.vpW / 2 + this.pad + this.iconSize + this.iconGap; } - // Stepper button x's (row-local, right end of the row). + // Stepper button x's (row-local, right end of the row) + the title-line + // right block: [name] … [inventory strip] … [price] [stepper], all on the + // product-title line. `left` is the strip's RIGHT edge (origin 1,0.5); + // stockGap pads both the name→strip and strip→price gaps. _btnXs() { const right = this.vpW / 2 - this.pad; + const price = right - (this.stepperW) - 10; // price's RIGHT edge (origin 1,0.5) + const left = price - this.priceW - this.stockGap; // strip's RIGHT edge return { plus: right - 11, qty: right - 44, minus: right - 77, - price: right - (this.stepperW) - 10, // price's RIGHT edge (origin 1,0.5) + price, + left, }; } - // Max description width (text block → price), in px. The price reserves - // its full width ("1,240 cr") so the description never collides with it; - // batch-limited rows reserve the "N left" / "SOLD OUT" strip between the - // desc and the price (`row.left` finite → the extra reservation). - _descMaxW(row = null) { - let w = this._btnXs().price - this.priceW - 14 - this._textX(); - if (row && row.left != null) w -= (this.stockW ?? 66); - return Math.max(20, w); + // The NAME's budget on the title line (row-local px): from the text column + // to the inventory strip (batch-limited rows), or to the price (unlimited + // rows — no strip). stockGap padding is kept on the strip/price side. + _nameMaxW(row) { + const bx = this._btnXs(); + const rightEdge = (row && row.left != null) + ? bx.left - this.stockW - this.stockGap + : bx.price - this.priceW - this.stockGap; + return Math.max(20, rightEdge - this._textX()); + } + + // Max description width (px) — LINE 2 is the desc's own line (the + // name/inventory/price/stepper block sits on the title line above), so + // it gets the row's full text width. + _descMaxW() { + const right = this.vpW / 2 - this.pad; + return Math.max(20, right - this._textX() - 4); } // Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize @@ -685,15 +716,16 @@ export class BuyList extends Phaser.GameObjects.Container { const o = row.objs; const tx = cx + this._textX(); // row-local → list-local (row center at cx) const bx = this._btnXs(); + const titleY = y - this.rowH * 0.18; // the TITLE LINE (name · inventory · price · stepper) 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.name?.setPosition?.(tx, titleY); o.desc?.setPosition?.(tx, y + this.rowH * 0.27); - o.price?.setPosition?.(cx + bx.price, y); - o.left?.setPosition?.(cx + bx.price - this.priceW - 8, y); - o.minus?.setPosition?.(cx + bx.minus, y); - o.qty?.setPosition?.(cx + bx.qty, y); - o.plus?.setPosition?.(cx + bx.plus, y); + o.price?.setPosition?.(cx + bx.price, titleY); + o.left?.setPosition?.(cx + bx.left, titleY); + o.minus?.setPosition?.(cx + bx.minus, titleY); + o.qty?.setPosition?.(cx + bx.qty, titleY); + o.plus?.setPosition?.(cx + bx.plus, titleY); if (row.shown) { for (const el of [o.icon, o.name, o.desc, o.price, o.left, o.minus, o.qty, o.plus]) { el?.setAlpha?.(this._elInVp(el, vpTop, vpBot) ? 1 : 0);