/** * 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; }