879 lines
38 KiB
JavaScript
879 lines
38 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 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;
|
||
}
|