Compare commits
6 Commits
05b6f573c3
...
3d82316a13
| Author | SHA1 | Date |
|---|---|---|
|
|
3d82316a13 | |
|
|
c3d5e7fe74 | |
|
|
cc4a15cafd | |
|
|
3fdd825570 | |
|
|
a2d211a981 | |
|
|
2b860cc899 |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
|
|
@ -1,19 +1,17 @@
|
|||
{
|
||||
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), a center column carries the live NET preview + Confirm/Clear, and a tab bar up top toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
|
||||
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the SELL grid (the player's cargo bays) sits on the LEFT, the BUY list (the shop's stock, as a scrollable list of wide rows) RIGHT NEXT TO it with just a bit of padding between (grid.centerW) — the panel is pure selection. The panel SPANS the screen: the SELL grid's left edge is flush with the planet text (data/landing.json → landing.hud.marginX), and the BUY list stretches to the same right padding. The trade ACTIONS (Confirm/Clear) + the live NET-credits readout ride on the TAB SUB-BAR (→ tabs/preview/buttons), and the tab sub-bar toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. SELL CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. BUY ROWS carry their name + description (wraps to two lines, never over the price/stepper) + price INLINE (no tooltip — the row is wide enough). GEOMETRY (slot/gap/grid/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
|
||||
"enabled": true,
|
||||
|
||||
"tabs": {
|
||||
"_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).",
|
||||
"_comment": "The tab sub-bar (the deck's cyberpunk style — js/ui/MenuButton.js): [Buy/Sell][Buyback] │ [Clear][Confirm] │ NET <value> — the tabs, the trade ACTIONS (→ buttons), and the live NET readout (→ preview), past hairline dividers, on ONE console. 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,
|
||||
"y": 44,
|
||||
"activeAccent": "#00e5ff",
|
||||
"disabledAlpha": 0.34
|
||||
},
|
||||
|
||||
"panel": {
|
||||
"_comment": "The semi-transparent panel behind each side's grid — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
|
||||
"_comment": "The semi-transparent panel behind each side (the SELL grid's bays / the BUY list's rows) — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
|
||||
"bgColor": "#0a1120",
|
||||
"bgAlpha": 0.70,
|
||||
"border": "#22405f",
|
||||
|
|
@ -22,27 +20,46 @@
|
|||
},
|
||||
|
||||
"grid": {
|
||||
"_comment": "The 4×4 grid shared by both sides (data/economy.json → cargo.maxSlots = 16 slots). SELL: the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). BUY: the shop's stock (12–16) fills the first N, the rest are empty.",
|
||||
"_comment": "The SELL side's 4×4 bay grid (data/economy.json → cargo.maxSlots = 16 slots): the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). `centerW` is the small PADDING gap between the SELL grid and the BUY list (the panels sit side by side; the trade actions live on the tab sub-bar — → buttons). (The BUY side is a list now — → buyList.)",
|
||||
"cols": 4, "rows": 4,
|
||||
"slot": 96, "gap": 10,
|
||||
"centerW": 208
|
||||
"centerW": 16
|
||||
},
|
||||
|
||||
"slot": {
|
||||
"_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which now fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors still apply — they feed the tooltip's name/price lines + the buy stepper. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).",
|
||||
"_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).",
|
||||
"bgColor": "#0e1728",
|
||||
"border": "#1d2e4c",
|
||||
"selected": "#00e5ff",
|
||||
"lockAlpha": 0.30,
|
||||
"iconScale": 0.62,
|
||||
"name": { "fontSize": 11, "color": "#eaf6ff" },
|
||||
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" },
|
||||
"price": { "fontSize": 12, "sell": "#7ce8a4" },
|
||||
"emptyLabel": "—",
|
||||
"lockLabel": "locked"
|
||||
},
|
||||
|
||||
"buyList": {
|
||||
"_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — [icon] NAME / description (the good's flavor, data/goods.json, wraps to up to desc.maxLines lines and NEVER over the price/stepper) … PRICE, − qty +. Live, ShopView STRETCHES the list from just past the SELL grid out to the right padding (data/landing.json → landing.hud.marginX) — `w` is only the fallback when no width is passed (headless/dev). Sized to the SELL grid's height so the panels align top AND bottom; the list scrolls (drag on the body / mouse wheel) when the stock outgrows the viewport — rows outside it are hidden (row-based clipping; this Phaser build has no camera/mask clip). A row in the pending trade (qty > 0) gets the accent border + lit qty. `rowH` is tall enough for the two-line description. `pad` insets rows from the panel edge; the title bar reuses panel.title.",
|
||||
"w": 560,
|
||||
"pad": 14,
|
||||
"rowH": 72,
|
||||
"rowGap": 6,
|
||||
"icon": 44,
|
||||
"iconGap": 12,
|
||||
"name": { "fontSize": 14, "color": "#eaf6ff" },
|
||||
"desc": { "fontSize": 11, "color": "#7d92c4", "maxLines": 2 },
|
||||
"price": { "fontSize": 14, "color": "#ffc94d", "w": 64 },
|
||||
"rowBg": "#0e1728",
|
||||
"rowBgAlpha": 0.55,
|
||||
"rowBorder": "#1d2e4c",
|
||||
"rowSelBg": "#12283a",
|
||||
"rowSelBgAlpha": 0.85,
|
||||
"selected": "#00e5ff"
|
||||
},
|
||||
|
||||
"tooltip": {
|
||||
"_comment": "The hover tooltip — one per grid (shared), revealed when the pointer is over a usable cell: the good's NAME + the price line (\"sell for N cr\" / \"buy for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri).",
|
||||
"_comment": "The hover tooltip — one per SELL grid (shared), revealed when the pointer is over a usable bay: the good's NAME + the sell-price line (\"sell for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri). (The BUY list has no tooltip — its rows carry the name/price inline.)",
|
||||
"bgColor": "#0a1120",
|
||||
"bgAlpha": 0.96,
|
||||
"border": "#22405f",
|
||||
|
|
@ -53,37 +70,37 @@
|
|||
},
|
||||
|
||||
"stepper": {
|
||||
"_comment": "The BUY-side quantity stepper (− n +). Clicking −/+ adjusts that good's buy quantity; the live preview recalcs. The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
|
||||
"_comment": "The BUY list's per-row quantity stepper (− n +): clicking −/+ adjusts that good's buy quantity (the qty tap = +1); the live preview recalcs. `w` is the stepper block's width (sizes the price gap). The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
|
||||
"fontSize": 19,
|
||||
"w": 88,
|
||||
"color": "#eaf6ff",
|
||||
"btn": "#7d92c4",
|
||||
"activeColor": "#00e5ff"
|
||||
},
|
||||
|
||||
"preview": {
|
||||
"_comment": "The live NET-credits readout in the center column. Positive (you're up) = green, negative (you'd spend past your credits / it's a net spend) = the value shown; the Confirm button is disabled while the trade is invalid (can't go negative, hold overflows).",
|
||||
"_comment": "The live NET-credits readout — on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons, past a hairline divider (→ divider): tabs AND the pending trade's credit delta on one console, one glance apart. Positive (you're up) = green, negative (a net spend) = the value shown in magenta, zero = dim (entry state / nothing selected). The value is right-aligned at the bar's end so longer numbers (+1,240 cr) never resize the bar. Confirm is disabled while the trade is invalid (can't go negative, hold overflows).",
|
||||
"label": "NET",
|
||||
"labelColor": "#7d92c4",
|
||||
"labelFontSize": 13,
|
||||
"valueFontSize": 34,
|
||||
"labelFontSize": 12,
|
||||
"valueFontSize": 22,
|
||||
"zeroColor": "#7d92c4",
|
||||
"posColor": "#7ce8a4",
|
||||
"negColor": "#ff2d6f"
|
||||
"negColor": "#ff2d6f",
|
||||
"gap": 12,
|
||||
"divider": { "width": 2, "height": 26, "color": "#22405f", "alpha": 0.9 }
|
||||
},
|
||||
|
||||
"buttons": {
|
||||
"_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — they appear once the player starts checking/stepping items. `disabled` dims Confirm while the trade is invalid. `back` is a reserved return-to-menu label (the deck's Leave Shop is the real exit). Separate *Label (text) and *Accent (color) keys so they don't collide.",
|
||||
"w": 158, "h": 54,
|
||||
"_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — on the TAB SUB-BAR (→ tabs), between the tabs and the NET readout: they appear once the player starts checking/stepping items, and serve BOTH the trade and the buyback tabs. Confirm is grayed (disabled) while the trade is invalid. Button size/palette come from data/save.json → subBar (the same MenuButtons as the tabs); this section just holds the labels + accents.",
|
||||
"confirmLabel": "Confirm",
|
||||
"clearLabel": "Clear",
|
||||
"backLabel": "‹ Menu",
|
||||
"accent": "#00e5ff",
|
||||
"confirmAccent": "#7ce8a4",
|
||||
"clearAccent": "#7d92c4",
|
||||
"disabled": "#7d92c4"
|
||||
"clearAccent": "#7d92c4"
|
||||
},
|
||||
|
||||
"animation": {
|
||||
"_comment": "Reveal: the SELL grid slides in from the left, the BUY grid from the right, the center column fades up (data/shopui.json → animation). A short stagger per row keeps it feeling assembled rather than popped.",
|
||||
"_comment": "Reveal: the SELL grid slides in from the left, the BUY list from the right (data/shopui.json → animation). A short stagger keeps it feeling assembled rather than popped.",
|
||||
"slideMs": 340,
|
||||
"staggerMs": 26,
|
||||
"ease": "Cubic.Out"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* ActionBar test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node --import ./dev/phaser-loader.mjs dev/actionbar.test.mjs
|
||||
*
|
||||
* The command deck's PER-SLOT lock (js/ui/ActionBar.js → setSlotEnabled),
|
||||
* the "shop is up" state: every slot but SHOP goes standby (dim, inert —
|
||||
* no hover, no press, clicks swallowed) and only LEAVE SHOP is the live
|
||||
* button on the deck. Verified:
|
||||
*
|
||||
* - CONSTRUCT — the surface deck builds all six live slots.
|
||||
* - SLOT LOCK — setSlotEnabled(id, false) arms the slot inert (input
|
||||
* disabled, press blocked, standby paint) and (id, true) re-arms it.
|
||||
* - DECK LOCK — the SurfaceScene recipe: lock everything but 'shop' →
|
||||
* only the shop slot fires onAction; unlock brings the rest back.
|
||||
* - STANDBY PRIORITY — the deck-wide comm hold (setEnabled(false))
|
||||
* owns input: a per-slot unlock can't re-open a slot underneath it.
|
||||
* - LABEL + LOCK — relabeling a locked slot keeps its standby paint.
|
||||
*
|
||||
* A fake Scene provides the add.* factories + tweens/time as no-ops; the
|
||||
* phaser stub (dev/phaser-stub.mjs) provides the Container the bar
|
||||
* extends.
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton ------
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
|
||||
const { ActionBar } = await import(pathToFileURL(join(__dirname, '../js/ui/ActionBar.js')).href);
|
||||
|
||||
const SURFACE = config.get('actionbar.surface.buttons');
|
||||
assert.ok(Array.isArray(SURFACE) && SURFACE.length >= 6, 'surface deck has six slots');
|
||||
const IDS = SURFACE.map((b) => b.id).filter(Boolean);
|
||||
assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'surface deck has the shop + menu slots');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A headless Scene: just enough for the bar to CONSTRUCT + drive the slot
|
||||
// state machine. `setInteractive`/`disableInteractive` on the slot panels
|
||||
// record the input state, so the test can assert which slots are armed.
|
||||
// ---------------------------------------------------------------------------
|
||||
function stubObject(extra = {}) {
|
||||
const o = {
|
||||
x: 0, y: 0, width: 60, height: 18, alpha: 1,
|
||||
setOrigin() { return this; },
|
||||
setAlpha(a) { this.alpha = a; return this; },
|
||||
setScale() { return this; },
|
||||
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; },
|
||||
setScrollFactor() { return this; },
|
||||
setDepth() { return this; },
|
||||
setInteractive(opts) { this._interactive = opts; return this; },
|
||||
disableInteractive() { this._interactive = null; return this; },
|
||||
setDisplaySize() { return this; },
|
||||
on() { return this; },
|
||||
once() { return this; },
|
||||
off() { return this; },
|
||||
setText(s) { this.text = String(s); return this; },
|
||||
setColor(c) { this.color = c; return this; },
|
||||
setFontSize() { return this; },
|
||||
clear() { return this; },
|
||||
fillStyle() { return this; },
|
||||
lineStyle() { return this; },
|
||||
fillPoints() { return this; },
|
||||
strokePoints() { return this; },
|
||||
fillRect() { return this; },
|
||||
strokeRect() { return this; },
|
||||
lineBetween() { return this; },
|
||||
destroy() { this.destroyed = true; },
|
||||
...extra,
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
function makeScene() {
|
||||
const scene = {
|
||||
scale: { width: 1280, height: 720 },
|
||||
add: {
|
||||
existing: (o) => o,
|
||||
text: (x, y, str) => stubObject({ x, y, text: String(str) }),
|
||||
image: (x, y, key) => stubObject({ x, y, key }),
|
||||
graphics: () => stubObject({}),
|
||||
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
|
||||
circle: (x, y, r) => stubObject({ x, y, radius: r }),
|
||||
},
|
||||
tweens: {
|
||||
add: (t) => {
|
||||
// Apply the target values immediately (the real tween eases to the
|
||||
// same place) so hover punches / boot flicker end at their finals.
|
||||
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
|
||||
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
|
||||
else if (t?.targets) apply(t.targets);
|
||||
return { destroy() {}, stop() {} };
|
||||
},
|
||||
killTweensOf() {},
|
||||
},
|
||||
time: {
|
||||
now: 1000,
|
||||
// Run the callback immediately: press()'s 130 ms press-restore and
|
||||
// boot()'s end-of-boot burst are the only scheduled calls — both are
|
||||
// no-op-safe, and running them keeps the state machine honest.
|
||||
delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
|
||||
},
|
||||
textures: { exists: () => true }, // canvasTexture caches → no DOM canvas
|
||||
};
|
||||
return scene;
|
||||
}
|
||||
|
||||
function makeBar() {
|
||||
const pressed = [];
|
||||
const scene = makeScene();
|
||||
const bar = new ActionBar(scene, {
|
||||
onAction: (id) => pressed.push(id),
|
||||
buttons: SURFACE,
|
||||
});
|
||||
return { scene, bar, pressed };
|
||||
}
|
||||
|
||||
const slot = (bar, id) => bar.slots.find((s) => s.id === id);
|
||||
const armed = (s) => Boolean(s.panel._interactive);
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== CONSTRUCT — the surface deck builds all six live slots ==');
|
||||
{
|
||||
const { bar } = makeBar();
|
||||
assert.equal(bar.slots.length, 6, 'six slots built');
|
||||
for (const id of IDS) {
|
||||
const s = slot(bar, id);
|
||||
assert.ok(s, `slot '${id}' exists`);
|
||||
assert.ok(s.live, `slot '${id}' is live`);
|
||||
assert.ok(armed(s), `slot '${id}' starts armed (interactive)`);
|
||||
assert.equal(s.disabled, undefined, `slot '${id}' is not locked at construction`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== SLOT LOCK — setSlotEnabled arms/disarms one slot ==');
|
||||
{
|
||||
const { bar, pressed } = makeBar();
|
||||
const s = slot(bar, 'build');
|
||||
|
||||
bar.setSlotEnabled('build', false);
|
||||
assert.equal(s.disabled, true, 'the slot is flagged disabled');
|
||||
assert.equal(armed(s), false, 'the slot is disarmed (input off)');
|
||||
assert.equal(s.label.color, '#5a6478', 'the slot paints standby (grayed label)');
|
||||
bar.press(s); // a stray click over the dead slot
|
||||
assert.equal(pressed.length, 0, 'pressing a locked slot fires no onAction');
|
||||
|
||||
// Idempotent: a second lock is a no-op (no re-paint churn, state kept).
|
||||
bar.setSlotEnabled('build', false);
|
||||
assert.equal(s.disabled, true, 're-locking is a no-op');
|
||||
|
||||
bar.setSlotEnabled('build', true);
|
||||
assert.equal(s.disabled, false, 'the slot is un-flagged');
|
||||
assert.ok(armed(s), 'the slot is re-armed (input on)');
|
||||
assert.notEqual(s.label.color, '#5a6478', 'the slot paints base again');
|
||||
bar.press(s);
|
||||
assert.deepEqual(pressed, ['build'], 'pressing an unlocked slot fires onAction');
|
||||
|
||||
// Unknown ids are a safe no-op.
|
||||
assert.equal(bar.setSlotEnabled('nope', false), null, 'unknown id → null, no crash');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== DECK LOCK — while the shop is up, only the shop slot is live ==');
|
||||
{
|
||||
const { bar, pressed } = makeBar();
|
||||
// The SurfaceScene recipe: lock every live slot except 'shop'.
|
||||
const lock = (locked) => {
|
||||
for (const s of bar.slots) {
|
||||
if (!s.live || s.id === 'shop') continue;
|
||||
bar.setSlotEnabled(s.id, !locked);
|
||||
}
|
||||
};
|
||||
|
||||
lock(true);
|
||||
assert.ok(armed(slot(bar, 'shop')), 'the SHOP slot stays armed (the way out)');
|
||||
for (const id of IDS.filter((x) => x !== 'shop')) {
|
||||
const s = slot(bar, id);
|
||||
assert.equal(armed(s), false, `slot '${id}' is disarmed while the shop is up`);
|
||||
}
|
||||
// The deck's presses: only the shop slot can get through.
|
||||
bar.press(slot(bar, 'build'));
|
||||
bar.press(slot(bar, 'takeoff'));
|
||||
bar.press(slot(bar, 'menu'));
|
||||
assert.equal(pressed.length, 0, 'locked slots swallow the press');
|
||||
bar.press(slot(bar, 'shop'));
|
||||
assert.deepEqual(pressed, ['shop'], 'LEAVE SHOP still fires onAction');
|
||||
|
||||
lock(false);
|
||||
for (const id of IDS.filter((x) => x !== 'shop')) {
|
||||
assert.ok(armed(slot(bar, id)), `slot '${id}' is re-armed after unlock`);
|
||||
}
|
||||
pressed.length = 0;
|
||||
bar.press(slot(bar, 'menu'));
|
||||
assert.deepEqual(pressed, ['menu'], 'the deck is fully live again');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== STANDBY PRIORITY — the comm hold owns input over a slot unlock ==');
|
||||
{
|
||||
const { bar } = makeBar();
|
||||
const s = slot(bar, 'build');
|
||||
bar.setEnabled(false); // the 'ack' comm's hold: the whole deck goes inert
|
||||
for (const id of IDS) assert.equal(armed(slot(bar, id)), false, `slot '${id}' inert under deck standby`);
|
||||
|
||||
// A per-slot unlock must NOT re-open a slot underneath the deck hold.
|
||||
bar.setSlotEnabled('build', true);
|
||||
assert.equal(armed(s), false, 'the slot stays disarmed (deck standby wins)');
|
||||
assert.equal(s.disabled, false, '…but its per-slot flag is cleared (the deck, not the slot, owns it)');
|
||||
|
||||
bar.setEnabled(true); // the comm releases
|
||||
assert.ok(armed(s), 'the slot re-arms when the deck hold lifts');
|
||||
for (const id of IDS) assert.ok(armed(slot(bar, id)), `slot '${id}' re-armed with the deck`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== LABEL + LOCK — relabeling a locked slot keeps the standby paint ==');
|
||||
{
|
||||
const { bar } = makeBar();
|
||||
const s = slot(bar, 'menu');
|
||||
bar.setSlotEnabled('menu', false);
|
||||
bar.setLabel('menu', 'Menu 2');
|
||||
assert.equal(s.label.text, 'MENU 2', 'the label text updates');
|
||||
assert.equal(s.label.color, '#5a6478', 'the standby paint survives the relabel');
|
||||
bar.setSlotEnabled('menu', true);
|
||||
assert.notEqual(s.label.color, '#5a6478', 'unlocking paints the base label back');
|
||||
}
|
||||
|
||||
console.log('\nAll ActionBar checks passed ✔');
|
||||
|
|
@ -21,6 +21,8 @@ class GameObject {
|
|||
setTint(t) { this.tint = t; return this; }
|
||||
setBlendMode() { return this; }
|
||||
setPosition(x, y) { this.x = x; this.y = y; return this; }
|
||||
setX(x) { this.x = x; return this; }
|
||||
setY(y) { this.y = y; return this; }
|
||||
setStrokeStyle() { return this; }
|
||||
setSize(w, h) { this.width = w; this.height = h; return this; }
|
||||
set() { return this; }
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ const bounds = (o) => {
|
|||
|
||||
// 1) structure: the old per-cell name/price text is GONE from usable cells
|
||||
const sellCell = sv.sellGrid.cells[0];
|
||||
const buyCell = sv.buyGrid.cells[0];
|
||||
const struct = {
|
||||
sellCellName: sellCell.name === undefined ? 'undefined' : 'present',
|
||||
sellCellPrice: sellCell.price === undefined ? 'undefined' : 'present',
|
||||
|
|
@ -134,7 +133,7 @@ const bounds = (o) => {
|
|||
sellLockedLabel: { text: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.text ?? null, alpha: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.alpha ?? null },
|
||||
sellIcon: { alpha: sellCell.icon?.alpha ?? null, scale: round((sellCell.icon?.scaleX ?? 0) * 100) / 100, pos: { x: sellCell.icon?.x, y: sellCell.icon?.y } },
|
||||
titleFont: sv.sellGrid.title?.style?.fontFamily ?? sv.sellGrid.title?._style?.fontFamily ?? null,
|
||||
stepperFont: sv.buyGrid.cells[0].stepQty?.style?.fontFamily ?? sv.buyGrid.cells[0].stepQty?._style?.fontFamily ?? null,
|
||||
buyQtyFont: sv.buyList.rows[0]?.objs?.qty?.style?.fontFamily ?? sv.buyList.rows[0]?.objs?.qty?._style?.fontFamily ?? null,
|
||||
};
|
||||
out.tooltip = { struct };
|
||||
|
||||
|
|
@ -142,21 +141,24 @@ const bounds = (o) => {
|
|||
const tipSellA = sv.sellGrid.hoverCell(0);
|
||||
out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 0) };
|
||||
|
||||
// 3) REAL mouse hover over a BUY cell — row 1 → tip flips ABOVE
|
||||
const buyIdxAbove = 6; // row 1, col 2 (stock is filled there)
|
||||
const bc = cellWorldCenter(sv.buyGrid, buyIdxAbove);
|
||||
out.tooltip.mouseAbove = { cell: bc, move: mouseMove(bc.x, bc.y), state: tipState(sv.buyGrid) };
|
||||
// 3) BUY side: the WIDE ROW carries name + description + price INLINE (no
|
||||
// tooltip — the row is wide enough).
|
||||
const row0 = sv.buyList.rows[0];
|
||||
const rowNameEl = row0?.objs?.name;
|
||||
out.tooltip.buyRow = {
|
||||
name: rowNameEl?.text ?? null,
|
||||
desc: row0?.objs?.desc?.text ?? null,
|
||||
price: row0?.objs?.price?.text ?? null,
|
||||
qty: row0?.objs?.qty?.text ?? null,
|
||||
nameFont: rowNameEl?.style?.fontFamily ?? rowNameEl?._style?.fontFamily ?? null,
|
||||
rowWorld: rowNameEl ? { x: round(sv.buyList.x + rowNameEl.x), y: round(sv.buyList.y + rowNameEl.y) } : null,
|
||||
};
|
||||
|
||||
// 4) REAL mouse hover over a BUY cell — row 0 → tip flips BELOW
|
||||
const buyIdxBelow = 0;
|
||||
const bc0 = cellWorldCenter(sv.buyGrid, buyIdxBelow);
|
||||
out.tooltip.mouseBelow = { cell: bc0, move: mouseMove(bc0.x, bc0.y), state: tipState(sv.buyGrid) };
|
||||
|
||||
// 5) move the pointer OFF the panel → pointerout hides the tip
|
||||
const off = toClient(Math.max(4, sv.buyGrid.x - 30), 360);
|
||||
// 4) move the pointer OFF the panel → pointerout hides the tip
|
||||
const off = toClient(Math.max(4, sv.buyList.x - 30), 360);
|
||||
game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window }));
|
||||
await sleep(150);
|
||||
out.tooltip.mouseOff = { state: tipState(sv.buyGrid) };
|
||||
out.tooltip.mouseOff = { state: tipState(sv.sellGrid) };
|
||||
|
||||
// 6) end with the SELL tooltip visible for the screenshot
|
||||
sv.sellGrid.hoverCell(0);
|
||||
|
|
@ -166,7 +168,7 @@ const bounds = (o) => {
|
|||
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; })();
|
||||
out.sellGrid = { x: round(sv.sellGrid.x), y: round(sv.sellGrid.y), w: sv.sellGrid.width, h: sv.sellGrid.height };
|
||||
out.buyGrid = { x: round(sv.buyGrid.x), y: round(sv.buyGrid.y), w: sv.buyGrid.width, h: sv.buyGrid.height };
|
||||
out.buyList = { x: round(sv.buyList.x), y: round(sv.buyList.y), w: sv.buyList.width, h: sv.buyList.height, rows: sv.buyList.rows.length, maxScroll: sv.buyList.maxScroll };
|
||||
|
||||
window.__SHOP_SHOT__ = out;
|
||||
console.info('[shop-shot] OK:', JSON.stringify(out));
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@
|
|||
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
|
||||
* verified in the browser) — it tests the LOGIC the player feels:
|
||||
*
|
||||
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays,
|
||||
* 12–16 buy slots) and starts on the Buy/Sell tab.
|
||||
* - CONSTRUCT — the view builds the SELL grid + the BUY list over a real
|
||||
* shop (16 sell bays; one wide row per stocked good) and starts on the
|
||||
* Buy/Sell tab.
|
||||
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
|
||||
* Confirm earns the credits and drops the good out of the hold.
|
||||
* - BUY — stepping a stock good buys it at the shop's buy price; Confirm
|
||||
* spends the credits and fills the bays.
|
||||
* - BUY — stepping a stock row buys that good at the shop's buy price;
|
||||
* Confirm spends the credits and fills the bays.
|
||||
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
|
||||
* ends, off-window rows are clipped, and selection is data (stepping an
|
||||
* off-screen row still works).
|
||||
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
|
||||
* credits) or overflow the hold (cargo-full); selling to free room for a
|
||||
* buy lands in one confirm.
|
||||
|
|
@ -79,6 +83,7 @@ function stubObject(extra = {}) {
|
|||
setColor(c) { this.color = c; return this; },
|
||||
setFontSize() { return this; },
|
||||
setWordWrap() { return this; },
|
||||
setWordWrapWidth(w) { this._wrapW = w; return this; },
|
||||
setLineSpacing() { return this; },
|
||||
setPadding() { return this; },
|
||||
setScrollFactor() { return this; },
|
||||
|
|
@ -180,7 +185,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
|
|||
// 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.equal(view.buyList, null, 'no BUY list 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');
|
||||
|
|
@ -194,10 +199,23 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
|
|||
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');
|
||||
|
||||
// The trade ACTIONS live on the sub-bar too — [tabs] │ [Clear][Confirm] │
|
||||
// [NET value] — created up front (persistent across tabs), hidden until a
|
||||
// selection exists (_syncPreview reveals them), Confirm grayed while the
|
||||
// trade is invalid.
|
||||
assert.ok(view._clearBtn, 'Clear lives on the sub-bar (persistent, not in the panel)');
|
||||
assert.ok(view._confirmBtn, 'Confirm lives on the sub-bar (persistent, not in the panel)');
|
||||
assert.ok(view._clearBtn.x < view._confirmBtn.x, 'Clear sits left of Confirm on the bar');
|
||||
assert.ok(view._tabBuyback.x < view._clearBtn.x, '… after the tabs');
|
||||
assert.ok(view._confirmBtn.x < view._netLabel.x, '… and the NET readout rides to their right');
|
||||
assert.equal(view._clearBtn.alpha, 0, 'entry state: Clear is hidden (no selection yet)');
|
||||
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');
|
||||
|
||||
// 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');
|
||||
assert.ok(view.sellGrid && view.buyList, 'both the SELL grid and BUY list 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
|
||||
|
|
@ -210,8 +228,8 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
|
|||
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.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
|
||||
assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good');
|
||||
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
|
||||
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
|
||||
}
|
||||
|
|
@ -225,21 +243,18 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
|
|||
const { view } = makeView(ship);
|
||||
const goodById = (id) => goods.find((g) => g.id === id);
|
||||
|
||||
// Each BUY cell's icon must carry the correct spritesheet frame (set via
|
||||
// Each BUY row's icon must carry the correct spritesheet frame (set via
|
||||
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
|
||||
// corrupted the texture source and crashed the real WebGL render).
|
||||
let checkedIcons = 0;
|
||||
for (let i = 0; i < view.buyGrid.cells.length; i++) {
|
||||
const cell = view.buyGrid.cells[i];
|
||||
const id = view.buyGrid.stock[i]?.id;
|
||||
if (!id) continue;
|
||||
const idx = goodById(id)?.icon;
|
||||
assert.ok(typeof idx === 'number', `good ${id} has an icon index`);
|
||||
assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
|
||||
assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`);
|
||||
for (const row of view.buyList.rows) {
|
||||
const idx = goodById(row.id)?.icon;
|
||||
assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
|
||||
assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
|
||||
assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
|
||||
checkedIcons += 1;
|
||||
}
|
||||
assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound');
|
||||
assert.ok(checkedIcons >= 1, 'at least one buy row got its icon bound');
|
||||
|
||||
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
|
||||
// setInteractive() leaves no callback and crashes Phaser v4's input (the
|
||||
|
|
@ -247,15 +262,32 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
|
|||
// real hitArea/hitAreaCallback path is exercised.
|
||||
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
|
||||
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
|
||||
assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback');
|
||||
assert.equal(typeof view.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback');
|
||||
assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback');
|
||||
|
||||
// The two panels must settle at DIFFERENT layout positions — the slide-in
|
||||
// tween targets each grid's set position (the ShopView centers
|
||||
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels
|
||||
// at the left edge so the cargo/sell panel sat hidden behind the buy panel.
|
||||
// tween targets each grid's set position. The old bug tweened to x:0,
|
||||
// stacking both panels at the left edge so the cargo/sell panel sat hidden
|
||||
// behind the buy panel.
|
||||
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
|
||||
assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`);
|
||||
assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin');
|
||||
assert.ok(view.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`);
|
||||
assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin');
|
||||
assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)');
|
||||
|
||||
// The panel spans the screen like the HUD: the SELL grid's LEFT edge is
|
||||
// the planet text's left edge (landing.hud.marginX), and the BUY list's
|
||||
// RIGHT edge is the SAME padding in from the screen's right edge.
|
||||
const leftPad = Number(config.get('landing.hud.marginX')) || 24;
|
||||
const W = 1280; // the stub scene's width
|
||||
assert.equal(view.sellGrid.x, leftPad, `SELL's left edge is flush with the planet text's left edge (x=${view.sellGrid.x}, hud margin=${leftPad})`);
|
||||
const rightEdge = view.buyList.x + view.buyList.width;
|
||||
assert.ok(Math.abs(rightEdge - (W - leftPad)) < 1, `BUY's right edge is the same padding in from the right (rightEdge=${rightEdge}, want ${W - leftPad})`);
|
||||
|
||||
// The two panels sit NEXT TO each other with just a bit of padding between
|
||||
// (grid.centerW — the actions moved onto the sub-bar, so there's no center
|
||||
// column anymore).
|
||||
const gapPx = view.buyList.x - (view.sellGrid.x + view.sellGrid.width);
|
||||
assert.ok(Math.abs(gapPx - 16) < 1, `BUY is right next to SELL, small padding (got gap ${gapPx.toFixed(1)} px, want ~16)`);
|
||||
}
|
||||
console.log('INTERACTION: OK');
|
||||
|
||||
|
|
@ -267,8 +299,8 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
|
|||
const { view } = makeView(ship);
|
||||
const nameA = goods.find((g) => g.id === A).name;
|
||||
const sellA = E.sellPrice(A, cls, sysId, galaxy);
|
||||
const buyId = view.buyGrid.stock[0]?.id;
|
||||
const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
|
||||
const buyId = view.buyList.stock[0]?.id;
|
||||
const nameB = view.buyList.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
|
||||
const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
|
||||
|
||||
// Cells no longer paint their own name/price text — the info lives in the
|
||||
|
|
@ -300,11 +332,29 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
|
|||
assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
|
||||
assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
|
||||
|
||||
// BUY side: name + "buy for N cr".
|
||||
view.buyGrid.hoverCell(0);
|
||||
assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip');
|
||||
assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name');
|
||||
assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`);
|
||||
// BUY side: no tooltip — the WIDE ROW carries name + description + price
|
||||
// inline (the row is wide enough, unlike the icon-only sell cells).
|
||||
const row0 = view.buyList.rows[0];
|
||||
assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline');
|
||||
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price inline');
|
||||
const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? '';
|
||||
assert.ok(row0.objs.desc.text.length >= 3, 'row carries the description');
|
||||
assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)');
|
||||
|
||||
// DESCRIPTION WRAP: smaller font (11px), wraps to up to 2 lines, and the
|
||||
// wrap width stops BEFORE the price/stepper so a wrapped line can never
|
||||
// overlap them (the char-count budget is the headless fail-safe).
|
||||
assert.equal(row0.objs.desc._style?.fontSize, '11px', 'desc uses the smaller 11px font');
|
||||
assert.ok(row0.objs.desc._wrapW > 0, 'desc has a word-wrap width set (real Phaser wraps inside the text column)');
|
||||
const descMaxW = view.buyList._descMaxW();
|
||||
assert.ok(descMaxW > 0, 'the text column has a positive width');
|
||||
assert.ok(row0.objs.desc._wrapW <= descMaxW + 0.001, `… and the wrap width fits inside it (wrap=${row0.objs.desc._wrapW}, col=${descMaxW.toFixed(1)})`);
|
||||
const budget = Math.max(4, Math.floor(descMaxW / (11 * 0.55))) * 2 + 1; // 2 lines + ellipsis room
|
||||
assert.ok(row0.objs.desc.text.length <= budget, `desc stays inside the two-line budget (len=${row0.objs.desc.text.length} ≤ ${budget})`);
|
||||
// The wrapped desc never bleeds into the price: name/desc start at the text
|
||||
// column's left edge, well clear of the price's right-aligned x.
|
||||
const priceX = view.buyList._btnXs().price;
|
||||
assert.ok(view.buyList._textX() + descMaxW < priceX, 'text column + max desc width ends before the price');
|
||||
|
||||
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
|
||||
// Ethnocentric for section headers, Centauri for body text + buttons.
|
||||
|
|
@ -312,7 +362,7 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
|
|||
assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`);
|
||||
assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`);
|
||||
assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face');
|
||||
assert.ok(/Centauri/.test(fam(view.buyGrid.cells[0].stepQty)), 'buy stepper uses the body face');
|
||||
assert.ok(/Centauri/.test(fam(view.buyList.rows[0].objs.qty)), 'buy row stepper uses the body face');
|
||||
assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face');
|
||||
}
|
||||
console.log('HOVER TOOLTIP: OK');
|
||||
|
|
@ -333,12 +383,25 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold
|
|||
assert.equal(pv.net, sellA, '… net = the sale (nothing bought)');
|
||||
assert.equal(pv.valid, true, '… and it\u2019s legal');
|
||||
|
||||
// The live NET readout rides on the TAB SUB-BAR (not the center column) —
|
||||
// it mirrors the pending trade's credit delta, one glance from the tabs —
|
||||
// alongside the Confirm/Clear actions (which light up with a selection).
|
||||
assert.ok(view._netValue, 'the sub-bar carries the NET value readout');
|
||||
assert.ok(view._netLabel, '… with the NET label');
|
||||
assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr');
|
||||
assert.ok(view._confirmBtn.alpha > 0.5, 'a selection reveals the sub-bar Confirm');
|
||||
assert.ok(view._clearBtn.alpha > 0.5, '… and Clear');
|
||||
assert.equal(view._confirmBtn.disabled, false, '… and Confirm is live (valid trade)');
|
||||
|
||||
const before = ship.credits;
|
||||
assert.equal(view.confirm(), true, 'Confirm goes through');
|
||||
assert.equal(ship.credits, before + sellA, 'credits rise by the sale');
|
||||
assert.deepEqual(ship.cargo.snapshot(), [B], 'A leaves the hold, B stays');
|
||||
assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
|
||||
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed');
|
||||
assert.equal(view._netValue.text, '0 cr', '… and the bar’s NET settles back to 0 cr');
|
||||
assert.equal(view._confirmBtn.alpha, 0, '… and the Confirm/Clear actions hide again (selection gone)');
|
||||
assert.equal(view._clearBtn.alpha, 0, '… Clear hides too');
|
||||
}
|
||||
console.log('SELL: OK');
|
||||
|
||||
|
|
@ -350,8 +413,8 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
|
|||
const buyC = E.buyPrice(C, cls, sysId, galaxy);
|
||||
assert.ok(buyC > 0, 'the buy price is positive');
|
||||
|
||||
view.buyGrid.bumpBuy(C, 2);
|
||||
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
|
||||
view.buyList.bumpBuy(C, 2);
|
||||
assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
|
||||
let pv = view.preview();
|
||||
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
|
||||
assert.equal(pv.net, -buyC * 2, '… net = the spend');
|
||||
|
|
@ -365,6 +428,41 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
|
|||
}
|
||||
console.log('BUY: OK');
|
||||
|
||||
// ===========================================================================
|
||||
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
|
||||
{
|
||||
const ship = makeShip(10000);
|
||||
const { view } = makeView(ship);
|
||||
const list = view.buyList;
|
||||
assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`);
|
||||
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
|
||||
// window, so the list must scroll (rows that don't fit were the old bug).
|
||||
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
|
||||
|
||||
// Scrolling clamps at both ends.
|
||||
list.scrollBy(100000);
|
||||
assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom');
|
||||
list.scrollBy(-100000);
|
||||
assert.equal(list._scroll, 0, 'scroll clamps back at the top');
|
||||
|
||||
// Row-based clipping: mid-scroll, only the window's rows are shown.
|
||||
list.scrollTo(list.maxScroll / 2);
|
||||
const shown = list.rows.filter((r) => r.shown);
|
||||
const hidden = list.rows.filter((r) => !r.shown);
|
||||
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
|
||||
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
|
||||
|
||||
// Selection is DATA, not position: stepping a good works whether its row
|
||||
// is on screen or not (the player can scroll, then adjust, freely).
|
||||
const last = list.stock[list.stock.length - 1].id;
|
||||
list.bumpBuy(last, 1);
|
||||
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');
|
||||
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
|
||||
list.clearSelection();
|
||||
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
|
||||
}
|
||||
console.log('SCROLL: OK');
|
||||
|
||||
// ===========================================================================
|
||||
console.log('\n== CAPS — blocked when negative or over the hold ==');
|
||||
{
|
||||
|
|
@ -372,11 +470,12 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
|
|||
const broke = makeShip(0);
|
||||
const buyC = E.buyPrice(C, cls, sysId, galaxy);
|
||||
const { view: vb } = makeView(broke);
|
||||
vb.buyGrid.bumpBuy(C, 1);
|
||||
vb.buyList.bumpBuy(C, 1);
|
||||
let pv = vb.preview();
|
||||
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
|
||||
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
|
||||
assert.equal(vb.confirm(), false, '… and Confirm is refused');
|
||||
assert.equal(vb._confirmBtn.disabled, true, '… and the sub-bar Confirm is grayed (invalid trade)');
|
||||
assert.equal(broke.credits, 0, '… nothing spent');
|
||||
assert.equal(broke.cargo.used, 0, '… nothing bought');
|
||||
|
||||
|
|
@ -384,7 +483,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
|
|||
const full = makeShip(100000);
|
||||
full.cargo.add([A, B, A, B]); // 4 bays, no room
|
||||
const { view: vf } = makeView(full);
|
||||
vf.buyGrid.bumpBuy(C, 1);
|
||||
vf.buyList.bumpBuy(C, 1);
|
||||
pv = vf.preview();
|
||||
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
|
||||
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');
|
||||
|
|
@ -395,6 +494,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
|
|||
pv = vf.preview();
|
||||
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits');
|
||||
assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)');
|
||||
assert.equal(vf._confirmBtn.disabled, false, '… and Confirm un-grays (the trade is valid again)');
|
||||
const before = full.credits;
|
||||
assert.equal(vf.confirm(), true, '… and it goes through');
|
||||
assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)');
|
||||
|
|
@ -453,6 +553,8 @@ console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==
|
|||
view.clear();
|
||||
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection');
|
||||
assert.equal(view.preview().net, 0, '… and the net returns to zero');
|
||||
assert.equal(view._confirmBtn.alpha, 0, '… and the sub-bar Confirm hides again');
|
||||
assert.equal(view._clearBtn.alpha, 0, '… and Clear');
|
||||
}
|
||||
console.log('LOCK + CLEAR: OK');
|
||||
|
||||
|
|
|
|||
|
|
@ -247,7 +247,8 @@ That's flavor, not management.
|
|||
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
|
||||
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. |
|
||||
| `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. |
|
||||
| `js/ui/shop/TradeGrid.js` | One side of the shop: a 4×4 grid of goods. SELL mode = the player's cargo bays (tap to check); BUY mode = the shop's stock (−/qty/+ stepper). |
|
||||
| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays (tap to check off, hover tooltip with name + price). |
|
||||
| `js/ui/shop/BuyList.js` | The shop's BUY side: the stock as a scrollable list of WIDE ROWS (icon + name + one-line description + price + −/qty/+ stepper; drag/wheel to scroll). |
|
||||
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. |
|
||||
| `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. |
|
||||
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
|
||||
|
|
@ -295,58 +296,90 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr
|
|||
|
||||
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
|
||||
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.
|
||||
it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL
|
||||
side) + a `BuyList` (the BUY side).
|
||||
|
||||
**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
|
||||
is up — one console-blue bar in the deck's cyberpunk **MenuButton** style
|
||||
(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade
|
||||
actions, and the live **NET** readout, past hairline dividers; Buyback grayed, the
|
||||
actions hidden until you start picking, the readout dim `0 cr`), 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).
|
||||
**Buy/Sell** reveals the trade panel in the middle of the screen: the **SELL grid** and the
|
||||
**BUY list** side by side with just a bit of padding between (the panels slide in). The
|
||||
panel **spans the screen** — the SELL grid's left edge is **flush with the planet name**
|
||||
(the HUD's left margin), and the BUY list stretches to the **same padding** in from the
|
||||
right edge.
|
||||
|
||||
```
|
||||
# first click — shop video + the tab sub-bar (rests above the deck):
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ (clip plays) │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ BUY/SELL │ BUYBACK │ ← sub-bar │
|
||||
│ └─────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET 0 cr │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# after clicking Buy/Sell — the trade panel reveals (middle of the screen):
|
||||
│ SELL and BUY sit NEXT TO each other (a small gap between);
|
||||
│ the trade actions + NET readout ride on the sub-bar below.
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ┌SELL 4×4┐ NET +420 cr ┌BUY 4×4┐ │
|
||||
│ │ bays │ (green ≥ 0 / │ stock │ │
|
||||
│ │(cargo) │ red < 0) │(12–16) │ │
|
||||
│ └────────┘ [ CONFIRM ] └────────┘ │
|
||||
│ [ CLEAR ] │
|
||||
│ ┌─────────────────┐ ┌───────────────────────────────────┐ │
|
||||
│ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │
|
||||
│ │ your cargo │ │ Crisp, clean, alive. A small mir… │ │
|
||||
│ │ (tap = sell) │ │[i] Refined Fuel 41 cr − 1 +│ │
|
||||
│ │ │ │ The lifeblood of the fleet. │ │
|
||||
│ └─────────────────┘ │ … (scrolls) … │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ BUY/SELL │ BUYBACK │ ← sub-bar │
|
||||
│ └─────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET +420 cr │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 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
|
||||
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear**
|
||||
trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk
|
||||
style: cut-corner panel, neon edge, hover sweep, press punch) in one 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. The bar is **extended** past hairline dividers to carry the actions *and* the
|
||||
**NET** readout — one glance sees the tabs, the pending trade's credit delta, and the
|
||||
button that executes it.
|
||||
- **Deck lock (while the shop is up)** — the command deck's other slots (Build, Ship,
|
||||
Quests, Take Off, Menu) go **standby**: dim, no hover, no press — the same inert
|
||||
language as the comm-hold pause (`ActionBar.setSlotEnabled`). **LEAVE SHOP is the only
|
||||
live button on the deck**; the press that opened the shop is the only way out. They
|
||||
re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the
|
||||
deck-wide comm hold still owns input if it fires mid-shop.)
|
||||
- **Confirm / Clear** — the same `MenuButton` style, on the **tab sub-bar** between the
|
||||
tabs and the NET readout (they serve BOTH the trade and the buyback tabs, and persist
|
||||
while you switch between them); 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
|
||||
quantity is simply the number of its bays that are checked.
|
||||
- **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. Updates as you check bays / step goods.
|
||||
- **BUY (right, next to the SELL grid)** — the shop's **stock** (12–16 distinct goods from
|
||||
`Economy.shopFor`) as a **scrollable list of WIDE ROWS**: each row is the good's
|
||||
**icon**, its **name** + a **description** (the good's `flavor`, a smaller font that
|
||||
**wraps to up to two lines** and never overlaps the price/stepper), and the **price** +
|
||||
a **−/qty/+ stepper** on the right. The stock is longer than the panel, so the list
|
||||
**scrolls** (drag on the list body or the mouse wheel) — rows outside the window are
|
||||
clipped; the selection is data, so stepping a good works whether its row is on screen
|
||||
or not. Buy as many as you like (capped by credits + hold room).
|
||||
- **NET readout (tab sub-bar)** — the live credit delta, at the bar's right end past the
|
||||
second divider (right of the tabs and the Confirm/Clear actions).
|
||||
Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check
|
||||
bays / step goods (it's the same `preview()` the Confirm button is gated on).
|
||||
- **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.
|
||||
|
|
@ -363,7 +396,7 @@ the same stock from `(seed, placeId, economyVersion)`. The only session state is
|
|||
list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
|
||||
against fixed regional prices, never against your own history.
|
||||
|
||||
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct both grids, sell a bay and earn the
|
||||
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct the SELL grid + BUY list, sell a bay and earn the
|
||||
sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking
|
||||
Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout
|
||||
and the slide-in animation are verified in the browser (the stub doesn't render).
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.shopVideo = null;
|
||||
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
|
||||
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
|
||||
this.setDeckLocked(false); // the deck was locked to the shop — release it
|
||||
this.consoleNote('SHOP CLIP UNAVAILABLE');
|
||||
});
|
||||
}
|
||||
|
|
@ -298,6 +299,7 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.shopVideo.resume();
|
||||
this.shopMode = true;
|
||||
this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP
|
||||
this.setDeckLocked(true); // the shop is up — the deck's other slots go standby
|
||||
this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js)
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +318,24 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface
|
||||
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
|
||||
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
|
||||
this.setDeckLocked(false); // the shop is down — the deck's other slots come back
|
||||
}
|
||||
|
||||
/**
|
||||
* DECK LOCK — while the shop clip is up, the deck's slots go standby
|
||||
* (dim, inert: no hover, no press) EXCEPT the SHOP slot — LEAVE SHOP
|
||||
* is the only live button on the deck, the same one-live-control
|
||||
* language as the 'ack' comm's hold (ActionBar.setEnabled). Re-enables
|
||||
* everything on unlock; the shop slot is untouched in both directions
|
||||
* (it's the toggle that owns the lock). Idempotent — safe on re-entry.
|
||||
*/
|
||||
setDeckLocked(locked) {
|
||||
const bar = this.actionBar;
|
||||
if (!bar || !Array.isArray(bar.slots)) return;
|
||||
for (const s of bar.slots) {
|
||||
if (!s.live || s.id === 'shop') continue; // SHOP is the way out
|
||||
bar.setSlotEnabled(s.id, !locked);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -359,6 +359,8 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
* reserved sockets — so the ACKNOWLEDGE button is the only live
|
||||
* control on the screen while the comm holds. Idempotent (a no-op
|
||||
* when the state is unchanged) — the scene may call it every frame.
|
||||
* Sets each slot's `disabled` flag too, so a per-slot lock (see
|
||||
* setSlotEnabled) and the deck-wide standby can't disagree.
|
||||
*/
|
||||
setEnabled(on) {
|
||||
const want = on !== false;
|
||||
|
|
@ -366,6 +368,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
this.enabled = want;
|
||||
for (const s of this.slots) {
|
||||
if (!s.live) continue;
|
||||
s.disabled = !want;
|
||||
if (want) {
|
||||
s.panel.setInteractive(this.slotHit()); // re-arm (v4: flips input.enabled)
|
||||
} else {
|
||||
|
|
@ -383,6 +386,41 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setSlotEnabled(id, on) — the DECK-LOCK state (one slot stays live,
|
||||
* the rest go standby — e.g. the surface deck while the shop clip is
|
||||
* up: every slot but SHOP is inert, and LEAVE SHOP is the only way
|
||||
* back). Same standby language as setEnabled: no hover, no press,
|
||||
* clicks swallowed, dim like the reserved sockets. Idempotent.
|
||||
* Returns the slot, or null if id has no live slot.
|
||||
*/
|
||||
setSlotEnabled(id, on) {
|
||||
const s = (this.slots ?? []).find((sl) => sl.id === id);
|
||||
if (!s || !s.live || this.dead) return null;
|
||||
const want = on !== false;
|
||||
if ((s.disabled !== true) === want) return s; // already in the wanted state
|
||||
s.disabled = !want;
|
||||
if (want) {
|
||||
// Re-arm — unless the DECK is in standby (setEnabled(false)): the
|
||||
// deck-wide hold owns input (the 'ack' comm's contract), so a
|
||||
// per-slot unlock must not re-open a slot underneath it.
|
||||
if (this.enabled !== false) s.panel.setInteractive(this.slotHit());
|
||||
this.paintSlot(s, 'base');
|
||||
} else {
|
||||
s.hoverOn = false; // hover is over (the paint below is the truth)
|
||||
s.sweep.setAlpha(0);
|
||||
if (s._sweep) {
|
||||
s._sweep.remove();
|
||||
s._sweep = null;
|
||||
}
|
||||
if (s._scaleTw) s._scaleTw.stop(); // the hover scale punch is a tween
|
||||
s.slot.setScale(1); // land any in-flight hover scale
|
||||
s.panel.disableInteractive(); // no more pointer events, cursor reset
|
||||
this.paintSlot(s, 'disabled');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Repaint a slot for a visual state: 'base' | 'hover' | 'press' | 'disabled'. */
|
||||
paintSlot(s, state) {
|
||||
const st = this.style;
|
||||
|
|
@ -454,7 +492,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
setHover(s, on) {
|
||||
if (!s.live || this.dead || this.enabled === false) return; // standby: inert
|
||||
if (!s.live || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert
|
||||
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||
s.hoverOn = on;
|
||||
this.paintSlot(s, on ? 'hover' : 'base');
|
||||
|
|
@ -488,7 +526,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
|
||||
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
|
||||
press(s) {
|
||||
if (!s.live || s.pressing || this.dead || this.enabled === false) return; // standby: inert
|
||||
if (!s.live || s.pressing || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert
|
||||
// SCAN plays its own sonar ping (the scene's onAction) — no double tick.
|
||||
if (s.id !== 'scan') this.scene.playSfx?.('ui_click');
|
||||
s.pressing = true;
|
||||
|
|
@ -532,8 +570,9 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
const k = Math.max(10, (s.fontSize * max) / s.label.width);
|
||||
for (const o of [s.label, s.ghostCyan, s.ghostMagenta]) o?.setFontSize(`${k}px`);
|
||||
}
|
||||
// Re-assert the current visual state (hover keeps the white label).
|
||||
this.paintSlot(s, s.hoverOn ? 'hover' : 'base');
|
||||
// Re-assert the current visual state (hover keeps the white label;
|
||||
// a locked slot keeps its standby paint — setSlotEnabled's truth).
|
||||
this.paintSlot(s, s.disabled === true ? 'disabled' : (s.hoverOn ? 'hover' : 'base'));
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* BuyList — the shop's BUY side: a scrollable list of stock rows.
|
||||
*
|
||||
* Each stock good is ONE WIDE ROW (replacing the old 4×4 icon grid):
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────────┐
|
||||
* │ BUY │
|
||||
* │ ┌────────────────────────────────────────────────────────┐ │
|
||||
* │ │ [icon] Fresh Produce 24 cr − 0 +│ │
|
||||
* │ │ Crisp, clean, alive. A small mir… │ │
|
||||
* │ │ [icon] Refined Fuel 41 cr − 1 +│ │
|
||||
* │ │ The lifeblood of the fleet. │ │
|
||||
* │ └────────────────────────────────────────────────────────┘ │
|
||||
* └────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* · LEFT — the good's icon (its goods-sheet frame).
|
||||
* · MIDDLE — the NAME (top line) + its one-line DESCRIPTION (the good's
|
||||
* `flavor` from data/goods.json), truncated to fit.
|
||||
* · RIGHT — the BUY PRICE ("24 cr") + the −/qty/+ stepper.
|
||||
*
|
||||
* A row with qty > 0 (in the pending trade) is highlighted — the same
|
||||
* selection the old grid's steppers tracked (getBuyMap).
|
||||
*
|
||||
* SCROLLING — 8–16 rows don't fit in the panel, so the list scrolls:
|
||||
* drag on the list body, or the mouse wheel (Phaser 4's object-level
|
||||
* pointermove + wheel events, the MapWindow.js recipe). Rows outside the
|
||||
* viewport are hidden (row-based clipping — this Phaser build, 4.2.1 Giedi,
|
||||
* has no camera/mask clip and no ScrollContainer). Scrolling never touches
|
||||
* the SELECTION: buyQty is plain data keyed by good id, so stepping a good
|
||||
* works whether or not its row is on screen.
|
||||
*
|
||||
* The PRICING lives in js/economy/Economy.js (shopFor → the fixed
|
||||
* per-(good, shop) buy price); this component only shows it and tracks the
|
||||
* SELECTION. ShopView (js/ui/shop/ShopView.js) composes it beside the SELL
|
||||
* grid (TradeGrid), runs the trade preview (Economy.tradePreview) and
|
||||
* applies it to the ship.
|
||||
*
|
||||
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
|
||||
* selection + scroll LOGIC (bumpBuy / getBuyMap / scrollBy) is plain data so
|
||||
* dev/shop-ui.test.mjs can drive it with no scene.
|
||||
*/
|
||||
import Phaser from '../../vendor/phaser.js';
|
||||
import { config } from '../../config/Config.js';
|
||||
import { toColor, toCss } from '../../utils/Color.js';
|
||||
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
|
||||
import { fontStack } from '../../utils/Theme.js';
|
||||
|
||||
export class BuyList extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* @param {object} scene — the SurfaceScene (or a headless fake).
|
||||
* @param {object} o — { width, height } (ShopView sizes it; height matches
|
||||
* the SELL grid so the two panels align top AND bottom).
|
||||
*/
|
||||
constructor(scene, o = {}) {
|
||||
super(scene, 0, 0);
|
||||
this.scene = scene;
|
||||
const bl = config.section('shopui.buyList', {});
|
||||
this.width = Math.max(320, Number(o.width) || bl.w || 560);
|
||||
this.height = Math.max(160, Number(o.height) || 480);
|
||||
this.pad = bl.pad ?? 14;
|
||||
this.titleH = config.get('shopui.panel.title.h', 34);
|
||||
this.rowH = bl.rowH ?? 72;
|
||||
this.rowGap = bl.rowGap ?? 6;
|
||||
this.iconSize = bl.icon ?? 44;
|
||||
this.iconGap = bl.iconGap ?? 12;
|
||||
// The description may WRAP onto up to `maxLines` lines (real Phaser
|
||||
// word-wraps it at the text column's width — see _addRow; the headless
|
||||
// char-count fit below budgets maxLines lines so it never bleeds into
|
||||
// the price/stepper either way).
|
||||
this.descLines = Math.max(1, Math.floor(Number(bl.desc?.maxLines) || 2));
|
||||
this.st = config.section('shopui.stepper', {}); // shared stepper knobs
|
||||
this.stepperW = this.st.w ?? 88;
|
||||
this.priceW = bl.price?.w ?? 64;
|
||||
this.bl = bl;
|
||||
|
||||
// The viewport (the scroll window, under the title) — the list's
|
||||
// content taller than this is what scrolls.
|
||||
this.vpX = this.pad;
|
||||
this.vpY = this.titleH + this.pad;
|
||||
this.vpW = this.width - this.pad * 2;
|
||||
this.vpH = Math.max(40, this.height - this.vpY - this.pad);
|
||||
|
||||
// --- state -----------------------------------------------------------
|
||||
this.stock = []; // [{ id, name, price }] (shopFor output)
|
||||
this.buyQty = new Map(); // goodId -> qty in the pending trade
|
||||
this.rows = []; // per row: { id, name, desc, price, objs, y0, shown }
|
||||
this._objs = []; // everything we create (destroy())
|
||||
this._surface = null; // the scroll surface (drag + wheel)
|
||||
this._drag = null; // { y } while a press drags the list
|
||||
|
||||
// --- geometry (scroll) ----------------------------------------------
|
||||
this._scroll = 0; // px scrolled from the top
|
||||
this.maxScroll = 0; // max(0, contentH - vpH), set on refresh
|
||||
|
||||
this._buildPanel();
|
||||
this._buildTitle();
|
||||
this._buildSurface(); // BEFORE the rows (rows paint above it)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ panel
|
||||
_buildPanel() {
|
||||
const p = config.get('shopui.panel', {});
|
||||
const gr = this.scene.add?.graphics?.();
|
||||
if (gr) {
|
||||
this._objs.push(gr);
|
||||
gr.clear?.();
|
||||
if (typeof gr.fillStyle === 'function') {
|
||||
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), p.bgAlpha ?? 0.7);
|
||||
gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), 255);
|
||||
}
|
||||
gr.fillRect?.(0, 0, this.width, this.height);
|
||||
// Parent it to the list (same reason as TradeGrid — scene-level depth
|
||||
// 0 renders BEHIND the shop video in this build; container children
|
||||
// inherit the list's depth + translation). Added first = behind rows.
|
||||
this.add?.(gr);
|
||||
}
|
||||
}
|
||||
|
||||
_buildTitle() {
|
||||
const t = this.scene.add?.text?.(
|
||||
this.width / 2, 8, 'BUY',
|
||||
{
|
||||
fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`,
|
||||
color: config.get('shopui.panel.title.color', '#eaf6ff'),
|
||||
align: 'center', fontFamily: fontStack('header'), // Ethnocentric — the theme's section face
|
||||
},
|
||||
);
|
||||
if (t) {
|
||||
t.setOrigin?.(0.5, 0);
|
||||
this.title = t;
|
||||
this._objs.push(t);
|
||||
this.add?.(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- scroll surface
|
||||
/**
|
||||
* The scroll surface: an (undrawn) interactive rect over the viewport.
|
||||
* Drag (pointerdown → pointermove → pointerup/out) and wheel scroll the
|
||||
* list — the MapWindow.js recipe (Phaser 4's input plugin delivers object-
|
||||
* level pointermove + wheel over the hit area). The row steppers sit ABOVE
|
||||
* it and stopPropagation() their clicks, so tapping −/+ never drags.
|
||||
*/
|
||||
_buildSurface() {
|
||||
const g = this.scene.add?.graphics?.();
|
||||
if (!g) return;
|
||||
this._surface = g;
|
||||
this._objs.push(g);
|
||||
this.add?.(g);
|
||||
if (typeof g.setInteractive !== 'function' || typeof g.on !== 'function') return;
|
||||
|
||||
const Rect = Phaser.Geom?.Rectangle;
|
||||
const area = Rect ? new Rect(this.vpX, this.vpY, this.vpW, this.vpH) : null;
|
||||
g.setInteractive({
|
||||
useHandCursor: false,
|
||||
hitArea: area,
|
||||
hitAreaCallback: (p, px, py) => (area ? Rect.Contains(p, px, py) : true),
|
||||
});
|
||||
g.on('pointerdown', (p) => { this._drag = { y: p?.y ?? 0 }; });
|
||||
g.on('pointermove', (p) => {
|
||||
if (this._drag && p && typeof p.y === 'number') {
|
||||
this.scrollBy(p.y - this._drag.y);
|
||||
this._drag = { y: p.y };
|
||||
}
|
||||
});
|
||||
g.on('pointerup', () => { this._drag = null; });
|
||||
g.on('pointerout', () => { this._drag = null; }); // left the list → end drag
|
||||
g.on('wheel', (p, _dx, dy) => {
|
||||
// line-mode wheels report small dy (MapWindow's deltaMode check).
|
||||
const dyPx = (p?.event?.deltaMode ?? 0) === 1 ? dy * 32 : dy;
|
||||
if (dyPx) this.scrollBy(dyPx);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rows
|
||||
/**
|
||||
* Set the list's contents from the shop's stock (Economy.shopFor → buy:
|
||||
* [{ id, name, price, produced }]). The description is the good's `flavor`
|
||||
* line (data/goods.json). Resets the selection (a fresh shop view).
|
||||
*/
|
||||
refresh(o = {}) {
|
||||
const stock = Array.isArray(o.stock) ? o.stock : [];
|
||||
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
|
||||
this.buyQty = new Map();
|
||||
this._destroyRows();
|
||||
for (const s of this.stock) this._addRow(s);
|
||||
this._layoutRows();
|
||||
this._clipRows();
|
||||
return this;
|
||||
}
|
||||
|
||||
_addRow(s) {
|
||||
const bl = this.bl;
|
||||
const id = s.id;
|
||||
const name = s.name ?? id;
|
||||
const price = Math.round(Number(s.price) || 0);
|
||||
const flavor = this._flavorOf(id);
|
||||
|
||||
const row = { id, name, desc: '', price, objs: {}, y0: 0, shown: true };
|
||||
|
||||
// row plate (bg + border) — the whole row's width, in the viewport
|
||||
const bg = this.scene.add?.graphics?.();
|
||||
if (bg) { row.objs.bg = bg; this._objs.push(bg); this.add?.(bg); }
|
||||
|
||||
// icon (guarded — headless has no texture). Created with a placeholder
|
||||
// frame, then bound to THIS good's frame via setTexture(key, frame) — the
|
||||
// TradeGrid recipe (NOT the read-only `.frame = n` assignment that
|
||||
// corrupted the texture source and crashed the real WebGL render).
|
||||
if (this.scene.textures && typeof this.scene.textures.exists === 'function' &&
|
||||
this.scene.textures.exists(GOODS_TEXTURE_KEY)) {
|
||||
const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0);
|
||||
if (icon) {
|
||||
icon.setOrigin?.(0.5, 0.5);
|
||||
icon.setScale?.(this.iconSize / 128); // the sheet's frames are 128×128
|
||||
icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(id));
|
||||
row.objs.icon = icon; this._objs.push(icon); this.add?.(icon);
|
||||
}
|
||||
}
|
||||
|
||||
// name + description (left of the price/stepper block)
|
||||
const descFont = Number(bl.desc?.fontSize) || 12;
|
||||
const textX = this._textX();
|
||||
const nameEl = this._makeText(
|
||||
textX, 0, name,
|
||||
{ fontSize: `${bl.name?.fontSize ?? 14}px`, color: bl.name?.color ?? '#eaf6ff', align: 'left' },
|
||||
);
|
||||
if (nameEl) { nameEl.setOrigin?.(0, 0.5); row.objs.name = nameEl; this._objs.push(nameEl); this.add?.(nameEl); }
|
||||
|
||||
row.desc = this._fitText(flavor, descFont, this.descLines);
|
||||
const descEl = this._makeText(
|
||||
textX, 0, row.desc,
|
||||
{ fontSize: `${descFont}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
|
||||
);
|
||||
if (descEl) {
|
||||
// Word-wrap inside the text column — the wrap width stops BEFORE the
|
||||
// price and stepper (_descMaxW()), so a wrapped second line can never
|
||||
// overlap them. The stub no-ops these (the char-count fit keeps it
|
||||
// bounded there).
|
||||
descEl.setWordWrap?.(true);
|
||||
descEl.setWordWrapWidth?.(this._descMaxW());
|
||||
descEl.setOrigin?.(0, 0.5);
|
||||
row.objs.desc = descEl; this._objs.push(descEl); this.add?.(descEl);
|
||||
}
|
||||
|
||||
// price (right of the text, left of the stepper) — "24 cr"
|
||||
const priceEl = this._makeText(
|
||||
0, 0, `${price} cr`,
|
||||
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: bl.price?.color ?? '#ffc94d', align: 'right' },
|
||||
);
|
||||
if (priceEl) { priceEl.setOrigin?.(1, 0.5); row.objs.price = priceEl; this._objs.push(priceEl); this.add?.(priceEl); }
|
||||
|
||||
// stepper: − qty + (the qty tap = +1, the old grid's behavior)
|
||||
const mkStep = (str) => this._makeText(0, 0, str, {
|
||||
fontSize: `${this.st.fontSize ?? 19}px`,
|
||||
color: this.st.btn ?? '#7d92c4', align: 'center',
|
||||
});
|
||||
const minus = mkStep('−');
|
||||
const qty = mkStep('0');
|
||||
const plus = mkStep('+');
|
||||
const hit = (el) => (el && el.width > 0 ? el.width + 8 : 22);
|
||||
const bx = this._btnXs();
|
||||
const stepHit = Math.max(hit(minus), hit(plus), 22) / 2;
|
||||
if (minus) { row.objs.minus = minus; this._objs.push(minus); this.add?.(minus); }
|
||||
if (qty) { row.objs.qty = qty; this._objs.push(qty); this.add?.(qty); }
|
||||
if (plus) { row.objs.plus = plus; this._objs.push(plus); this.add?.(plus); }
|
||||
// The steppers sit on the scroll surface; stopPropagation keeps a
|
||||
// stepper tap from starting a list drag (TradeGrid's recipe).
|
||||
this._bindStep(minus, () => this.bumpBuy(id, -1), bx.minus, stepHit, row);
|
||||
this._bindStep(qty, () => this.bumpBuy(id, +1), bx.qty, stepHit, row);
|
||||
this._bindStep(plus, () => this.bumpBuy(id, +1), bx.plus, stepHit, row);
|
||||
|
||||
this.rows.push(row);
|
||||
}
|
||||
|
||||
_bindStep(el, fn, x, half, row) {
|
||||
if (!el || typeof el.setInteractive !== 'function' || typeof el.on !== 'function') return;
|
||||
const Rect = Phaser.Geom?.Rectangle;
|
||||
const area = Rect ? new Rect(-half, -half, half * 2, half * 2) : null;
|
||||
el.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: area,
|
||||
// Dead when the row is scrolled out of the viewport (alpha-0 buttons
|
||||
// must not be tappable — gate the hit test on row visibility too).
|
||||
hitAreaCallback: (p, px, py) => (row.shown ? (area ? Rect.Contains(p, px, py) : true) : false),
|
||||
});
|
||||
el.on('pointerdown', (p) => {
|
||||
p?.stopPropagation?.();
|
||||
if (row.shown) fn();
|
||||
});
|
||||
}
|
||||
|
||||
// Text block's left edge (after the icon) — shared by name + desc.
|
||||
_textX() {
|
||||
return -this.vpW / 2 + this.pad + this.iconSize + this.iconGap;
|
||||
}
|
||||
|
||||
// Stepper button x's (row-local, right end of the row).
|
||||
_btnXs() {
|
||||
const right = this.vpW / 2 - this.pad;
|
||||
return {
|
||||
plus: right - 11,
|
||||
qty: right - 44,
|
||||
minus: right - 77,
|
||||
price: right - (this.stepperW) - 10, // price's RIGHT edge (origin 1,0.5)
|
||||
};
|
||||
}
|
||||
|
||||
// Max description width (text block → price), in px. The price reserves
|
||||
// its full width ("1,240 cr") so the description never collides with it.
|
||||
_descMaxW() {
|
||||
return this._btnXs().price - this.priceW - 14 - this._textX();
|
||||
}
|
||||
|
||||
// Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
|
||||
// (deterministic char estimate — no text metrics needed; the headless
|
||||
// stubs have none). Real Phaser word-wraps at _descMaxW() per line;
|
||||
// this cap is the fail-safe that keeps the total inside maxLines lines.
|
||||
_fitText(str, fontSize, maxLines = 1) {
|
||||
const s = String(str ?? '');
|
||||
if (!s) return '';
|
||||
const perLine = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55)));
|
||||
const maxChars = perLine * Math.max(1, Math.floor(Number(maxLines) || 1));
|
||||
if (s.length <= maxChars) return s;
|
||||
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
_flavorOf(id) {
|
||||
const list = config.get('goods.goods', []);
|
||||
return list.find((g) => g.id === id)?.flavor ?? '';
|
||||
}
|
||||
|
||||
_makeText(x, y, str, style) {
|
||||
const t = this.scene.add?.text?.(x, y, str, {
|
||||
fontFamily: fontStack('body'), // Centauri — the theme's body face
|
||||
fontSize: style.fontSize, color: style.color, align: style.align,
|
||||
});
|
||||
return t ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scroll
|
||||
/** Scroll by `dy` px (clamped to [0, maxScroll]); re-clips the rows. */
|
||||
scrollBy(dy) {
|
||||
this.scrollTo(this._scroll + Math.floor(Number(dy) || 0));
|
||||
return this;
|
||||
}
|
||||
|
||||
scrollTo(v) {
|
||||
const n = Math.max(0, Math.min(this.maxScroll, Number(v) || 0));
|
||||
if (n === this._scroll) return this;
|
||||
this._scroll = n;
|
||||
this._clipRows();
|
||||
return this;
|
||||
}
|
||||
|
||||
// Row-based clip: lay rows at (y0 − scroll), hide the ones off-viewport
|
||||
// (this Phaser build has no camera/mask clip). A row counts as visible
|
||||
// while it OVERLAPS the viewport, so partial rows fade in as you scroll.
|
||||
_layoutRows() {
|
||||
const n = this.rows.length;
|
||||
const contentH = n * this.rowH + Math.max(0, n - 1) * this.rowGap;
|
||||
this.maxScroll = Math.max(0, contentH - this.vpH);
|
||||
this._scroll = Math.max(0, Math.min(this._scroll, this.maxScroll));
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.rows[i].y0 = this.vpY + i * (this.rowH + this.rowGap) + this.rowH / 2;
|
||||
}
|
||||
}
|
||||
|
||||
_clipRows() {
|
||||
const cx = this.vpX + this.vpW / 2;
|
||||
for (const row of this.rows) {
|
||||
const y = row.y0 - this._scroll;
|
||||
const top = y - this.rowH / 2;
|
||||
const bottom = y + this.rowH / 2;
|
||||
row.shown = top < this.vpY + this.vpH && bottom > this.vpY;
|
||||
const a = row.shown ? 1 : 0;
|
||||
const o = row.objs;
|
||||
const tx = cx + this._textX(); // row-local → list-local (row center at cx)
|
||||
const bx = this._btnXs();
|
||||
o.bg?.setPosition?.(cx, y);
|
||||
o.icon?.setPosition?.(cx - this.vpW / 2 + this.pad + this.iconSize / 2, y);
|
||||
o.name?.setPosition?.(tx, y - this.rowH * 0.18);
|
||||
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
|
||||
o.price?.setPosition?.(cx + bx.price, y);
|
||||
o.minus?.setPosition?.(cx + bx.minus, y);
|
||||
o.qty?.setPosition?.(cx + bx.qty, y);
|
||||
o.plus?.setPosition?.(cx + bx.plus, y);
|
||||
for (const el of Object.values(o)) el?.setAlpha?.(a);
|
||||
}
|
||||
this._renderRows();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- DATA (the core)
|
||||
|
||||
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
|
||||
bumpBuy(id, d = 1) {
|
||||
if (!id) return this;
|
||||
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
|
||||
const cur = this.buyQty.get(id) ?? 0;
|
||||
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
|
||||
if (next === 0) this.buyQty.delete(id);
|
||||
else this.buyQty.set(id, next);
|
||||
this._renderRow(id);
|
||||
this._emitSelChange();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** { goodId: qty } from the steppers. */
|
||||
getBuyMap() {
|
||||
const m = {};
|
||||
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
|
||||
return m;
|
||||
}
|
||||
|
||||
hasSelection() {
|
||||
return this.buyQty.size > 0;
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.buyQty = new Map();
|
||||
this._renderRows();
|
||||
this._emitSelChange();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The ShopView hooks this to live-update the preview + Confirm/Clear. */
|
||||
set onSelectionChange(fn) { this._onSel = fn; }
|
||||
_emitSelChange() {
|
||||
if (typeof this._onSel === 'function') this._onSel(this);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- render
|
||||
_renderRows() {
|
||||
for (const row of this.rows) this._renderRow(row.id);
|
||||
}
|
||||
|
||||
_renderRow(id) {
|
||||
const row = this.rows.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const q = this.buyQty.get(id) ?? 0;
|
||||
const o = row.objs;
|
||||
if (o.qty && typeof o.qty.setText === 'function') o.qty.setText(String(q));
|
||||
|
||||
// in-basket highlight: brighter plate + accent border (the theme's
|
||||
// selection cyan), the qty text lit.
|
||||
const sel = q > 0;
|
||||
const bl = this.bl;
|
||||
const cx = this.vpX + this.vpW / 2;
|
||||
const y = row.y0 - this._scroll;
|
||||
if (o.bg && typeof o.bg.clear === 'function' && typeof o.bg.fillStyle === 'function') {
|
||||
o.bg.clear?.();
|
||||
o.bg.fillStyle(_hex(sel ? (bl.rowSelBg ?? '#12283a') : (bl.rowBg ?? '#0e1728')), sel ? (bl.rowSelBgAlpha ?? 0.85) : (bl.rowBgAlpha ?? 0.55));
|
||||
o.bg.lineStyle?.(sel ? 2 : 1, _hex(sel ? (bl.selected ?? '#00e5ff') : (bl.rowBorder ?? '#1d2e4c')), 255);
|
||||
o.bg.fillRect?.(cx - this.vpW / 2, y - this.rowH / 2, this.vpW, this.rowH);
|
||||
}
|
||||
if (o.qty && typeof o.qty.setColor === 'function') {
|
||||
o.qty.setColor(toCss(sel ? (this.st.activeColor ?? '#00e5ff') : (this.st.color ?? '#eaf6ff')));
|
||||
}
|
||||
}
|
||||
|
||||
_destroyRows() {
|
||||
for (const row of this.rows) for (const el of Object.values(row.objs)) el?.destroy?.();
|
||||
this.rows = [];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- animation
|
||||
/** Slide the panel in from the right (the TradeGrid recipe). */
|
||||
slideIn() {
|
||||
const ms = config.get('shopui.animation.slideMs', 340);
|
||||
const ease = config.get('shopui.animation.ease', 'Cubic.Out');
|
||||
const off = this.width + 24;
|
||||
const targetX = this.x; // the final x (set by ShopView.setPosition before this)
|
||||
const targetY = this.y;
|
||||
if (this.scene.tweens && typeof this.scene.tweens.add === 'function') {
|
||||
this.x = targetX + off;
|
||||
this.y = targetY;
|
||||
this.scene.tweens.add({ targets: this, x: targetX, y: targetY, duration: ms, ease, delay: 40 });
|
||||
} else {
|
||||
this.x = targetX;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const o of this._objs) o?.destroy?.();
|
||||
this._objs = [];
|
||||
this.rows = [];
|
||||
if (typeof super.destroy === 'function') super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed.
|
||||
function _hex(v) {
|
||||
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
|
||||
return m ? parseInt(m[1], 16) : 0xffffff;
|
||||
}
|
||||
|
|
@ -1,24 +1,31 @@
|
|||
/**
|
||||
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────┐
|
||||
* │ [ Buy/Sell | Buyback ] ← tabs │
|
||||
* │ │
|
||||
* │ ┌SELL 4×4┐ ┌ NET ┐ ┌BUY 4×4┐ │
|
||||
* │ │ bays │ │ +420 │ │ stock │ │
|
||||
* │ │ (cargo)│ │ cr │ │ (12–16)│ │
|
||||
* │ └────────┘ │[Conf]│ └────────┘ │
|
||||
* │ │[Clear]│ │
|
||||
* └─────────────────────────────────────────────────────────────┘
|
||||
* ┌────────────────────────────────────────────────────────────────┐
|
||||
* │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │
|
||||
* │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │
|
||||
* │ │ (cargo)│ │ Crisp, clean… − 0 +│ │
|
||||
* │ └────────┘ │ [i] Refined Fuel 41cr│ │
|
||||
* │ │ The lifeblood − 1 +│ │
|
||||
* │ │ … (scrolls) … │ │
|
||||
* │ └────────────────────────┘ │
|
||||
* └────────────────────────────────────────────────────────────────┘
|
||||
* ┌────────────────────────────────────────────────────────────────┐
|
||||
* │ BUY/SELL BUYBACK │ NET +420 cr ← tab sub-bar │
|
||||
* └────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* The two sides are TradeGrids (js/ui/shop/TradeGrid.js):
|
||||
* · SELL (left) — the player's cargo bays. Tap a filled bay to check it
|
||||
* off (a bay = 1 unit). Locked bays are the hold's upgrade room.
|
||||
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper.
|
||||
* The live NET preview (green ≥ 0, red < 0) and Confirm/Clear live 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).
|
||||
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
|
||||
* player's cargo bays — tap a filled bay to check it off (a bay = 1 unit);
|
||||
* locked bays are the hold's upgrade room.
|
||||
* The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's
|
||||
* 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).
|
||||
*
|
||||
* BUYBACK (session-local only) — the second tab. After the player SELLs
|
||||
* something, the goods they sold are listed here and can be bought BACK —
|
||||
|
|
@ -39,6 +46,7 @@ import { MenuButton } from '../MenuButton.js';
|
|||
import { toColor, toCss } from '../../utils/Color.js';
|
||||
import { fontStack } from '../../utils/Theme.js';
|
||||
import { TradeGrid } from './TradeGrid.js';
|
||||
import { BuyList } from './BuyList.js';
|
||||
|
||||
export class ShopView extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
|
|
@ -81,9 +89,10 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
|
||||
this.buybackSel = new Map(); // goodId -> qty to buy back
|
||||
this.sellGrid = null;
|
||||
this.buyGrid = null;
|
||||
this.buyList = null;
|
||||
this._content = null; // the active content container
|
||||
this._previewText = null;
|
||||
this._netLabel = null; // the sub-bar NET label (persistent)
|
||||
this._netValue = null; // the sub-bar NET value (live)
|
||||
this._confirmBtn = null;
|
||||
this._clearBtn = null;
|
||||
this._tabBuyback = null;
|
||||
|
|
@ -132,6 +141,13 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
// 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.
|
||||
//
|
||||
// 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").
|
||||
const a = this.anchor;
|
||||
const H = config.get('save.subBar.height', 58);
|
||||
const pad = config.get('save.subBar.padding', 16);
|
||||
|
|
@ -139,6 +155,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
const tabs = config.get('shopui.tabs', {});
|
||||
const c = config.section('save.colors', {});
|
||||
const btnCfg = config.section('save.subBar.button', {});
|
||||
const pv = config.section('shopui.preview', {});
|
||||
|
||||
const items = [
|
||||
{ id: 'trade', label: tabs.buySell ?? 'Buy/Sell', accent: tabs.activeAccent ?? c.neon ?? '#00e5ff' },
|
||||
|
|
@ -147,8 +164,8 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
|
||||
// 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),
|
||||
const mkBtn = (label, accent, onClick) => new MenuButton(
|
||||
this.scene, 0, -H / 2, String(label), onClick,
|
||||
{
|
||||
screenFixed: true,
|
||||
fontSize: btnCfg.fontSize ?? 13,
|
||||
|
|
@ -159,15 +176,52 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
textColor: c.ink ?? '#eaf6ff',
|
||||
bgColor: c.cardBg ?? '#0a1222',
|
||||
hoverColor: c.cardBg ?? '#0a1222',
|
||||
borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'),
|
||||
borderColor: toCss(accent ?? c.neon ?? '#00e5ff'),
|
||||
},
|
||||
);
|
||||
|
||||
const btns = items.map(mkBtn);
|
||||
const btns = items.map((it) => mkBtn(String(it.label ?? it.id), it.accent, () => this._onTabTap(it.id)));
|
||||
const widths = btns.map((b) => b.style?.width ?? 120);
|
||||
const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1);
|
||||
|
||||
// The trade actions — Clear (secondary, dim) + Confirm (primary, green)
|
||||
// — live on the bar too (both trade AND buyback confirm here, since
|
||||
// preview()/confirm() already branch on the tab).
|
||||
const buttons = config.section('shopui.buttons', {});
|
||||
this._clearBtn = mkBtn(buttons.clearLabel ?? 'Clear', buttons.clearAccent ?? c.dim ?? '#7d92c4', () => this.clear());
|
||||
this._confirmBtn = mkBtn(buttons.confirmLabel ?? 'Confirm', buttons.confirmAccent ?? '#7ce8a4', () => this.confirm());
|
||||
this._confirmBtn.setAlpha?.(0); // hidden until a selection exists (_syncPreview)
|
||||
this._clearBtn.setAlpha?.(0);
|
||||
this._confirmBtn.setDisabled?.(true); // + invalid-trade gray, via _syncPreview
|
||||
const clearW = this._clearBtn.style?.width ?? 90;
|
||||
const confirmW = this._confirmBtn.style?.width ?? 110;
|
||||
const actionsW = clearW + gap + confirmW;
|
||||
|
||||
// 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.
|
||||
const labelStr = String(pv.label ?? 'NET');
|
||||
const valueFont = Math.max(8, Number(pv.valueFontSize) || 22);
|
||||
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', {
|
||||
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 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 + widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1),
|
||||
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
|
||||
|
|
@ -187,15 +241,18 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
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).
|
||||
// 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);
|
||||
this._drawSubBarBody(bodyG, W, H, c, [divX1, divX2], divH);
|
||||
}
|
||||
|
||||
// The buttons LAST (above the body), centered in the row.
|
||||
// The buttons LAST (above the body), left-anchored in the row.
|
||||
let bx = -W / 2 + pad + widths[0] / 2;
|
||||
items.forEach((it, i) => {
|
||||
const btn = btns[i];
|
||||
|
|
@ -206,12 +263,32 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
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);
|
||||
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);
|
||||
bar.add?.(this._netLabel);
|
||||
bar.add?.(this._netValue);
|
||||
|
||||
// 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) {
|
||||
/**
|
||||
* The sub-bar panel body (the MenuSubBar recipe at full height).
|
||||
* `dividers` (optional) — hairline divider x's (one between the tabs and
|
||||
* the actions, one between the actions and the NET readout) — drawn as
|
||||
* body so they paint under the buttons + text.
|
||||
*/
|
||||
_drawSubBarBody(g, W, H, c, dividers, divH) {
|
||||
if (!g || typeof g.clear !== 'function') return;
|
||||
const panelTop = toColor(c.panelTop ?? '#0e1930');
|
||||
const panelBottom = toColor(c.panelBottom ?? '#050912');
|
||||
|
|
@ -238,6 +315,11 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
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
|
||||
const divs = Array.isArray(dividers) ? dividers : (typeof dividers === 'number' ? [dividers] : []);
|
||||
if (divs.length > 0 && divH > 0) {
|
||||
g.fillStyle(toColor(c.border ?? '#22405f'), 0.9);
|
||||
for (const dx of divs) g.fillRect(dx - 1, -H / 2 - divH / 2, 2, divH); // section dividers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,108 +336,75 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons)
|
||||
// CONTENT — the trade panel (SELL grid + BUY list, side by side)
|
||||
// ---------------------------------------------------------------------
|
||||
_showTrade() {
|
||||
const grid = config.get('shopui.grid', {});
|
||||
const centerW = grid.centerW ?? 208;
|
||||
// The two panels sit NEXT TO each other with just a bit of padding
|
||||
// between (the trade actions + NET readout ride on the tab sub-bar —
|
||||
// see _buildTabs — so the panel is pure selection).
|
||||
const centerW = grid.centerW ?? 16;
|
||||
const W = this._W();
|
||||
// The panel's LEFT edge = the surface HUD's planet text's LEFT edge —
|
||||
// the same margin key (data/landing.json → landing.hud.marginX) the
|
||||
// name/line are drawn at (origin 0,0), so the cargo grid lines up
|
||||
// vertically with the planet name above it. The BUY list then STRETCHES
|
||||
// to the right, ending at the SAME padding from the screen's right edge.
|
||||
const leftPad = Math.max(8, Number(config.get('landing.hud.marginX')) || 24);
|
||||
|
||||
const sellGrid = new TradeGrid(this.scene, {
|
||||
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
|
||||
cls: this.cls, systemId: this.systemId,
|
||||
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
|
||||
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
|
||||
});
|
||||
const buyGrid = new TradeGrid(this.scene, {
|
||||
side: 'buy', maxSlots: this.maxSlots,
|
||||
cls: this.cls, systemId: this.systemId,
|
||||
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
|
||||
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
|
||||
});
|
||||
// The BUY side: a scrollable LIST of stock rows (wide bars — icon +
|
||||
// name + description + price + −/qty/+ stepper). Sized to the SELL
|
||||
// grid's height so the two panels align top AND bottom; the list scrolls
|
||||
// when the stock outgrows the viewport (it always does — 8–16 rows).
|
||||
// Width: from just past the SELL grid out to the right padding — the
|
||||
// wide row gets all the room (the 440 floor is the narrow-window
|
||||
// fallback, where stretching to the right edge isn't possible).
|
||||
const buyW = Math.max(440, W - leftPad - (leftPad + sellGrid.width + centerW));
|
||||
const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height });
|
||||
this.sellGrid = sellGrid;
|
||||
this.buyGrid = buyGrid;
|
||||
this.buyList = buyList;
|
||||
|
||||
// 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).
|
||||
// Layout: [SELL grid] [gap] [BUY list]. 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); its LEFT edge is the planet text's
|
||||
// left edge (leftPad), and the BUY list ends at the right padding.
|
||||
//
|
||||
// IMPORTANT: a TradeGrid lays its plate/cells out in local (0,0)→(w,h)
|
||||
// IMPORTANT: a TradeGrid/BuyList lays its plate 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 gridH = sellGrid.height;
|
||||
// (verified with dev/cbox-test.html) — so the panel's position IS its
|
||||
// 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 leftX = leftPad;
|
||||
sellGrid.setPosition(leftX, this.topY);
|
||||
const rightX = leftX + sellGrid.width + centerW;
|
||||
buyGrid.setPosition(rightX, this.topY);
|
||||
buyList.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 — 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 = 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);
|
||||
|
||||
// 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 = 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);
|
||||
content.add?.(buyList);
|
||||
|
||||
// Confirm / Clear live on the tab sub-bar (persistent — see
|
||||
// _buildTabs); the panel is pure selection.
|
||||
this._content = content;
|
||||
this.add?.(content);
|
||||
|
||||
// Data + live preview wiring.
|
||||
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
||||
buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
|
||||
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
|
||||
sellGrid.onSelectionChange = () => this._syncPreview();
|
||||
buyGrid.onSelectionChange = () => this._syncPreview();
|
||||
buyList.onSelectionChange = () => this._syncPreview();
|
||||
this._syncPreview();
|
||||
|
||||
// Reveal: SELL slides in from the left, BUY from the right.
|
||||
sellGrid.slideIn();
|
||||
buyGrid.slideIn();
|
||||
buyList.slideIn();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -389,21 +438,9 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
y += rowH;
|
||||
}
|
||||
|
||||
const cy = y + 24;
|
||||
this._previewLabel = this._makeText(cx, cy - 8, config.get('shopui.preview.label', 'NET'), {
|
||||
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center' });
|
||||
content.add?.(this._previewLabel);
|
||||
this._previewText = this._makeText(cx, cy + 22, 'NET 0 cr', {
|
||||
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center' });
|
||||
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 + 92);
|
||||
this._confirmBtn.tap(() => this.confirm());
|
||||
this._confirmBtn.setAlpha(0);
|
||||
content.add?.(this._confirmBtn.container);
|
||||
|
||||
// Confirm / Clear / the live NET readout all ride on the tab sub-bar
|
||||
// (persistent — see _buildTabs); the buyback panel is the pure list of
|
||||
// sold goods.
|
||||
this._content = content;
|
||||
this.add?.(content);
|
||||
this._syncPreview();
|
||||
|
|
@ -445,7 +482,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
|
||||
cargo, capacity, credits,
|
||||
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
|
||||
buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {},
|
||||
buyMap: this.buyList ? this.buyList.getBuyMap() : {},
|
||||
});
|
||||
}
|
||||
// buyback — priced at the sale price (buyOverrides), a neutral undo.
|
||||
|
|
@ -475,7 +512,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
clear() {
|
||||
if (this.tab === 'trade') {
|
||||
this.sellGrid?.clearSelection();
|
||||
this.buyGrid?.clearSelection();
|
||||
this.buyList?.clearSelection();
|
||||
} else {
|
||||
this.buybackSel = new Map();
|
||||
}
|
||||
|
|
@ -495,7 +532,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
if (this.tab === 'trade') {
|
||||
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
|
||||
this.sellGrid?.clearSelection();
|
||||
this.buyGrid?.clearSelection();
|
||||
this.buyList?.clearSelection();
|
||||
} else {
|
||||
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
|
||||
this.buybackSel = new Map();
|
||||
|
|
@ -531,13 +568,16 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
_syncPreview() {
|
||||
const pv = this.preview();
|
||||
const anySel = this._hasAnySelection();
|
||||
if (this._previewText && typeof this._previewText.setText === 'function') {
|
||||
const sign = pv.net >= 0 ? '+' : '';
|
||||
this._previewText.setText(`NET ${sign}${Math.round(pv.net)} cr`);
|
||||
const col = pv.net === 0
|
||||
// The live NET readout lives on the tab sub-bar (this._netValue) — one
|
||||
// glance sees the tabs AND the pending trade's credit delta. Right-
|
||||
// aligned, so a growing number never shifts the "NET" label.
|
||||
if (this._netValue && typeof this._netValue.setText === 'function') {
|
||||
const n = Math.round(pv.net);
|
||||
this._netValue.setText(n === 0 ? '0 cr' : `${n > 0 ? '+' : ''}${n} cr`);
|
||||
const col = n === 0
|
||||
? config.get('shopui.preview.zeroColor', '#7d92c4')
|
||||
: (pv.net > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
|
||||
if (typeof this._previewText.setColor === 'function') this._previewText.setColor(col);
|
||||
: (n > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
|
||||
if (typeof this._netValue.setColor === 'function') this._netValue.setColor(col);
|
||||
}
|
||||
const confirmOn = anySel;
|
||||
const confirmEnabled = anySel && pv.valid;
|
||||
|
|
@ -551,7 +591,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
_hasAnySelection() {
|
||||
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false);
|
||||
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false);
|
||||
return [...this.buybackSel.values()].some((q) => q > 0);
|
||||
}
|
||||
|
||||
|
|
@ -576,24 +616,34 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
_destroyContent() {
|
||||
// The panel content goes; the sub-bar (tabs + actions + NET readout)
|
||||
// PERSISTS across tab switches — it's not part of the content.
|
||||
if (this._content) {
|
||||
this._content.destroy?.();
|
||||
this._content = null;
|
||||
}
|
||||
this.sellGrid?.destroy?.();
|
||||
this.buyGrid?.destroy?.();
|
||||
this.buyList?.destroy?.();
|
||||
this.sellGrid = null;
|
||||
this.buyGrid = null;
|
||||
this._previewText = null;
|
||||
this._previewLabel = null;
|
||||
this._confirmBtn = null;
|
||||
this._clearBtn = null;
|
||||
this.buyList = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyContent();
|
||||
this._tabTrade?.destroy?.();
|
||||
this._tabBuyback?.destroy?.();
|
||||
this._confirmBtn?.destroy?.();
|
||||
this._clearBtn?.destroy?.();
|
||||
this._netLabel?.destroy?.();
|
||||
this._netValue?.destroy?.();
|
||||
this._tabTrade = null;
|
||||
this._tabBuyback = null;
|
||||
this._confirmBtn = null;
|
||||
this._clearBtn = null;
|
||||
this._netLabel = null;
|
||||
this._netValue = null;
|
||||
this.subBar?.destroy?.();
|
||||
this.subBar = null;
|
||||
if (typeof super.destroy === 'function') super.destroy();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,36 @@
|
|||
/**
|
||||
* TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells.
|
||||
* TradeGrid — the shop's SELL side: the player's cargo bays as a 4×4 grid.
|
||||
*
|
||||
* Two modes, one component:
|
||||
* · SELL (the left panel) — the grid is the player's CARGO. Each cell is a
|
||||
* BAY (one good each, data/economy.json → cargo). The first `capacity`
|
||||
* cells are live bays (filled with a good, or empty); the rest are LOCKED
|
||||
* (the hold's upgrade room — the ship can't carry there yet). Tapping a
|
||||
* filled bay checks it off to sell (a bay = 1 unit); the sell quantity for
|
||||
* a good is just the number of its bays that are checked.
|
||||
* · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct
|
||||
* goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy
|
||||
* quantity is whatever the stepper says.
|
||||
* ┌────────────┐
|
||||
* │ SELL │
|
||||
* │ ┌────┐┌────┐ │
|
||||
* │ │icon││icon│ │ filled bay → good icon
|
||||
* │ └────┘└────┘ │ empty bay → dim plate
|
||||
* │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room)
|
||||
* │ │ ││lock│ │ checked → cyan selection ring (1 unit queued)
|
||||
* │ └────┘└────┘ │
|
||||
* └────────────┘
|
||||
*
|
||||
* Cells show the good's ICON only — the name + price are revealed in a
|
||||
* shared hover tooltip (pointerover the cell; one tooltip per grid).
|
||||
* A BAY is the unit (not a good): each filled bay holds 1 unit of its good
|
||||
* and checking it off queues 1 unit for sale. The first `capacity` bays are
|
||||
* live (filled with a good, or empty); the rest are LOCKED — the hold's
|
||||
* upgrade room (the ship can't carry there yet). Hovering a cell reveals
|
||||
* the good's name + sell price in a shared tooltip (icon-only cells — no
|
||||
* text over the icon).
|
||||
*
|
||||
* The PRICING lives in js/economy/Economy.js (buyPrice/sellPrice — the fixed
|
||||
* per-(good, shop) price). This component only shows the price and tracks the
|
||||
* SELECTION (which bays to sell / how many of each to buy). The ShopView
|
||||
* (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade
|
||||
* (The BUY side is no longer a grid — the shop's stock is now a scrollable
|
||||
* list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side
|
||||
* by side.)
|
||||
*
|
||||
* The PRICING lives in js/economy/Economy.js (sellPrice — the fixed
|
||||
* per-(good, shop) price). This grid only shows the price and tracks the
|
||||
* SELECTION (which bays are checked off). The ShopView
|
||||
* (js/ui/shop/ShopView.js) composes it with the BuyList, runs the trade
|
||||
* preview (Economy.tradePreview) and applies it to the ship.
|
||||
*
|
||||
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
|
||||
* selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data
|
||||
* so dev/shop-ui.test.mjs can drive it with no scene.
|
||||
* selection LOGIC (toggleBay / getSellMap) is plain data so
|
||||
* dev/shop-ui.test.mjs can drive it with no scene.
|
||||
*/
|
||||
import Phaser from '../../vendor/phaser.js';
|
||||
import { config } from '../../config/Config.js';
|
||||
|
|
@ -33,21 +40,19 @@ import { fontStack } from '../../utils/Theme.js';
|
|||
export class TradeGrid extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* @param {object} scene — the SurfaceScene (or a headless fake).
|
||||
* @param {object} o — { side: 'sell'|'buy', maxSlots, capacity,
|
||||
* cls, systemId, galaxy, sellUnit(id), buyUnit(id) }
|
||||
* sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so
|
||||
* the grid can show "sell for N" / "buy for N" without re-deriving.
|
||||
* @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) }
|
||||
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
|
||||
* can show "sell for N" without re-deriving.
|
||||
*/
|
||||
constructor(scene, o) {
|
||||
super(scene, 0, 0);
|
||||
this.scene = scene;
|
||||
this.side = o.side;
|
||||
this.side = 'sell'; // SELL-only now (the BUY side moved to BuyList.js)
|
||||
this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16));
|
||||
this.capacity = Math.max(0, Math.floor(o.capacity ?? 0));
|
||||
this.cls = o.cls;
|
||||
this.systemId = o.systemId;
|
||||
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
|
||||
this.buyUnit = (id) => o.buyUnit?.(id) ?? 0;
|
||||
|
||||
const g = config.get('shopui.grid', {});
|
||||
this.cols = g.cols ?? 4;
|
||||
|
|
@ -66,19 +71,16 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
this.height = this.titleH + this.cellH + pad * 2;
|
||||
|
||||
// --- state -----------------------------------------------------------
|
||||
// SELL: bays in fill order (goodId | null), length = maxSlots; usable =
|
||||
// index < capacity. BUY: stock entries { id, name, price }, length =
|
||||
// stock size (≤ maxSlots).
|
||||
// bays in fill order (goodId | null), length = maxSlots; usable =
|
||||
// index < capacity (locked bays are the hold's upgrade room).
|
||||
this.bays = [];
|
||||
this.stock = [];
|
||||
this.sellSel = new Set(); // sell: checked bay indices
|
||||
this.buyQty = new Map(); // buy: goodId -> qty
|
||||
this.sellSel = new Set(); // checked bay indices (the pending sell)
|
||||
|
||||
// --- objects ---------------------------------------------------------
|
||||
this._objs = []; // everything we create (destroy())
|
||||
this.plate = null; // the semi-transparent panel background
|
||||
this.title = null;
|
||||
this.cells = []; // per cell: { plate, sel, icon, label, stepQty, stepMinus, stepPlus }
|
||||
this.cells = []; // per cell: { plate, sel, icon, label }
|
||||
this.tip = null; // shared hover tooltip (name + price) — the
|
||||
this.tipPlate = null; // per-cell name/price text is gone; hovering
|
||||
this.tipName = null; // a cell reveals it beside the icon
|
||||
|
|
@ -122,7 +124,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
const gr = this.plate;
|
||||
if (!gr || typeof gr.clear !== 'function') return;
|
||||
const p = this.plateStyle ?? {};
|
||||
const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7);
|
||||
const a = p.bgAlpha ?? 0.7;
|
||||
gr.clear?.();
|
||||
if (typeof gr.fillStyle === 'function') {
|
||||
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a);
|
||||
|
|
@ -133,8 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
|
||||
_buildTitle() {
|
||||
const t = this.scene.add?.text?.(
|
||||
this.width / 2, 8,
|
||||
this.side === 'sell' ? 'SELL' : 'BUY',
|
||||
this.width / 2, 8, 'SELL',
|
||||
{ fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center', fontFamily: fontStack('header') }, // Ethnocentric — the theme's section face
|
||||
);
|
||||
if (t) {
|
||||
|
|
@ -178,37 +179,11 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
});
|
||||
if (label) { label.setOrigin?.(0.5, 0.5); label.setAlpha?.(0); cell.label = label; cell.objects.push(label); }
|
||||
|
||||
// BUY stepper: − n + (bottom of the cell)
|
||||
if (this.side === 'buy') {
|
||||
const mk = (dx, str) => {
|
||||
const el = this.scene.add?.text?.(x + dx, y + 26, str, {
|
||||
fontSize: `${config.get('shopui.stepper.fontSize', 19)}px`,
|
||||
color: this.sl.price?.buy ?? '#ffc94d', align: 'center',
|
||||
fontFamily: fontStack('body'),
|
||||
});
|
||||
if (el) { el.setOrigin?.(0.5, 0.5); return el; }
|
||||
return null;
|
||||
};
|
||||
const minus = mk(-26, '−');
|
||||
const qty = mk(0, '0');
|
||||
const plus = mk(26, '+');
|
||||
if (minus) cell.objects.push(minus);
|
||||
if (qty) { cell.stepQty = qty; cell.objects.push(qty); }
|
||||
if (plus) cell.objects.push(plus);
|
||||
cell.stepMinus = minus; cell.stepPlus = plus;
|
||||
const hit = [-18, -14, 36, 28];
|
||||
this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit);
|
||||
this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
|
||||
this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
|
||||
}
|
||||
|
||||
// interaction: the plate is the whole cell's surface — tap (SELL: toggle
|
||||
// the bay) + hover (either side: reveal the name/price tooltip).
|
||||
// interaction: the plate is the whole cell's surface — tap (toggle the
|
||||
// bay) + hover (reveal the name/price tooltip).
|
||||
const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot];
|
||||
if (this._interactive(cell.plate, hit)) {
|
||||
if (this.side === 'sell') {
|
||||
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
|
||||
}
|
||||
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
|
||||
cell.plate.on('pointerover', () => this.hoverCell(i));
|
||||
cell.plate.on('pointerout', () => this.unhover());
|
||||
}
|
||||
|
|
@ -218,15 +193,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
if (cell.sel) this.add?.(cell.sel);
|
||||
if (cell.icon) this.add?.(cell.icon);
|
||||
if (cell.label) this.add?.(cell.label);
|
||||
if (cell.stepMinus) this.add?.(cell.stepMinus);
|
||||
if (cell.stepQty) this.add?.(cell.stepQty);
|
||||
if (cell.stepPlus) this.add?.(cell.stepPlus);
|
||||
}
|
||||
}
|
||||
|
||||
// A tap only works when the object supports interactivity (real Phaser); a
|
||||
// headless fake returns no-op objects, and the test drives the logic
|
||||
// methods (toggleBay / bumpBuy) directly.
|
||||
// method (toggleBay) directly.
|
||||
_bindTap(obj, fn, hitRect) {
|
||||
if (!this._interactive(obj, hitRect)) return;
|
||||
obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
|
||||
|
|
@ -252,36 +224,26 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
// --- DATA (the testable core) -----------------------------------------
|
||||
|
||||
/**
|
||||
* Set the grid's contents. SELL: `cargo` is the filled-bay id list (order
|
||||
* preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list
|
||||
* ({ id, name, price } | { id }). Resets the selection (a fresh shop view).
|
||||
* Set the grid's contents: `cargo` is the filled-bay id list (order
|
||||
* preserved — bay i holds cargo[i]). Resets the selection (a fresh shop
|
||||
* view).
|
||||
*/
|
||||
refresh(o = {}) {
|
||||
this.unhover();
|
||||
if (this.side === 'sell') {
|
||||
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
|
||||
this.bays = [];
|
||||
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
|
||||
this.sellSel = new Set();
|
||||
} else {
|
||||
const stock = Array.isArray(o.stock) ? o.stock : [];
|
||||
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
|
||||
this.buyQty = new Map();
|
||||
}
|
||||
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
|
||||
this.bays = [];
|
||||
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
|
||||
this.sellSel = new Set();
|
||||
this._renderAll();
|
||||
return this;
|
||||
}
|
||||
|
||||
_stockIdAt(i) {
|
||||
return this.stock[i]?.id ?? null;
|
||||
}
|
||||
_bayIdAt(i) {
|
||||
return this.bays[i] ?? null;
|
||||
}
|
||||
|
||||
/** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
|
||||
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
|
||||
toggleBay(i) {
|
||||
if (this.side !== 'sell') return this;
|
||||
if (i < 0 || i >= this.capacity) return this; // locked bay
|
||||
const id = this._bayIdAt(i);
|
||||
if (!id) return this; // empty bay
|
||||
|
|
@ -292,9 +254,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */
|
||||
/** Check / uncheck by good id (the Nth unchecked/checked bay of it). */
|
||||
toggleGood(id) {
|
||||
if (this.side !== 'sell') return this;
|
||||
const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity);
|
||||
if (idx >= 0) this.toggleBay(idx);
|
||||
else {
|
||||
|
|
@ -305,20 +266,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
|
||||
bumpBuy(id, d = 1) {
|
||||
if (this.side !== 'buy' || !id) return this;
|
||||
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
|
||||
const cur = this.buyQty.get(id) ?? 0;
|
||||
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
|
||||
if (next === 0) this.buyQty.delete(id);
|
||||
else this.buyQty.set(id, next);
|
||||
this._renderStockRow(id);
|
||||
this._emitSelChange();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** SELL: { goodId: qty } from the checked bays. */
|
||||
/** { goodId: qty } from the checked bays. */
|
||||
getSellMap() {
|
||||
const m = {};
|
||||
for (const i of this.sellSel) {
|
||||
|
|
@ -328,20 +276,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
return m;
|
||||
}
|
||||
|
||||
/** BUY: { goodId: qty } from the steppers. */
|
||||
getBuyMap() {
|
||||
const m = {};
|
||||
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
|
||||
return m;
|
||||
}
|
||||
|
||||
hasSelection() {
|
||||
return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0;
|
||||
return this.sellSel.size > 0;
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
if (this.side === 'sell') this.sellSel = new Set();
|
||||
else this.buyQty = new Map();
|
||||
this.sellSel = new Set();
|
||||
this._renderAll();
|
||||
this._emitSelChange();
|
||||
return this;
|
||||
|
|
@ -368,19 +308,14 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
let goodId = null;
|
||||
let locked = false;
|
||||
|
||||
if (this.side === 'sell') {
|
||||
goodId = this._bayIdAt(i);
|
||||
locked = i >= this.capacity;
|
||||
} else {
|
||||
goodId = this._stockIdAt(i);
|
||||
locked = false;
|
||||
}
|
||||
goodId = this._bayIdAt(i);
|
||||
locked = i >= this.capacity;
|
||||
|
||||
const usable = !locked && !!goodId;
|
||||
|
||||
// plate border (selected sell bays get the cyan ring)
|
||||
if (cell.plate && typeof cell.plate.clear === 'function') {
|
||||
const selected = this.side === 'sell' && this.sellSel.has(i);
|
||||
const selected = this.sellSel.has(i);
|
||||
cell.plate.clear?.();
|
||||
if (typeof cell.plate.fillStyle === 'function') {
|
||||
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3));
|
||||
|
|
@ -389,46 +324,28 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
|
||||
}
|
||||
|
||||
// icon — the cell's only content for a usable bay/stock slot (the
|
||||
// name+price live in the hover tooltip). Centered; the BUY side lifts it
|
||||
// a touch so the stepper under it reads as a row of its own.
|
||||
// icon — the cell's only content for a usable bay (the name+price live
|
||||
// in the hover tooltip). Centered.
|
||||
if (cell.icon) {
|
||||
const show = usable;
|
||||
if (show) {
|
||||
cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId));
|
||||
cell.icon.setAlpha?.(1);
|
||||
cell.icon.setScale?.(this.sl.iconScale ?? 0.62);
|
||||
cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y);
|
||||
cell.icon.setPosition?.(x, y);
|
||||
} else {
|
||||
cell.icon.setAlpha?.(0);
|
||||
}
|
||||
}
|
||||
|
||||
// state label — locked bays / empty slots only; usable cells stay clean
|
||||
// state label — locked bays / empty bays only; usable cells stay clean
|
||||
if (cell.label && typeof cell.label.setText === 'function') {
|
||||
cell.label.setText(usable
|
||||
? ''
|
||||
: (this.side === 'sell'
|
||||
? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))
|
||||
: (sl.emptyLabel ?? '—')));
|
||||
cell.label.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')));
|
||||
cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3));
|
||||
}
|
||||
|
||||
// buy stepper
|
||||
if (this.side === 'buy' && cell.stepQty) {
|
||||
const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0;
|
||||
if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q));
|
||||
const on = usable;
|
||||
for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
_renderStockRow(id) {
|
||||
for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i);
|
||||
}
|
||||
|
||||
_goodName(id) {
|
||||
if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id;
|
||||
const list = config.get('goods.goods', []);
|
||||
return list.find((g) => g.id === id)?.name ?? id;
|
||||
}
|
||||
|
|
@ -462,7 +379,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
};
|
||||
const plate = this.scene.add?.graphics?.() ?? null;
|
||||
const name = mk(this.tipNameSize, this.sl.name?.color ?? '#eaf6ff');
|
||||
const price = mk(this.tipPriceSize, this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d'));
|
||||
const price = mk(this.tipPriceSize, this.sl.price?.sell ?? '#7ce8a4');
|
||||
if (!plate && !name && !price) return;
|
||||
if (plate) this._objs.push(plate);
|
||||
if (name) this._objs.push(name);
|
||||
|
|
@ -501,17 +418,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
|||
hoverCell(i) {
|
||||
const cell = this.cells[i];
|
||||
if (!cell || !this.tip) return this;
|
||||
let goodId = null;
|
||||
if (this.side === 'sell') {
|
||||
if (i < this.capacity) goodId = this._bayIdAt(i);
|
||||
} else {
|
||||
goodId = this._stockIdAt(i);
|
||||
}
|
||||
const goodId = i < this.capacity ? this._bayIdAt(i) : null;
|
||||
if (!goodId) return this.unhover();
|
||||
|
||||
const unit = Math.round(this.side === 'sell' ? this.sellUnit(goodId) : this.buyUnit(goodId));
|
||||
const unit = Math.round(this.sellUnit(goodId));
|
||||
if (this.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId);
|
||||
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`${this.side === 'sell' ? 'sell' : 'buy'} for ${unit} cr`);
|
||||
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`sell for ${unit} cr`);
|
||||
|
||||
// size from the text metrics (real Phaser or the test stubs)
|
||||
const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18;
|
||||
|
|
|
|||
Loading…
Reference in New Issue