diff --git a/assets/images/characters/carol.png b/assets/images/characters/carol.png new file mode 100644 index 0000000..bd10f73 Binary files /dev/null and b/assets/images/characters/carol.png differ diff --git a/assets/videos/lava-land-01.mp4 b/assets/videos/lava-land-01.mp4 new file mode 100644 index 0000000..f5609e6 Binary files /dev/null and b/assets/videos/lava-land-01.mp4 differ diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs index 11087fc..a09b35b 100644 --- a/dev/shop-shot.mjs +++ b/dev/shop-shot.mjs @@ -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; })(); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 751e0d3..7939a71 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -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'); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index cfbd68d..c4d053e 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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() { diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index a17814d..db36b47 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -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);