orbit/js/ui/shop/BuyList.js

478 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 ?? 64;
this.rowGap = bl.rowGap ?? 6;
this.iconSize = bl.icon ?? 44;
this.iconGap = bl.iconGap ?? 12;
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 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, bl.desc?.fontSize ?? 12);
const descEl = this._makeText(
textX, 0, row.desc,
{ fontSize: `${bl.desc?.fontSize ?? 12}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
);
if (descEl) { 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 at fontSize (deterministic char estimate
// — no text metrics needed; the headless stubs have none).
_fitText(str, fontSize) {
const s = String(str ?? '');
if (!s) return '';
const maxChars = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55)));
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;
}