Rework BUY-list rows: title line carries name · inventory · price · step
- 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.
This commit is contained in:
parent
828a5b8c38
commit
8810ec1b3d
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue