Rework shop UI to cyberpunk sub-bar + entry state, fix panel geometry

- Shop now opens on the tab sub-bar only (shop video plays); clicking Buy/Sell reveals the trade panel — showcases the clip before the UI takes over.
- Tab bar and Confirm/Clear are now MenuButtons in a console-blue sub-bar that rests above the command deck, anchored to the deck's Shop button (MenuSubBar recipe), replacing the old top-of-screen custom buttons.
- Fix trade panel vertical placement: a Container's (x,y) is a pure translation of local space (verified via dev/cbox-test.html), so the grid position IS the panel top-left; setting it to topY + h/2 treated it as a centered origin and clipped bottom rows. Panel now anchors just under the HUD planet text via SurfaceScene.shopTopY().
- Parent TradeGrid plate/title to the grid container so they render above the shop video (scene-level depth-0 objects drew behind it in this build).
- Add dev probes (dev/cbox-test.html, dev/shop-shot.{html,mjs}) and update phaser-stub/test stubs for setSize/setBlendMode/rectangle; document entry state + sub-bar in docs/ECONOMY.md.
This commit is contained in:
Brian Fertig 2026-09-11 21:19:32 -06:00
parent 1b4cba9f4f
commit 77d649276c
13 changed files with 536 additions and 70 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

View File

@ -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" },

View File

@ -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,

65
dev/cbox-test.html Normal file
View File

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Dev probe — Phaser container transform semantics</title>
<style>html,body{margin:0;height:100%;background:#04060d;overflow:hidden}#g{width:100%;height:100%}</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="g"></div>
<script type="module">
/* Dev probe: WHAT DOES A CONTAINER'S (x, y) MEAN in this build?
*
* A container at (200,150) holds a 100x80 "plate" drawn at LOCAL (0,0)
* and a 10x10 marker at local (50,40). The red plate lands with its
* top-left at (200,150) — i.e. a container's position is a pure
* TRANSLATION of its local space (local (0,0) == the container's
* x,y). The container's origin (0.5,0.5) does NOT shift the children.
*
* Consequence the shop layout depends on: a TradeGrid's (x,y) IS the
* panel's top-left corner — setting it to topY + h/2 (centered-origin
* math) drops the panel half a grid lower and clips its bottom rows.
*
* View in a browser (node dev/server.mjs 8126 → /dev/cbox-test.html) —
* the red rectangle's top-left sits exactly at (200,150) of the 800x600
* canvas; window.__CBOX__ carries the numeric readings. */
import Phaser from '../js/vendor/phaser.js';
window.__ERRS__ = window.__ERRS__ ?? [];
window.addEventListener('error', (e) => window.__ERRS__.push(String(e.message)));
class T extends Phaser.Scene {
create() {
const cx = 200, cy = 150;
const c = this.add.container(cx, cy);
// Red "plate" 100x80 at LOCAL (0,0) — like TradeGrid's plate.
const plate = this.add.rectangle(50, 40, 100, 80, 0xff0000);
c.add(plate);
// Green 10x10 at local (50, 40) — the content center.
const g = this.add.rectangle(50, 40, 10, 10, 0x00ff00);
c.add(g);
const tl = (o) => {
if (o?.getTopLeft) { try { const v = o.getTopLeft(undefined, true); return { x: Math.round(v.x), y: Math.round(v.y) }; } catch { /* fall through */ } }
if (o?.getBounds) { const b = o.getBounds(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; }
return null;
};
window.__CBOX__ = {
container: { x: c.x, y: c.y, originX: c.originX, originY: c.originY,
displayWidth: c.displayWidth, displayHeight: c.displayHeight },
plate_world: tl(plate),
green_world: tl(g),
};
}
}
T.key = 'T';
const game = new Phaser.Game({
type: Phaser.AUTO, parent: 'g', width: 800, height: 600, backgroundColor: '#111', banner: false,
scale: { mode: Phaser.Scale.NONE },
scene: [T],
});
window.game = game;
</script>
</body>
</html>

View File

@ -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 {};

20
dev/shop-shot.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev shop panel geometry probe</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/shop-shot.mjs"></script>
</body>
</html>

115
dev/shop-shot.mjs Normal file
View File

@ -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)));

View File

@ -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');

View File

@ -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.

View File

@ -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?.();

View File

@ -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

View File

@ -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);
}
}