478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|