orbit/js/ui/shop/BuyList.js

800 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* BuyList — the shop's BUY side: a scrollable list of stock rows.
*
* Each stock good is ONE WIDE ROW (replacing the old 4×4 icon grid):
*
* ┌────────────────────────────────────────────────────────────┐
* │ BUY │
* │ ┌────────────────────────────────────────────────────────┐ │
* │ │ [icon] Fresh Produce 24 cr − 0 +│ │
* │ │ Crisp, clean, alive. A small mir… │ │
* │ │ [icon] Refined Fuel 41 cr − 1 +│ │
* │ │ The lifeblood of the fleet. │ │
* │ └────────────────────────────────────────────────────────┘ │
* └────────────────────────────────────────────────────────────┘
*
* · LEFT — the good's icon (its goods-sheet frame).
* · MIDDLE — the NAME (top line) + its one-line DESCRIPTION (the good's
* `flavor` from data/goods.json), truncated to fit.
* · RIGHT — the BUY PRICE ("24 cr") + the −/qty/+ stepper.
*
* A row with qty > 0 (in the pending trade) is highlighted — the same
* selection the old grid's steppers tracked (getBuyMap).
*
* SCROLLING — 8–16 rows don't fit in the panel, so the list scrolls:
* drag on the list body, or the mouse wheel (Phaser 4's object-level
* pointermove + wheel events, the MapWindow.js recipe). Rows 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;
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 : [];
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();
// 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, 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 descFont = Number(bl.desc?.fontSize) || 12;
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, descFont, this.descLines);
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 wrap width stops BEFORE the
// price and stepper (_descMaxW()), so a wrapped second line can never
// overlap them. The stub no-ops these (the char-count fit keeps it
// bounded there).
descEl.setWordWrap?.(true);
descEl.setWordWrapWidth?.(this._descMaxW());
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).
// 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).
_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 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) {
const s = String(str ?? '');
if (!s) return '';
const perLine = Math.max(4, Math.floor(this._descMaxW() / (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();
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);
if (row.shown) {
for (const el of [o.icon, o.name, o.desc, o.price, 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;
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;
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;
}