diff --git a/assets/images/goods.png b/assets/images/goods.png index 29a3912..1cf854b 100644 Binary files a/assets/images/goods.png and b/assets/images/goods.png differ diff --git a/assets/images/goods.psd b/assets/images/goods.psd index 420656d..b6a3a86 100644 Binary files a/assets/images/goods.psd and b/assets/images/goods.psd differ diff --git a/data/landing.json b/data/landing.json index 71a32b6..4c5f560 100644 --- a/data/landing.json +++ b/data/landing.json @@ -51,7 +51,8 @@ "lineLetterSpacing": 1, "lineColor": "#7d92c4", "lineStroke": "#000000", - "lineStrokeWidth": 2 + "lineStrokeWidth": 2, + "shopPanelGap": 18 }, "videos": [ { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, diff --git a/data/shopui.json b/data/shopui.json index ea2d9ab..d3a24b2 100644 --- a/data/shopui.json +++ b/data/shopui.json @@ -3,7 +3,7 @@ "enabled": true, "tabs": { - "_comment": "The sub-menu (two buttons). Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). `disabledAlpha` is the Buyback dim until the player has sold something.", + "_comment": "The tab sub-bar (two MenuButtons, the deck's cyberpunk style — js/ui/MenuButton.js). 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", "buyback": "Buyback", "w": 184, "h": 46, "gap": 14, diff --git a/dev/cbox-test.html b/dev/cbox-test.html new file mode 100644 index 0000000..b9b8cce --- /dev/null +++ b/dev/cbox-test.html @@ -0,0 +1,65 @@ + + + + + + Dev probe — Phaser container transform semantics + + + + +
+ + + diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs index a4cb532..8ebc18d 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -22,6 +22,8 @@ class GameObject { setBlendMode() { return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } setStrokeStyle() { return this; } + setSize(w, h) { this.width = w; this.height = h; return this; } + set() { return this; } destroy() { this.active = false; this.destroyed = true; } } const Container = class extends GameObject {}; diff --git a/dev/shop-shot.html b/dev/shop-shot.html new file mode 100644 index 0000000..6e2c1bb --- /dev/null +++ b/dev/shop-shot.html @@ -0,0 +1,20 @@ + + + + + Orbit — dev shop panel geometry probe + + + + + + +
+ + + diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs new file mode 100644 index 0000000..793798f --- /dev/null +++ b/dev/shop-shot.mjs @@ -0,0 +1,115 @@ +/** + * Dev-only SHOP PANEL geometry probe (real browser, via cdp-firefox.mjs): + * boots the game, lands on the home world, forces the surface stage, opens + * the shop, clicks "Buy/Sell", waits for the slide-in, then reports the + * on-screen geometry of the planet HUD text + the SELL/BUY panels + the + * sub-bar — so the panel's top edge vs. the planet text is verifiable + * numerically. Also sets window.__SHOP_SHOT__ so a driver can screenshot + * the moment the numbers are ready. + * + * node dev/server.mjs 8126 + * node dev/cdp-firefox.mjs http://127.0.0.1:8126/dev/shop-shot.html \ + * 'return window.__SHOP_SHOT__;' 60000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const fail = (msg, extra = {}) => { + window.__SHOP_SHOT__ = { pass: false, error: String(msg), ...extra }; + console.error('[shop-shot] FAIL:', msg); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +const bounds = (o) => { + if (!o) return null; + const wb = o.worldBounds ?? (o.getBounds && o.getBounds()); + if (wb && Number.isFinite(wb.x)) { + return { x: Math.round(wb.x), y: Math.round(wb.y), w: Math.round(wb.width), h: Math.round(wb.height) }; + } + return { x: Math.round(o.x ?? 0), y: Math.round(o.y ?? 0) }; +}; + +(async () => { + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) throw new Error('GameScene never came up'); + + // Land on the home world + force the surface stage (skip the clip). + const target = gs.commsTargetFor(gs.planet); + gs.startLanding(target); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) throw new Error('SurfaceScene never registered'); + // Wait for the scene to finish loading (this build: RUNNING = 5) before + // driving it. + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')'); + await sleep(300); + + // Give the hold a couple of goods so the SELL grid shows filled bays. + const goods = config.get('goods.goods', []); + gs.ship.cargo.add([goods[0].id, goods[1].id]); + + // Open the shop the real way; if the shop clip is missing in this + // environment (headless video), build the view directly (same UI path). + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + const sv = ss.shopView; + if (!sv) throw new Error('no shopView'); + + // Click "Buy/Sell" (reveal the trade panel) and let the slide-in settle. + sv.setTab('trade'); + await sleep(900); + + const chain = (o) => o ? { + x: o.x, y: o.y, scaleX: o.scaleX, scaleY: o.scaleY, + ox: o.originX, oy: o.originY, dw: o.displayWidth, dh: o.displayHeight, + active: o.active, parent: o.parent ? (o.parent.isContainer ? 'container' : o.parent.type ?? String(o.parent)) : null, + } : null; + const out = { + pass: true, + screen: { w: ss.scale.width, h: ss.scale.height }, + camera: { scrollX: ss.cameras?.main?.scrollX, scrollY: ss.cameras?.main?.scrollY, zoom: ss.cameras?.main?.zoom }, + topY: sv.topY ?? null, + hudName: bounds(ss.hudName), + hudLine: bounds(ss.hudLine), + sellGrid: bounds(sv.sellGrid), + buyGrid: bounds(sv.buyGrid), + subBar: bounds(sv.subBar), + panel: { w: sv.sellGrid?.width ?? null, h: sv.sellGrid?.height ?? null }, + chain: { + shopView: chain(sv), + content: chain(sv._content), + sellGrid: chain(sv.sellGrid), + buyGrid: chain(sv.buyGrid), + }, + // Where the panel ACTUALLY renders: the plate + first cell + title are + // real children at known local coords — their world matrices are the truth. + actual: { + sellTitle_world: (() => { const t = sv.sellGrid.title; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(), + sellCellName_world: (() => { const t = sv.sellGrid.cells?.[0]?.name; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(), + buyTitle_world: (() => { const t = sv.buyGrid.title; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(), + net_world: (() => { const t = sv._previewText; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } return null; })(), + }, + }; + window.__SHOP_SHOT__ = out; + console.info('[shop-shot] OK:', JSON.stringify(out)); +})().catch((e) => fail(String(e?.message || e))); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 8b1f5f0..aa488f7 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -67,6 +67,8 @@ function stubObject(extra = {}) { setTint() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; }, setStrokeStyle() { return this; }, + setSize(w, h) { this.width = w; this.height = h; return this; }, + setBlendMode() { return this; }, setInteractive(opts) { this._interactive = opts; return this; }, setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; }, on() { return this; }, @@ -85,6 +87,9 @@ function stubObject(extra = {}) { clear() { return this; }, fillStyle() { return this; }, lineStyle() { return this; }, + fillPoints() { return this; }, + strokePoints() { return this; }, + lineBetween() { return this; }, rect() { return this; }, fillRect() { return this; }, lineRect() { return this; }, @@ -99,9 +104,10 @@ function makeScene() { const scene = { scale: { width: 1280, height: 720, on() { return this; } }, add: { - text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style }), + text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style, width: 60, height: 18 }), image: (x, y, key, frame) => stubObject({ x, y, key, frame }), graphics: () => stubObject({}), + rectangle: (x, y, w, h, key, alpha) => stubObject({ x, y, width: w, height: h, key, alpha }), existing: (o) => o, }, tweens: { @@ -151,29 +157,63 @@ const A = goods[0].id; const B = goods[1].id; const C = stock[0].id; // a good the shop actually sells -function makeView(ship) { +function makeView(ship, { reveal = true, topY = 120 } = {}) { const scene = makeScene(); const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; } }; const view = new ShopView(scene, { ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {}, + // topY = the surface HUD's planet-text bottom + padding (what + // SurfaceScene.shopTopY() computes). The panel top should sit here. + topY, }); - return { scene, view, gs }; + if (reveal) view.setTab('trade'); // reveal the panel (what clicking "Buy/Sell" does) + return { scene, view, gs, topY }; } // =========================================================================== -console.log('== CONSTRUCT — both grids, Buy/Sell default =='); +console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals the panel =='); { const ship = makeShip(10000); ship.cargo.add([A, B]); - const { view } = makeView(ship); - assert.equal(view.tab, 'trade', 'opens on the Buy/Sell tab'); - assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist'); + const { view, topY } = makeView(ship, { reveal: false }); + // Entry state: shop video + tab bar only. No trade panel yet — the player + // must click "Buy/Sell" to reveal it (showcase the clip before the UI). + assert.equal(view.tab, null, 'starts unopened (entry state)'); + assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)'); + assert.equal(view.buyGrid, null, 'no BUY grid yet'); + assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state'); + assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)'); + assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale'); + + // The tabs are cyberpunk MenuButtons in a sub-bar that rests ABOVE the + // deck (the MenuSubBar recipe) — not the old top-of-screen custom buttons. + assert.equal(view._tabBuyback.disabled, true, 'Buyback tab is disabled (grayed) before a sale'); + assert.equal(view._tabTrade.disabled, false, 'Buy/Sell tab is enabled (the entry point)'); + assert.ok(view.subBar, 'the sub-bar container exists'); + assert.ok(view.subBar.y > view._H() - 80, `sub-bar rests above the deck (bar.y=${view.subBar.y}, H=${view._H()})`); + 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'); + + // 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'); + assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist after reveal'); + // The panel's TOP edge sits at topY (just under the planet text). A + // TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a + // Container's (x,y) is a pure translation of that local space (verified + // in dev/cbox-test.html) — so the grid's position IS the panel's + // top-left corner: gridTop == grid.y. (Setting it to topY + h/2 treated + // the position like a centered origin and dropped the panel half a grid + // lower, clipping the bottom rows.) + const gridTop = view.sellGrid.y; + assert.ok(Math.abs(gridTop - topY) < 2, `panel top is at topY (gridTop=${gridTop.toFixed(1)}, topY=${topY})`); + assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`); + assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`); assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays'); assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots'); assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock'); assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold'); assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet'); - assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale'); } console.log('CONSTRUCT: OK'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index 388718a..b89a572 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -297,18 +297,48 @@ Where the loop is actually played. You open it from the surface deck's **SHOP** existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over** it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid`s. +**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), 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 +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 grids slide in). + ``` +# first click — shop video + the tab sub-bar (rests above the deck): +┌─────────────────────────────────────────────────────────────────┐ +│ (clip plays) │ +│ ┌─────────────────────────┐ │ +│ │ BUY/SELL │ BUYBACK │ ← sub-bar │ +│ └─────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│ +└─────────────────────────────────────────────────────────────────┘ + +# after clicking Buy/Sell — the trade panel reveals (middle of the screen): ┌─────────────────────────────────────────────────────────────────┐ -│ [ Buy/Sell | Buyback ] ← tabs │ -│ │ │ ┌SELL 4×4┐ NET +420 cr ┌BUY 4×4┐ │ │ │ bays │ (green ≥ 0 / │ stock │ │ │ │(cargo) │ red < 0) │(12–16) │ │ -│ └────────┘ [ Confirm ] └────────┘ │ -│ [ Clear ] │ +│ └────────┘ [ CONFIRM ] └────────┘ │ +│ [ CLEAR ] │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────┐ │ +│ │ BUY/SELL │ BUYBACK │ ← sub-bar │ +│ └─────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│ └─────────────────────────────────────────────────────────────────┘ ``` +- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons are `MenuButton`s (the deck's + cyberpunk style: cut-corner panel, neon edge, hover sweep, press punch) in a console-blue + panel that rests above the command deck, anchored to the Shop button — the exact recipe of + the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit. +- **Confirm / Clear** — the same `MenuButton` style; 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` 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 @@ -316,8 +346,7 @@ it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid - **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`). Each cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room). - **NET preview (center)** — the live credit delta. Green when you're up (a net sale), red when - you'd spend. **Confirm** is disabled while the trade is invalid (can't go negative, hold - would overflow). **Clear** drops the current selection. Both appear once you start picking. + you'd spend. Updates as you check bays / step goods. - **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm executes it: credits `+ sale − buy`, cargo `− sold + bought`, in that order (free room first). Selling to free a bay for a buy lands in the same Confirm. diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 63c1517..b5bca94 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -350,10 +350,46 @@ export class SurfaceScene extends Phaser.Scene { systemId, gameScene: g, onLeave: () => this.leaveShop(), + anchor: this.shopAnchor(), + topY: this.shopTopY(), }); return this.shopView; } + /** + * The trade panel's top edge — just under the surface HUD's planet text + * (the name + the type/tether sub-line), with a little padding. The HUD is + * upper-left, so the panel hangs below it (near the top of the screen). + * Falls back to a small top margin if the HUD isn't up (dev). + */ + shopTopY() { + const hud = config.section('landing.hud', {}); + const pad = hud.shopPanelGap ?? 18; // a little padding under the planet text + const line = this.hudLine; + if (line && Number.isFinite(line.y) && Number.isFinite(line.height)) { + return line.y + line.height + pad; + } + const name = this.hudName; + if (name && Number.isFinite(name.y) && Number.isFinite(name.height)) { + return name.y + name.height + pad; + } + return (this.scale.height ?? 720) * 0.16; + } + + /** + * The shop tab sub-bar's anchor — the deck's "Shop" button, the same + * recipe as menuAnchor(): the bar rests ABOVE the deck (bottom edge on its + * top rail), right-aligned to the Shop button. Falls back to bottom-center + * if the deck is missing (dev). + */ + shopAnchor() { + const slot = this.actionBar?.slots?.find((s) => s.id === 'shop'); + if (slot) { + return { x: slot.slot.x, y: this.actionBar.rect.y, w: this.actionBar.style.bw }; + } + return { x: this.scale.width / 2, y: this.scale.height - 58, w: 120 }; + } + destroyShopView() { if (!this.shopView) return; this.shopView.destroy?.(); diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index eeebcaa..3aeaa5c 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -35,6 +35,8 @@ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; import * as E from '../../economy/Economy.js'; +import { MenuButton } from '../MenuButton.js'; +import { toColor, toCss } from '../../utils/Color.js'; import { TradeGrid } from './TradeGrid.js'; export class ShopView extends Phaser.GameObjects.Container { @@ -48,6 +50,10 @@ 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). */ constructor(scene, o) { super(scene, 0, 0); @@ -58,6 +64,13 @@ 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 + // 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 }; + // 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). + this.topY = Number.isFinite(o.topY) ? o.topY : (this._H() * 0.16); this.maxSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16)); this.capacity = this.ship.cargo ? this.ship.cargo.capacity : 0; @@ -89,7 +102,12 @@ export class ShopView extends Phaser.GameObjects.Container { // ENTRY + TABS // --------------------------------------------------------------------- enter() { - this.setTab('trade'); + // Entry state: the shop video + tab bar only. The player must click + // "Buy/Sell" to reveal the trade panel (this keeps the screen open so the + // shop clip plays before the UI takes over). Buyback is grayed until a + // sale exists this visit. + this.tab = null; + this._refreshTabs(); return this; } @@ -108,36 +126,130 @@ export class ShopView extends Phaser.GameObjects.Container { } _buildTabs() { + // The tab sub-bar: the same recipe as the deck's Menu sub-bar + // (js/ui/MenuSubBar.js) — a console-blue panel with cut corners + the + // signature top rail, holding cyberpunk MenuButtons. It rests ABOVE the + // 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. + const a = this.anchor; + const H = config.get('save.subBar.height', 58); + const pad = config.get('save.subBar.padding', 16); + const gap = config.get('save.subBar.gap', 12); const tabs = config.get('shopui.tabs', {}); - const W = this._W(); - const y = tabs.y ?? 44; - const tw = tabs.w ?? 184, th = tabs.h ?? 46, gap = tabs.gap ?? 14; - const cx = W / 2; - const sellBtn = this._makeButton(tabs.buySell ?? 'Buy/Sell', tw, th, tabs.activeAccent ?? '#00e5ff'); - sellBtn.setPosition(cx - gap / 2 - tw / 2, y); - this.add?.(sellBtn.container); - sellBtn.tap(() => this.setTab('trade')); - this._tabTrade = sellBtn; + const c = config.section('save.colors', {}); + const btnCfg = config.section('save.subBar.button', {}); - const bbBtn = this._makeButton(tabs.buyback ?? 'Buyback', tw, th, tabs.activeAccent ?? '#00e5ff'); - bbBtn.setPosition(cx + gap / 2 + tw / 2, y); - this.add?.(bbBtn.container); - bbBtn.tap(() => { if (this.isBuybackEnabled()) this.setTab('buyback'); }); - this._tabBuyback = bbBtn; + const items = [ + { id: 'trade', label: tabs.buySell ?? 'Buy/Sell', accent: tabs.activeAccent ?? c.neon ?? '#00e5ff' }, + { id: 'buyback', label: tabs.buyback ?? 'Buyback', accent: c.dim ?? '#7d92c4' }, + ]; + + // Build the buttons first (they measure their own width) so the row + // layout uses the REAL widths — no second, divergent text-measure pass. + const mkBtn = (item) => new MenuButton( + this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id), + { + screenFixed: true, + fontSize: btnCfg.fontSize ?? 13, + paddingX: btnCfg.paddingX ?? 18, + paddingY: btnCfg.paddingY ?? 9, + letterSpacing: btnCfg.letterSpacing ?? 2, + upper: true, + textColor: c.ink ?? '#eaf6ff', + bgColor: c.cardBg ?? '#0a1222', + hoverColor: c.cardBg ?? '#0a1222', + borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'), + }, + ); + + const btns = items.map(mkBtn); + const widths = btns.map((b) => b.style?.width ?? 120); + const W = Math.min( + this._W() - 24, + pad * 2 + widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1), + ); + + // 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. + 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 bar = new Phaser.GameObjects.Container(this.scene, cx, a.y); + bar.setScrollFactor?.(0); + this.subBar = bar; + this.subBarW = W; + this.subBarH = H; + this.add?.(bar); + + // The body FIRST (v4 paints children in list order — the opaque panel + // fill must come before the buttons or it would cover them). + const bodyG = this.scene.add?.graphics?.(); + if (bodyG) { + bodyG.setScrollFactor?.(0); + bar.add?.(bodyG); + this._drawSubBarBody(bodyG, W, H, c); + } + + // The buttons LAST (above the body), centered in the row. + let bx = -W / 2 + pad + widths[0] / 2; + items.forEach((it, i) => { + const btn = btns[i]; + btn.setX?.(bx); + 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); + }); + + // Buyback starts grayed (disabled) until a sale exists this visit. + this._refreshTabs(); + } + + /** The sub-bar panel body (the MenuSubBar recipe at full height). */ + _drawSubBarBody(g, W, H, c) { + if (!g || typeof g.clear !== 'function') return; + const panelTop = toColor(c.panelTop ?? '#0e1930'); + const panelBottom = toColor(c.panelBottom ?? '#050912'); + const neon = toColor(c.neon ?? '#00e5ff'); + const magenta = toColor(c.magenta ?? '#ff2d6f'); + const ink = toColor(c.ink ?? '#eaf6ff'); + const cut = Math.max(3, Math.min(12, H * 0.18)); + g.clear(); + const pts = [ + { x: -W / 2 + cut, y: -H }, { x: W / 2 - cut, y: -H }, + { x: W / 2, y: -H + cut }, { x: W / 2, y: 0 }, + { x: -W / 2, y: 0 }, { x: -W / 2, y: -H + cut }, + ]; + const has = (fn) => typeof g[fn] === 'function'; + if (has('fillPoints')) { + g.fillStyle(panelBottom, 1); g.fillPoints(pts, true); + g.fillStyle(panelTop, 0.55); g.fillPoints(pts, true); + } + if (has('fillRect')) { + g.fillStyle(neon, 0.05); g.fillRect(-W / 2, -H, 26, H); // cyan left + g.fillStyle(magenta, 0.06); g.fillRect(W / 2 - 26, -H, 26, H); // magenta right + const rail = 2; + g.fillStyle(neon, 0.9); g.fillRect(-W / 2 + cut, -H, W - cut * 2, rail); + 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.35); g.fillRect(-W / 2, -1.5, W, 1.5); // seam + } + } + + /** A tab button press (MenuButton → _onTabTap). */ + _onTabTap(id) { + if (id === 'trade') this.setTab('trade'); + else if (id === 'buyback' && this.isBuybackEnabled()) this.setTab('buyback'); } _refreshTabs() { - const tabs = config.get('shopui.tabs', {}); - const dim = tabs.disabledAlpha ?? 0.34; - const accent = tabs.activeAccent ?? '#00e5ff'; - // active tab = bright, inactive = dimmer; buyback disabled = dim + no-op. - const tradeOn = this.tab === 'trade'; - const bbOn = this.tab === 'buyback'; - const bbEnabled = this.isBuybackEnabled(); - this._tabTrade?.setAccent(accent); - this._tabTrade?.setAlpha(tradeOn ? 1 : 0.55); - this._tabBuyback?.setAccent(bbOn ? accent : '#7d92c4'); - this._tabBuyback?.setAlpha(bbOn ? 1 : (bbEnabled ? 0.55 : dim)); + // MenuButtons carry their own state (base/hover/disabled) — the only thing + // that changes here is Buyback's locked state (grayed until a sale exists). + this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled()); } // --------------------------------------------------------------------- @@ -163,44 +275,72 @@ export class ShopView extends Phaser.GameObjects.Container { this.sellGrid = sellGrid; this.buyGrid = buyGrid; - // Layout: [SELL grid] [center] [BUY grid], centered in the viewport. + // Layout: [SELL grid] [center] [BUY grid]. The panel's TOP edge sits at + // 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 + // hanging down to the deck/sub-bar). + // + // IMPORTANT: a TradeGrid lays its plate/cells out in local (0,0)→(w,h) + // and a Container's (x,y) is a pure TRANSLATION of that local space + // (verified with dev/cbox-test.html) — so the grid's position IS the + // panel's top-left corner. Treating it as a centered origin (topY + h/2) + // drops the panel half a grid lower and clips the bottom rows. const totalW = sellGrid.width + centerW + buyGrid.width; const leftX = Math.max(8, (W - totalW) / 2); - const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26; - sellGrid.setPosition(leftX, contentY); + const gridH = sellGrid.height; + sellGrid.setPosition(leftX, this.topY); const rightX = leftX + sellGrid.width + centerW; - buyGrid.setPosition(rightX, contentY); + buyGrid.setPosition(rightX, this.topY); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); + content.setScrollFactor?.(0); content.add?.(sellGrid); content.add?.(buyGrid); - // center column: NET preview + Confirm + Clear + // center column: NET preview + Confirm + Clear — vertically centered with + // the grids (the NET above, the buttons below), so the whole panel is one + // cohesive block near the top of the screen. const cx = leftX + sellGrid.width + centerW / 2; - const cy = contentY + sellGrid.height / 2; - this._previewText = this._makeText(cx, cy - 30, 'NET 0 cr', { - fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, - color: config.get('shopui.preview.zeroColor' , '#7d92c4'), align: 'center', - }); - this._previewLabel = this._makeText(cx, cy - 62, config.get('shopui.preview.label', 'NET'), { + const cy = this.topY + gridH / 2; + this._previewLabel = this._makeText(cx, cy - 52, config.get('shopui.preview.label', 'NET'), { fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center', }); + this._previewText = this._makeText(cx, cy - 14, 'NET 0 cr', { + fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, + color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center', + }); content.add?.(this._previewLabel); content.add?.(this._previewText); - 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 + 34); - this._confirmBtn.tap(() => this.confirm()); - this._confirmBtn.setAlpha(0); - content.add?.(this._confirmBtn.container); + // 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 + 34, 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 = this._makeButton(config.get('shopui.buttons.clearLabel', 'Clear'), bw, bh, config.get('shopui.buttons.clearAccent', '#7d92c4')); - this._clearBtn.setPosition(cx, cy + 104); - this._clearBtn.tap(() => this.clear()); - this._clearBtn.setAlpha(0); - content.add?.(this._clearBtn.container); + this._clearBtn = new MenuButton( + this.scene, cx, cy + 104, 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.add?.(content); @@ -223,8 +363,9 @@ export class ShopView extends Phaser.GameObjects.Container { _showBuyback() { const W = this._W(); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); + content.setScrollFactor?.(0); const rows = this._bbRows(); - const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26; + const contentY = this._H() / 2; const cx = W / 2; const title = this._makeText(cx, contentY + 10, 'BUY BACK', { @@ -399,11 +540,13 @@ export class ShopView extends Phaser.GameObjects.Container { } const confirmOn = anySel; const confirmEnabled = anySel && pv.valid; - this._confirmBtn?.setAlpha(confirmOn ? 1 : 0); - this._confirmBtn?.setAccent(confirmEnabled - ? config.get('shopui.buttons.confirmAccent', '#7ce8a4') - : config.get('shopui.buttons.disabled', '#7d92c4')); - this._clearBtn?.setAlpha(confirmOn ? 1 : 0); + // MenuButton: hide (alpha) when no selection, and GRAY (setDisabled) while + // the trade is invalid — a disabled button swallows its own clicks. + if (this._confirmBtn) { + this._confirmBtn.setAlpha?.(confirmOn ? 1 : 0); + this._confirmBtn.setDisabled?.(!confirmEnabled); + } + this._clearBtn?.setAlpha?.(confirmOn ? 1 : 0); } _hasAnySelection() { @@ -461,6 +604,10 @@ export class ShopView extends Phaser.GameObjects.Container { return this.scene.scale?.width ?? 1280; } + _H() { + return this.scene.scale?.height ?? 720; + } + _makeText(x, y, str, style = {}) { // Owned by whichever container the caller adds it to (or this) — real // Phaser tears the whole tree down on destroy(); the headless stub doesn't diff --git a/js/ui/shop/TradeGrid.js b/js/ui/shop/TradeGrid.js index 1a867a2..eacc94d 100644 --- a/js/ui/shop/TradeGrid.js +++ b/js/ui/shop/TradeGrid.js @@ -97,6 +97,13 @@ export class TradeGrid extends Phaser.GameObjects.Container { this.plate = gr; this._objs.push(gr); this._drawPlate(); + // Parent it to the grid so it renders with the panel: in this Phaser + // build a scene-level object at depth 0 draws BEHIND the shop video + // (verified in dev/cbox-test.html + screenshots) — container children + // inherit the grid's depth (ShopView = 100) and its translation, so the + // plate stays glued to the panel and above the video. The cells are + // added later, so the plate sits behind them (added first = drawn first). + this.add?.(gr); } this.plateStyle = p; } @@ -124,6 +131,10 @@ export class TradeGrid extends Phaser.GameObjects.Container { t.setOrigin?.(0.5, 0); this.title = t; this._objs.push(t); + // Parent to the grid (same reason as the plate — scene-level depth 0 + // renders behind the shop video in this build, so the SELL/BUY header + // was invisible before). + this.add?.(t); } }