Align shop sub-bar to span full action deck width

- Widen the tab sub-bar from a button-width pill to the full deck span (left/right edges of the main action bar), centering it on that span.
- Pack tabs at the left edge and Clear/Confirm + NET readout at the right edge, compressing gaps rather than button widths when the deck is narrow.
- Right-anchor the NET value (origin 1,0.5) and reserve its widest slot so growing totals never shift the label or overflow the bar.
- Extend SurfaceScene.shopAnchor to expose left/right edges for the sub-bar layout.
- Add sub-bar probe assertions in dev/shop-shot.mjs and unit tests in dev/shop-ui.test.mjs covering span, alignment, non-overlap, and NET math.
- Add carol character image and lava-land video assets.
This commit is contained in:
Brian Fertig 2026-09-12 09:44:30 -06:00
parent e9eda003b4
commit 071f3ac0e4
6 changed files with 180 additions and 54 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

View File

@ -364,6 +364,70 @@ const bounds = (o) => {
};
out.pass = out.pass && out.buyClip.ok;
// ---- SUB-BAR PROBE (full deck span + right-side actions/NET, no overlap) ----
// The ask: the sub-bar's LEFT edge aligns with the main action bar's left
// edge, its RIGHT edge with the action bar's right edge, and the credit
// readout (NET) + Confirm/Clear sit on the RIGHT — without overlapping the
// tabs (left) or each other. Add a few items to the purchasable section
// (BUY rows) and verify the NET math updates while the layout holds.
const ab = ss.actionBar;
const deck = ab?.rect ? { x: ab.rect.x, y: ab.rect.y, w: ab.rect.w, h: ab.rect.h } : null;
// A few items in the purchasable section (first three BUY rows).
const buys = [];
for (let i = 0; i < Math.min(3, bl.rows.length); i++) {
const id = bl.rows[i].id;
const qty = (i % 2) + 1; // 1, 2, 1
bl.buyQty.set(id, qty);
const price = round(bl.stock.find((s) => s.id === id)?.price ?? 0);
buys.push({ id, qty, price });
}
sv._syncPreview();
await sleep(80);
const expectedNet = -buys.reduce((s, b) => s + b.qty * b.price, 0);
const netText = sv._netValue.text ?? null;
const netNum = parseInt(String(netText).replace(/[^\-0-9]/g, ''), 10);
// World-space x ranges of the bar's items (bar local x + bar world x),
// honouring each object's originX (the NET value is right-anchored, 1).
const barCx = sv.subBar.x;
const barHalf = sv.subBarW / 2;
const barWx = (localX) => barCx + localX;
const span = (el) => {
const w = (el?.style?.width ?? el?.width) || 0;
const ox = (typeof el?.originX === 'number') ? el.originX : 0.5;
const cx = barWx(el.x);
return [round(cx - w * ox), round(cx + w * (1 - ox))];
};
const tabTrade = span(sv._tabTrade);
const tabBuy = span(sv._tabBuyback);
const clearSp = span(sv._clearBtn);
const confirmSp = span(sv._confirmBtn);
const netLabelSp = span(sv._netLabel);
const netValueSp = span(sv._netValue);
const barLeft = round(barCx - barHalf);
const barRight = round(barCx + barHalf);
const subBarOk = !!deck
&& Math.abs((deck.x + deck.w / 2) - barCx) < 1.5 // centred on the deck span
&& Math.abs(deck.w - sv.subBarW) < 1.5 // full deck width
&& tabTrade[0] >= barLeft + 8 // tabs inside the bar
&& tabBuy[1] < clearSp[0] // tabs │ Clear (no overlap)
&& clearSp[1] < confirmSp[0] // Clear │ Confirm
&& confirmSp[1] < netLabelSp[0] // Confirm │ NET label
&& netLabelSp[1] < netValueSp[0] // NET label │ live value
&& netLabelSp[1] <= barWx(sv._netValue.x) - (sv._netValueW || 0) + 1 // …and of the WIDEST value (reserved slot)
&& netValueSp[1] <= barRight - 8 // value inside the bar's end
&& netText !== null && netNum === expectedNet // the math is right
&& (sv._clearBtn.alpha ?? 0) > 0.9 && (sv._confirmBtn.alpha ?? 0) > 0.9; // revealed
out.subBar = {
deck,
bar: { cx: round(barCx), left: barLeft, right: barRight, w: round(sv.subBarW), h: sv.subBarH },
buys,
net: { text: netText, expected: expectedNet, valueColor: sv._netValue?.style?.color ?? sv._netValue?._style?.color ?? null },
spans: { tabTrade, tabBuyback: tabBuy, clear: clearSp, confirm: confirmSp, netLabel: netLabelSp, netValue: netValueSp },
alphas: { clear: sv._clearBtn.alpha, confirm: sv._confirmBtn.alpha, disabled: sv._confirmBtn.disabled },
ok: subBarOk,
};
out.pass = out.pass && out.subBar.ok;
out.screen = { w: ss.scale.width, h: ss.scale.height };
out.topY = sv.topY ?? null;
out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })();

View File

@ -215,6 +215,30 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
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');
// The bar spans the FULL DECK — the anchor's left/right edges (the main
// action bar's own span): tabs packed at the LEFT edge, the trade actions
// + NET readout packed at the RIGHT edge, the value right-aligned at the
// bar's end (a growing number never shifts the label or the buttons).
const pad = config.get('save.subBar.padding', 16);
assert.ok(view.subBarW > 0 && view.anchor.left < view.anchor.right, 'anchor carries the deck span (left < right)');
assert.equal(view.subBarW, view.anchor.right - view.anchor.left, 'sub-bar spans the full deck width (left edge → right edge)');
assert.equal(view.subBar.x, (view.anchor.left + view.anchor.right) / 2, 'sub-bar is centred on the deck span');
const half = view.subBarW / 2;
const tw = view._tabTrade.style.width;
assert.ok(Math.abs(view._tabTrade.x - (-half + pad + tw / 2)) < 1, `tabs pack at the bar's LEFT edge (tabTrade.x=${view._tabTrade.x}, want ${(-half + pad + tw / 2).toFixed(1)})`);
// The NET value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's end: its
// anchor sits at barRight − pad and its width extends leftward, so a
// longer total never overflows the bar. The label stays clear of even the
// WIDEST value (the reserved slot, _netValueW), so nothing overlaps.
assert.ok(Math.abs(view._netValue.x - (half - pad)) < 1, `NET value right-anchored at the bar's RIGHT edge (netValue.x=${view._netValue.x}, want ${(half - pad).toFixed(1)})`);
assert.ok(view._netLabel.x + view._netLabel.width / 2 < view._netValue.x - view._netValueW + 0.001, 'NET label sits clear of the widest value (reserved slot)');
// No overlap, left to right: tabs │ Clear │ Confirm │ NET label.
const rightEdge = (el) => el.x + (el.style?.width ?? el.width) / 2;
const leftEdge = (el) => el.x - (el.style?.width ?? el.width) / 2;
assert.ok(rightEdge(view._tabBuyback) < leftEdge(view._clearBtn), 'BUYBACK tab does not overlap Clear');
assert.ok(rightEdge(view._clearBtn) < leftEdge(view._confirmBtn), 'Clear does not overlap Confirm');
assert.ok(rightEdge(view._confirmBtn) < leftEdge(view._netLabel), 'Confirm does not overlap the NET label');
// 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');

View File

@ -403,11 +403,17 @@ export class SurfaceScene extends Phaser.Scene {
* if the deck is missing (dev).
*/
shopAnchor() {
const slot = this.actionBar?.slots?.find((s) => s.id === 'shop');
const bar = this.actionBar;
const slot = bar?.slots?.find((s) => s.id === 'shop');
// The bar spans the FULL DECK — left edge on the deck's left, right
// edge on its right (the main action bar's own rect), resting on its
// top rail. Falls back to a bottom-center span if the deck is missing.
const left = bar?.rect ? bar.rect.x : this.scale.width * 0.06;
const right = bar?.rect ? bar.rect.x + bar.rect.w : this.scale.width * 0.94;
if (slot) {
return { x: slot.slot.x, y: this.actionBar.rect.y, w: this.actionBar.style.bw };
return { x: slot.slot.x, y: bar.rect.y, w: bar.style.bw, left, right };
}
return { x: this.scale.width / 2, y: this.scale.height - 58, w: 120 };
return { x: this.scale.width / 2, y: this.scale.height - 58, w: 120, left, right };
}
destroyShopView() {

View File

@ -11,7 +11,8 @@
* │ └────────────────────────┘ │
* └────────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │
* │ BUY/SELL BUYBACK ···console··· CLEAR CONFIRM │ NET +420 cr │
* │ ← spans the FULL command deck (left + right edges) → │
* └────────────────────────────────────────────────────────────────┘
*
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
@ -21,11 +22,13 @@
* stock as WIDE ROWS — icon + name + one-line description (the good's
* flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
* full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0)
* sits on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons past a
* hairline divider (tabs AND the pending trade's credit math on one
* console); Confirm/Clear stay in the center. Confirm is disabled while
* the trade is invalid (can't go negative, hold would overflow). SELL→BUY
* happens atomically (sell to free room for a buy).
* sits on the TAB SUB-BAR at its RIGHT edge: the tabs pack at the bar's
* LEFT, the trade actions (Clear + Confirm) and the credit readout pack
* at the RIGHT — the value right-aligned at the bar's end, so a growing
* number never shifts the label, the buttons, or the tabs (no overlap).
* Confirm is disabled while the trade is invalid (can't go negative, hold
* would overflow). SELL→BUY happens atomically (sell to free room for a
* buy).
*
* BUYBACK (session-local only) — the second tab. After the player SELLs
* something, the goods they sold are listed here and can be bought BACK —
@ -59,10 +62,12 @@ export class ShopView extends Phaser.GameObjects.Container {
* gameScene — to refresh the credits/minerals HUD after a trade.
* onLeave — called by close() so the SurfaceScene can restore the
* surface loop (the deck's "Leave Shop" does this too).
* anchor — { x, y, w } the sub-bar's anchor: the deck's "Shop"
* button center x, the deck's top-rail y (the bar's bottom
* edge rests on it), and the button width (right-aligns the
* bar) — the MenuSubBar recipe (see SurfaceScene.shopAnchor).
* anchor — { x, y, w, left, right } the sub-bar's anchor: the
* deck's "Shop" button (x, w — legacy), the deck's
* top-rail y (the bar's bottom edge rests on it), and
* left/right — the deck's left/right edges. The bar
* spans the FULL deck — the same span as the main
* action bar (see SurfaceScene.shopAnchor).
*/
constructor(scene, o) {
super(scene, 0, 0);
@ -73,9 +78,9 @@ export class ShopView extends Phaser.GameObjects.Container {
this.systemId = o.systemId;
this.gameScene = o.gameScene;
this.onLeave = o.onLeave;
// The tab sub-bar anchor (the deck's Shop button). Falls back to a
// The tab sub-bar anchor (the deck's span). Falls back to a
// bottom-center bar if the scene doesn't provide one (headless/dev).
this.anchor = o.anchor ?? { x: this._W() / 2, y: this._H() - 58, w: 120 };
this.anchor = o.anchor ?? { x: this._W() / 2, y: this._H() - 58, w: 120, left: 20, right: this._W() - 20 };
// The trade panel's TOP edge — just under the surface HUD's planet text
// (the scene computes it from its own hudName/hudLine). Falls back to a
// small top margin if the scene doesn't provide one (headless/dev).
@ -144,10 +149,11 @@ export class ShopView extends Phaser.GameObjects.Container {
//
// 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").
// console, packed at the RIGHT edge —
// [tabs ···console··· actions] │ [NET value]
// while the tabs pack at the LEFT edge. 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);
@ -199,40 +205,76 @@ export class ShopView extends Phaser.GameObjects.Container {
// 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
// numbers ("+1,240 cr") never need the bar to grow.
// estimate). The value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's
// end, so a longer number ("−9,999 cr") extends LEFT and never spills
// past the bar or shifts the label. The label sits clear of the WIDEST
// plausible value (reserved below) so nothing overlaps at any total.
const labelStr = String(pv.label ?? 'NET');
const valueFont = Math.max(8, Number(pv.valueFontSize) || 22);
// Reserve the value slot with the widest plausible reading so the label
// never overlaps a long total (the value itself only grows leftward).
const reserveStr = String(pv.reserve ?? '\u221299,999 cr');
this._netLabel = this._makeText(0, -H / 2, labelStr, {
fontSize: `${Math.max(8, Number(pv.labelFontSize) || 12)}px`,
color: toCss(pv.labelColor ?? c.dim ?? '#7d92c4'),
align: 'left',
});
this._netValue = this._makeText(0, -H / 2, '0 cr', {
this._netValue = this._makeText(0, -H / 2, reserveStr, {
fontSize: `${valueFont}px`,
color: toCss(pv.zeroColor ?? pv.labelColor ?? '#7d92c4'),
align: 'left',
});
const labelW = (Number(this._netLabel?.width) > 0 ? this._netLabel.width : labelStr.length * 8) || 30;
const valueW = (Number(this._netValue?.width) > 0 ? this._netValue.width : 4 * valueFont * 0.6) || 40;
const valueW = (Number(this._netValue?.width) > 0 ? this._netValue.width : reserveStr.length * valueFont * 0.55) || 90;
this._netValueW = valueW; // reserved (widest) width — the label keeps clear of it
// Reset to the live (zero) reading and anchor the RIGHT edge so the
// value stays pinned to the bar's end as its length changes.
this._netValue.setText?.('0 cr');
this._netValue.setOrigin?.(1, 0.5);
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 ] │ [ 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
// CHILD of the ShopView (which stays at the screen origin, 0,0) at
// (cx, a.y) — so the trade panel (a SIBLING at 0,0) still renders in
// screen space, not offset down to the deck.
// Row: [ BUY/SELL ][ BUYBACK ] ···console··· [ CLEAR ][ CONFIRM ] │ NET value
// The bar spans the FULL DECK (anchor.left/right — the main action
// bar's left and right edges): tabs packed at the LEFT edge, the trade
// actions + credit readout packed at the RIGHT edge, the value
// right-aligned at the bar's end (a growing number never shifts the
// label, the buttons, or the tabs — nothing overlaps on either end).
const sceneW = this._W();
let cx = a.x + a.w / 2 - W / 2;
cx = Phaser.Math.Clamp(cx, W / 2 + 12, sceneW - W / 2 - 12);
const fixedW = pad * 2 + tabsW + divW * 2 + actionsW + labelW + valueW;
const span = (Number.isFinite(a.left) && Number.isFinite(a.right) && a.right - a.left > fixedW + 28)
? { left: a.left, right: a.right }
: null;
// Seven gap slots: tab1·tab2, tab2·div1, div1·clear, clear·confirm,
// confirm·div2, div2·label, label·value. If the content can't fit the
// span (a narrow deck), the gaps compress — never the button widths.
let W, g;
if (span) {
W = span.right - span.left;
g = Math.min(gap, Math.max(4, (W - fixedW) / 7));
} else {
g = gap;
W = Math.min(sceneW - 24, fixedW + 7 * g);
}
const cx = span ? (span.left + span.right) / 2 : sceneW / 2;
// All the x's, bar-local (the bar's origin is its BOTTOM CENTER, a.y):
// the right group packs inward from the bar's right edge; the tabs
// pack outward from the left; the dividers sit between the groups.
const lEdge = -W / 2 + pad;
const rEdge = W / 2 - pad;
const tX = [lEdge + widths[0] / 2, lEdge + widths[0] + g + widths[1] / 2];
const vX = rEdge; // NET value — right edge anchored at the bar's end (origin 1,0.5)
const lCx = rEdge - valueW - g - labelW / 2; // NET label, clear of the widest value
const d2X = lCx - labelW / 2 - g - divW / 2; // divider: actions │ NET
const cCx = d2X - divW / 2 - g - confirmW / 2; // Confirm
const qCx = cCx - confirmW / 2 - g - clearW / 2; // Clear
const d1X = qCx - clearW / 2 - g - divW / 2; // divider: tabs │ actions
// Position: bottom edge on the deck's top rail (a.y), centred on the
// deck's span. The sub-bar is a CHILD of the ShopView (which stays at
// the screen origin, 0,0) at (cx, a.y) — so the trade panel (a SIBLING
// at 0,0) still renders in screen space, not offset down to the deck.
const bar = new Phaser.GameObjects.Container(this.scene, cx, a.y);
bar.setScrollFactor?.(0);
this.subBar = bar;
@ -243,38 +285,28 @@ export class ShopView extends Phaser.GameObjects.Container {
// 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 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, [divX1, divX2], divH);
this._drawSubBarBody(bodyG, W, H, c, [d1X, d2X], divH);
}
// The buttons LAST (above the body), left-anchored in the row.
let bx = -W / 2 + pad + widths[0] / 2;
// The buttons LAST (above the body): tabs at the left edge, the trade
// actions + NET readout at the right edge.
items.forEach((it, i) => {
const btn = btns[i];
btn.setX?.(bx);
btn.setX?.(tX[i]);
bar.add?.(btn);
if (it.id === 'trade') this._tabTrade = btn;
else this._tabBuyback = btn;
bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
});
// 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);
this._clearBtn.setX?.(qCx);
this._confirmBtn.setX?.(cCx);
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._netLabel.setPosition?.(lCx, -H / 2);
this._netValue.setPosition?.(vX, -H / 2);
bar.add?.(this._netLabel);
bar.add?.(this._netValue);