/** * 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 3 left 24 cr − 0 + │ │ * │ │ Crisp, clean, alive. A small mir… │ │ * │ │ [icon] Refined Fuel SOLD OUT 41 cr − 1 + │ │ * │ │ The lifeblood of the fleet. │ │ * │ └────────────────────────────────────────────────────────┘ │ * └────────────────────────────────────────────────────────────┘ * * · LEFT — the good's icon (its goods-sheet frame). * · TITLE — line 1: the NAME, then (padding) the INVENTORY ("3 left" / * "SOLD OUT" — the origin's remaining daily batch, model v2), * then (padding) the BUY PRICE ("24 cr") + the −/qty/+ stepper. * Batch-limited rows carry the strip; unlimited rows (hubs' * resale) don't — the name/price just get the padding room. * · DESC — line 2: the one-line DESCRIPTION (the good's `flavor` from * data/goods.json), wrapped to fit the row's full width (the * title line owns the right side, so this line is unshared). * * 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 are CLIPPED to * the viewport: the plate is drawn only for the slice inside the window * (partial rows read as a clean cut at the edge) and the text/icons render * only while they fit WHOLLY inside (this Phaser build, 4.2.1 Giedi, has no * camera/mask clip, so text pixels can't be cropped — a partially-out glyph * would ghost over the title or onto the Confirm bar below). A SCROLLBAR on * the right (track + drag/jump thumb) shows the content overflows and where * the window is. 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; // The inventory strip ("N left" / "SOLD OUT", model v2) — its reserved // width + the TITLE-LINE paddings (name → inventory, inventory → price). this.stockW = Number(bl.stock?.w) || 78; this.stockGap = Number(bl.stock?.gap) || 16; 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 this._tap = null; // { x, y, moved } — the tap candidate (row tap → scout) // --- focus glow (the light stroke around the scouted row) ------------- this._focusId = null; // the row id under focus (null = none) this._focus = null; // the glow graphics (above the rows) this._onRow = null; // row-tap handler (ShopView toggles the scout) // --- scrollbar (right edge of the viewport) --------------------------- this._sb = null; // the track+thumb graphics this._sbThumb = null; // { x, y, w, h } list-local, while scrollable this._sbDrag = null; // { py, scroll } while a press drags the thumb // --- geometry (scroll) ---------------------------------------------- this._scroll = 0; // px scrolled from the top this.maxScroll = 0; // max(0, contentH - vpH), set on refresh this.contentH = 0; // full content height (set in _layoutRows) this._buildPanel(); this._buildTitle(); this._buildSurface(); // BEFORE the rows (rows paint above it) this._buildScrollbar(); // AFTER the surface; refresh() re-adds it // ABOVE the rows (they're created later) this._buildFocus(); // the focus glow; refresh() re-adds it // ABOVE the rows + scrollbar } // ------------------------------------------------------------------ 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 }; this._tap = { x: p?.x ?? 0, y: p?.y ?? 0, moved: false }; // tap candidate }); g.on('pointermove', (p) => { if (this._drag && p && typeof p.y === 'number') { if (this._tap && (Math.abs(p.x - this._tap.x) > 6 || Math.abs(p.y - this._tap.y) > 6)) { this._tap.moved = true; // it's a drag, not a tap } this.scrollBy(p.y - this._drag.y); this._drag = { y: p.y }; } }); g.on('pointerup', () => { this._drag = null; this._tapUp(); // a press released without dragging = a row tap (scout) }); g.on('pointerout', () => { this._drag = null; this._tap = 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); }); } // ------------------------------------------------------------- row taps /** * The tap half of a down→up on the scroll surface with no drag in * between — the TRADE-SCOUT trigger (js/ui/shop/ShopView.js → the scout * panel replaces the cargo grid): resolve the tapped row's box (if the * tap is in one) and fire onRowTap. A tap on dead list space fires * onRowTap(null) — "click anywhere else" closes the scout. Taps over the * scrollbar are scroll actions, not row taps; the steppers stop their * own events so −/+ never reach here. */ _tapUp() { const t = this._tap; this._tap = null; if (!t || t.moved || typeof this._onRow !== 'function') return; // A tap barely moves between down and up — the down point IS the tap // point (and a stub scene's bare events carry no up-point anyway). let lx = t.x - (this.x ?? 0); let ly = t.y - (this.y ?? 0); const el = this._surface; if (el && typeof el.getLocalPoint === 'function') { const out = { x: 0, y: 0 }; try { const r = el.getLocalPoint(t.x, t.y, out); if (r && Number.isFinite(r.x) && Number.isFinite(r.y)) { lx = r.x; ly = r.y; } } catch { /* fall back to the world delta */ } } // A scrollbar click scrolls — it must never open/close the scout. if (this.maxScroll > 0) { const sb = this._sbGeom(); if (lx >= sb.x - 6 && lx <= sb.x + sb.w + 6 && ly >= sb.y - 6 && ly <= sb.y + sb.h + 6) return; } this._onRow(this._rowAt(lx, ly)); } /** The row whose box contains a list-local point (null = dead space). */ _rowAt(lx, ly) { if (!Number.isFinite(lx) || !Number.isFinite(ly)) return null; if (ly < this.vpY || ly > this.vpY + this.vpH) return null; if (lx < this.vpX || lx > this.vpX + this.vpW) return null; for (const row of this.rows) { if (!row.shown) continue; // clipped out — can't be tapped const y = row.y0 - this._scroll; if (ly >= y - this.rowH / 2 && ly <= y + this.rowH / 2) return row; } return null; } /** Row-tap handler (ShopView toggles the trade scout). null = background. */ set onRowTap(fn) { this._onRow = typeof fn === 'function' ? fn : null; } get onRowTap() { return this._onRow; } // ----------------------------------------------------------- scrollbar // Track + thumb at the viewport's right edge (in the panel's right pad, // clear of the rows). Hidden while the content fits the window. _sbGeom() { const sb = this.bl.scrollbar ?? {}; const w = Math.max(3, Number(sb.w) || 5); const gap = Math.max(2, Number(sb.gap) || 3); return { x: this.vpX + this.vpW + gap, // just right of the rows, inside the panel y: this.vpY, w, h: this.vpH, min: Math.max(20, Number(sb.thumbMin) || 28), trackColor: sb.trackColor ?? '#22405f', thumbColor: sb.thumbColor ?? '#7d92c4', trackAlpha: Number(sb.trackAlpha) || 0.4, thumbAlpha: Number(sb.thumbAlpha) || 0.85, }; } // Thumb height for the current content (viewport's share of the content, // with a min so a short overflow is still grabbable). _sbThumbH() { const sb = this._sbGeom(); const contentH = Math.max(1, this.contentH || this.vpH); return Math.max(sb.min, sb.h * (this.vpH / contentH)); } // Scroll position for a press at list-local `py` (thumb centered on it). _sbScrollForPoint(py) { if (!this.maxScroll || typeof py !== 'number') return 0; const sb = this._sbGeom(); const th = this._sbThumbH(); const frac = Math.max(0, Math.min(1, (py - sb.y - th / 2) / Math.max(1, sb.h - th))); return frac * this.maxScroll; } // Pointer (canvas/world) → list-local y. The scrollbar sits at (0,0) in // list-local with origin (0,0), so its local frame IS list-local. _localY(p) { if (!p || typeof p.y !== 'number') return null; const el = this._sb; if (el && typeof el.getLocalPoint === 'function') { const out = { x: 0, y: 0 }; try { const r = el.getLocalPoint(p.x ?? 0, p.y, out); if (r && Number.isFinite(r.y)) return r.y; } catch { /* fall through */ } } return p.y - (this.y ?? 0); } _buildScrollbar() { const g = this.scene.add?.graphics?.(); if (!g) return; this._sb = g; this._objs.push(g); this.add?.(g); if (typeof g.setInteractive !== 'function' || typeof g.on !== 'function') return; const Rect = Phaser.Geom?.Rectangle; const sb = this._sbGeom(); const pad = 5; // grab margin around the track const area = Rect ? new Rect(sb.x - pad, sb.y - pad, sb.w + pad * 2, sb.h + pad * 2) : null; g.setInteractive({ useHandCursor: true, hitArea: area, hitAreaCallback: (hit, px, py) => (area ? Rect.Contains(hit, px, py) : true), }); g.on('pointerdown', (p) => { if (!this.maxScroll) return; const py = this._localY(p); if (py == null) return; // Jump (thumb centers on the press) + track the drag from there. this.scrollTo(this._sbScrollForPoint(py)); this._sbDrag = { py, scroll: this._scroll }; }); g.on('pointermove', (p) => { if (!this._sbDrag || !this.maxScroll) return; const py = this._localY(p); if (py == null) return; const dy = py - this._sbDrag.py; const th = this._sbThumbH(); this._sbDrag = { py, scroll: this._sbDrag.scroll + dy * (this.maxScroll / Math.max(1, this._sbGeom().h - th)) }; this.scrollTo(this._sbDrag.scroll); }); g.on('pointerup', () => { this._sbDrag = null; }); g.on('pointerout', () => { this._sbDrag = null; }); // left the bar → end drag } // Track + thumb for the current scroll (list-local coords on `this._sb`). _renderScrollbar() { const g = this._sb; if (!g || typeof g.clear !== 'function' || typeof g.fillRect !== 'function') return; g.clear?.(); // Not scrollable → nothing to show (no track, no thumb). if (this.maxScroll <= 0 || !this.contentH) { this._sbThumb = null; return; } const sb = this._sbGeom(); g.fillStyle(_hex(sb.trackColor), sb.trackAlpha); g.fillRect(sb.x, sb.y, sb.w, sb.h); const th = this._sbThumbH(); const ty = sb.y + (this.maxScroll > 0 ? (this._scroll / this.maxScroll) : 0) * (sb.h - th); g.fillStyle(_hex(sb.thumbColor), sb.thumbAlpha); g.fillRect(sb.x + 1, ty + 1, Math.max(1, sb.w - 2), Math.max(1, th - 2)); this._sbThumb = { x: sb.x, y: ty, w: sb.w, h: th }; } // ----------------------------------------------------------- focus glow /** * The light stroke around the SCOUTED row — the "this box" highlight * (the player tapped it, the scout panel shows its strategy). A two-pass * outline: a soft wide glow under a bright thin core, in the row's * CLIPPED slice (it follows the row as the list scrolls, and vanishes * when the row scrolls out — same recipe as the plates' clipping). */ _buildFocus() { const g = this.scene.add?.graphics?.(); if (!g) return; this._focus = g; this._objs.push(g); this.add?.(g); // refresh() re-adds it ABOVE the rows + scrollbar g.clear?.(); } /** * Focus a row (or null to clear). Renders the glow immediately (when the * row is visible) and keeps it in step on scroll (refresh/_clipRows * re-render it). */ focusRow(id) { this._focusId = id ?? null; this._renderFocus(); return this; } _renderFocus() { const g = this._focus; if (!g || typeof g.clear !== 'function' || typeof g.lineStyle !== 'function' || typeof g.strokeRect !== 'function') return; g.clear(); const row = this._focusId ? this.rows.find((r) => r.id === this._focusId) : null; if (!row || !row.shown || row.clipBot <= row.clipTop) { this._focusOn = false; return; } const f = this.bl.focus ?? {}; const color = _hex(f.color ?? '#00e5ff'); const y = row.y0 - this._scroll; const top = Math.max(y - this.rowH / 2, row.clipTop) - 1; const bot = Math.min(y + this.rowH / 2, row.clipBot) + 1; const x = this.vpX - 1; const w = this.vpW + 2; g.lineStyle(Math.max(2, Number(f.glow) || 5), color, Math.round(255 * (f.glowAlpha ?? 0.30))); g.strokeRect(x, top, w, bot - top); // the soft wide glow g.lineStyle(Math.max(1, Number(f.core) || 1.5), color, Math.round(255 * (f.alpha ?? 0.95))); g.strokeRect(x + 1.5, top + 1.5, w - 3, bot - top - 3); // the bright core this._focusOn = true; } // ------------------------------------------------------------------ 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 : []; // `left` = the shop's REMAINING BATCH for this row (the origin's daily // stock — js/economy/Stock.js). Finite on batch-limited rows (a world's // signature good); null/absent = unlimited (hubs' resale, the old model). this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price, left: Number.isFinite(s.left) ? Math.max(0, Math.floor(s.left)) : null })); this.buyQty = new Map(); this._destroyRows(); for (const s of this.stock) this._addRow(s); this._layoutRows(); this._clipRows(); // The rows were added AFTER the scrollbar graphics — re-append it so it // paints on top (v4 paints children in list order). No-op when headless. if (this._sb) this.add?.(this._sb); // …and the focus glow on top of everything (it frames a row's slice). if (this._focus) { this.add?.(this._focus); this._renderFocus(); } 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, left: Number.isFinite(s.left) ? Math.max(0, Math.floor(s.left)) : null, 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 (TITLE LINE — the first line, left of the inventory strip + // price). Truncated to the name's budget (_nameMaxW — it stops BEFORE // the inventory strip, or before the price on unlimited rows), so the // title and the strip can never collide. const nameFont = Number(bl.name?.fontSize) || 14; const nameText = this._fitText(name, nameFont, 1, this._nameMaxW(row)); const descFont = Number(bl.desc?.fontSize) || 12; const textX = this._textX(); const nameEl = this._makeText( textX, 0, nameText, { fontSize: `${nameFont}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); } // description (LINE 2 — its own line; the title line owns the right // block, so the desc gets the row's full text width). row.desc = this._fitText(flavor, descFont, this.descLines, this._descMaxW()); 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 row's full width — the right // block is on the title line above, so nothing shares this line). descEl.setWordWrap?.(true); descEl.setWordWrapWidth?.(this._descMaxW()); descEl.setOrigin?.(0, 0.5); row.objs.desc = descEl; this._objs.push(descEl); this.add?.(descEl); } // price (TITLE LINE — right of the inventory strip, left of the stepper) const bx = this._btnXs(); 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); } // REMAINING BATCH (model v2 — the origin world's daily stock, the one // stateful number in the economy): "3 left" sits just left of the price // on the TITLE LINE (right-aligned, like it), with the stockGap padding // on both sides; 0 = SOLD OUT (magenta, the stepper is clamped at the // same number). Only batch-limited rows have one — hubs' resale is // unlimited (left === null → no element). if (row.left != null) { const leftEl = this._makeText( 0, 0, row.left > 0 ? `${row.left} left` : 'SOLD OUT', { fontSize: `${bl.stock?.fontSize ?? bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.stock?.color ?? bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' }, ); if (leftEl) { leftEl.setOrigin?.(1, 0.5); row.objs.left = leftEl; this._objs.push(leftEl); this.add?.(leftEl); } } // stepper: − qty + (the qty tap = +1, the old grid's behavior). // Centered origin: _btnXs() positions each stepper at its intended // CENTER, so render the glyph centered on that point (name/desc/price // above set their origins the same way). The tap square is centered on // the glyph in _bindStep. const mkStep = (str) => { const t = this._makeText(0, 0, str, { fontSize: `${this.st.fontSize ?? 19}px`, color: this.st.btn ?? '#7d92c4', align: 'center', }); t?.setOrigin?.(0.5, 0.5); return t; }; const minus = mkStep('−'); const qty = mkStep('0'); const plus = mkStep('+'); 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), row); this._bindStep(qty, () => this.bumpBuy(id, +1), row); this._bindStep(plus, () => this.bumpBuy(id, +1), row); this.rows.push(row); } _bindStep(el, fn, row) { if (!el || typeof el.setInteractive !== 'function' || typeof el.on !== 'function') return; const Rect = Phaser.Geom?.Rectangle; // THIS BUILD's input convention (pointWithinHitArea / getLocalPoint in // the phaser build): hitAreaCallback(hitArea, localX, localY, obj), with // localX/Y in the object's TOP-LEFT-anchored frame (the input adds // displayOrigin) — so a Text glyph always spans (0,0)-(w,h) there, // regardless of origin. (A fixed rect centered on (0,0) sits upper-left // of the visible button, anchored on its top-left corner.) // // Test against the LIVE glyph size inside the callback: the qty text // re-measures on every bump ("0"=25px wide, "1"=17px ...), the // top-left anchor moves with it, and a rect baked at bind time slides // (the narrower "1" made the old box drift right and swallow the gap // toward "+"). const PAD = 6; const live = (px, py) => { const w = el.width > 0 ? el.width : 12; const h = el.height > 0 ? el.height : 18; return px >= -PAD && px <= w + PAD && py >= -PAD && py <= h + PAD; }; const area = Rect ? new Rect(-PAD, -PAD, 1, 1) : null; // placeholder — the callback does the real test el.setInteractive({ useHandCursor: true, hitArea: area, // Dead when the row is scrolled out of the viewport (alpha-0 buttons // must not be tappable), and also when the stepper itself is clipped // out at a window edge (_elInVp — hidden glyphs stay unclickable). hitAreaCallback: (hit, px, py) => (row.shown && this._elInVp(el) && live(px, py)), }); el.on('pointerdown', (p) => { p?.stopPropagation?.(); if (row.shown && this._elInVp(el)) 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) + the title-line // right block: [name] … [inventory strip] … [price] [stepper], all on the // product-title line. `left` is the strip's RIGHT edge (origin 1,0.5); // stockGap pads both the name→strip and strip→price gaps. _btnXs() { const right = this.vpW / 2 - this.pad; const price = right - (this.stepperW) - 10; // price's RIGHT edge (origin 1,0.5) const left = price - this.priceW - this.stockGap; // strip's RIGHT edge return { plus: right - 11, qty: right - 44, minus: right - 77, price, left, }; } // The NAME's budget on the title line (row-local px): from the text column // to the inventory strip (batch-limited rows), or to the price (unlimited // rows — no strip). stockGap padding is kept on the strip/price side. _nameMaxW(row) { const bx = this._btnXs(); const rightEdge = (row && row.left != null) ? bx.left - this.stockW - this.stockGap : bx.price - this.priceW - this.stockGap; return Math.max(20, rightEdge - this._textX()); } // Max description width (px) — LINE 2 is the desc's own line (the // name/inventory/price/stepper block sits on the title line above), so // it gets the row's full text width. _descMaxW() { const right = this.vpW / 2 - this.pad; return Math.max(20, right - this._textX() - 4); } // 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, maxW = null) { const s = String(str ?? ''); if (!s) return ''; const w = Math.max(20, maxW ?? this._descMaxW()); const perLine = Math.max(4, Math.floor(w / (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), then render ONLY what is // inside the viewport — this build has no camera/mask clip, so: // · row.shown = the row's body OVERLAPS the window (drives hit-testing // + the plate); // · the plate draws only the intersecting slice (a partial row reads as // a clean cut at the window edge, not a spill over the title or onto // the Confirm bar); // · text/icons render only while they fit WHOLLY inside (a glyph can't // be cropped in this build — half-out would ghost over the title // above / the sub-bar below, so it hides until fully in). _layoutRows() { const n = this.rows.length; const contentH = n * this.rowH + Math.max(0, n - 1) * this.rowGap; this.contentH = contentH; 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; } } // Strict containment: `el`'s whole vertical box inside the viewport. (A // partially out element would render pixels OVER the title / below the // panel — this build can't crop it, so hide it entirely.) Scaled objects // (the 128px icon frames scaled down) count at their DISPLAYED size. _elInVp(el, vpTop = this.vpY, vpBot = this.vpY + this.vpH) { if (!el || typeof el.y !== 'number') return false; const h = (Number(el.height) > 0 ? el.height : 20) * (el.scaleY ?? 1) / 2; return el.y - h >= vpTop - 0.5 && el.y + h <= vpBot + 0.5; } _clipRows() { const cx = this.vpX + this.vpW / 2; const vpTop = this.vpY; const vpBot = this.vpY + this.vpH; 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 < vpBot && bottom > vpTop; // overlaps the window at all row.clipTop = Math.max(top, vpTop); // list-local slice bounds (plate) row.clipBot = Math.min(bottom, vpBot); const o = row.objs; const tx = cx + this._textX(); // row-local → list-local (row center at cx) const bx = this._btnXs(); const titleY = y - this.rowH * 0.18; // the TITLE LINE (name · inventory · price · stepper) o.bg?.setPosition?.(cx, y); o.icon?.setPosition?.(cx - this.vpW / 2 + this.pad + this.iconSize / 2, y); o.name?.setPosition?.(tx, titleY); o.desc?.setPosition?.(tx, y + this.rowH * 0.27); o.price?.setPosition?.(cx + bx.price, titleY); o.left?.setPosition?.(cx + bx.left, titleY); o.minus?.setPosition?.(cx + bx.minus, titleY); o.qty?.setPosition?.(cx + bx.qty, titleY); o.plus?.setPosition?.(cx + bx.plus, titleY); if (row.shown) { for (const el of [o.icon, o.name, o.desc, o.price, o.left, o.minus, o.qty, o.plus]) { el?.setAlpha?.(this._elInVp(el, vpTop, vpBot) ? 1 : 0); } } else { for (const el of Object.values(o)) el?.setAlpha?.(0); } } this._renderRows(); this._renderScrollbar(); this._renderFocus(); // the scouted row's glow follows the scroll } // --------------------------------------------------------- DATA (the core) /** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */ bumpBuy(id, d = 1) { if (!id) return this; const row = this.stock.find((s) => s.id === id); if (!row) return this; // not in stock const cur = this.buyQty.get(id) ?? 0; // THE BATCH CLAMP (model v2) — batch-limited rows (a world's signature // good) can't be selected past the shop's remaining daily stock; // unlimited rows (hubs) have left === null → no cap. const cap = row.left != null ? row.left : Infinity; const next = Math.max(0, Math.min(cap, 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; } /** * Restore { goodId: qty } stepper quantities onto the CURRENT stock (the * ShopView re-applies the player's pending trade after the BUY/SELL toggle * rebuilds the panel). Ids no longer in stock are dropped. Call after * refresh() (which resets the quantities). */ setBuyMap(map = {}) { this.buyQty = new Map(); for (const [id, q] of Object.entries(map ?? {})) { const n = Math.max(0, Math.floor(Number(q) || 0)); if (n > 0 && this.stock.some((s) => s.id === id)) this.buyQty.set(id, n); } this._renderRows(); this._emitSelChange(); return this; } 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; if (o.bg && typeof o.bg.clear === 'function' && typeof o.bg.fillStyle === 'function') { o.bg.clear?.(); // The plate renders ONLY its slice inside the viewport (the row may be // partially out at a window edge — the rest is the cut, not a spill). // Draw in the bg object's OWN local space: _clipRows() moves it to the // row center (cx, y), and Graphics content is object-local. (Filling at // the row's ABSOLUTE list coords used to double the offset — bars // drifted right by cx and down by y.) if (row.shown) { const y = row.y0 - this._scroll; const dTop = Math.max(y - this.rowH / 2, row.clipTop ?? (y - this.rowH / 2)); const dBot = Math.min(y + this.rowH / 2, row.clipBot ?? (y + this.rowH / 2)); if (dBot > dTop) { 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?.(-this.vpW / 2, dTop - y, this.vpW, dBot - dTop); } } } 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; }