Fix shop buy-list clipping and add a scrollbar

- Rows that spill past the viewport edge used to render whole, ghosting over
  the BUY title or onto the Confirm bar; now only elements fully inside the
  window render (this Phaser build can't crop glyphs), and row plates draw
  just their in-window slice for a clean cut.
- Clipped-out steppers are no longer tappable: hit-testing gates on element
  containment, not just row visibility.
- Add a right-edge scrollbar (track + drag/jump thumb) that tracks scroll
  position and hides when content fits the viewport.
- Extend shop-shot/dev tests to verify the clip invariant, dead-click
  behavior, and scrollbar geometry; add a geckodriver screenshot helper.
This commit is contained in:
Brian Fertig 2026-09-12 09:21:05 -06:00
parent 71f8bf6fe4
commit e9eda003b4
6 changed files with 427 additions and 24 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -273,6 +273,97 @@ const bounds = (o) => {
};
out.pass = out.pass && out.buySteppers.ok;
// ---- BUY LIST CLIP + SCROLLBAR PROBE (rows stay INSIDE the window) ----
// The reported bug: rows rendered OUTSIDE the panel bounds — spilling over
// the BUY title (top) and onto the Confirm sub-bar (below). Verify:
// (a) the bottom edge row at scroll 0 is a SLICE — shown, but every one
// of its elements clipped OUT (alpha 0, would ghost onto the bar);
// (b) invariant: every element that IS rendered (alpha 1) of every row
// fits WHOLLY inside the viewport, at several scroll positions;
// (c) the edge row's clipped-out stepper is UNCLICKABLE (hidden buttons
// stay dead);
// (d) the scrollbar track sits in the right pad inside the panel, and
// the thumb tracks the scroll (top at 0, lower when scrolled).
const clipSnapshot = () => bl.rows.map((row) => {
const y = row.y0 - bl._scroll;
const els = ['icon', 'name', 'desc', 'price', 'minus', 'qty', 'plus'];
const alphas = {};
let allIn = true;
for (const k of els) {
const el = row.objs[k];
if (!el) continue;
alphas[k] = el.alpha;
if (el.alpha === 1) {
const h = (Number(el.height) > 0 ? el.height : 20) * (el.scaleY ?? 1) / 2; // icons: displayed size
if (!(el.y - h >= bl.vpY - 0.5 && el.y + h <= bl.vpY + bl.vpH + 0.5)) allIn = false;
}
}
return {
id: row.id, rowTop: round(y - bl.rowH / 2), rowBot: round(y + bl.rowH / 2),
shown: !!row.shown, clipTop: round(row.clipTop), clipBot: round(row.clipBot),
alphas, allIn,
};
});
const sbGeom = bl._sbGeom ? bl._sbGeom() : null;
const thumbAt = () => bl._sbThumb ? { x: round(bl._sbThumb.x), y: round(bl._sbThumb.y), w: round(bl._sbThumb.w), h: round(bl._sbThumb.h) } : null;
bl.scrollTo(0);
await sleep(120);
const atTop = clipSnapshot();
const thumbTop = thumbAt();
// The edge row: its body crosses the viewport bottom at scroll 0.
const edgeRowObj = bl.rows.find((r) => r.shown && (r.y0 - bl._scroll + bl.rowH / 2) > bl.vpY + bl.vpH + 0.5 && (r.y0 - bl._scroll - bl.rowH / 2) < bl.vpY + bl.vpH + 0.5);
const edge = atTop.find((r) => r.id === edgeRowObj?.id) ?? null;
// (c) the clipped-out stepper of the edge row must be DEAD (only when that
// geometry actually clips the stepper out — otherwise it stays clickable).
let deadClick = null;
if (edgeRowObj) {
const el = edgeRowObj.objs.plus;
const plusClipped = (edge?.alphas?.plus ?? el.alpha) === 0;
if (plusClipped) {
const before = bl.buyQty.get(edgeRowObj.id) ?? 0;
clickCanvas(bl.x + el.x, bl.y + el.y);
await sleep(120);
const after = bl.buyQty.get(edgeRowObj.id) ?? 0;
deadClick = { alpha: el.alpha, before, after, registered: after - before };
bl.buyQty.delete(edgeRowObj.id);
bl._renderRows();
} else {
deadClick = { alpha: el.alpha, skipped: 'plus is visible in this geometry' };
}
}
// (b)+(d) mid-scroll: invariant still holds, thumb moved down.
bl.scrollTo(Math.min(80, bl.maxScroll));
await sleep(120);
const atMid = clipSnapshot();
const thumbMid = thumbAt();
bl.scrollTo(bl.maxScroll);
await sleep(120);
const thumbBot = thumbAt();
bl.scrollTo(0);
await sleep(120);
const outRowsOk = atTop.every((r) => r.allIn) && atMid.every((r) => r.allIn);
const edgeOk = !!edge
&& edge.clipBot === round(bl.vpY + bl.vpH)
&& edge.alphas.plus === 0; // the edge row's stepper is clipped out here
const sbOk = !!sbGeom
&& sbGeom.x >= bl.vpX + bl.vpW
&& sbGeom.x + sbGeom.w <= bl.width
&& !!thumbTop && Math.abs(thumbTop.y - sbGeom.y) < 1
&& thumbTop.h < sbGeom.h
&& !!thumbMid && thumbMid.y > thumbTop.y
&& !!thumbBot && thumbBot.y > thumbMid.y;
out.buyClip = {
vp: { x: bl.vpX, y: bl.vpY, w: bl.vpW, h: bl.vpH, bot: bl.vpY + bl.vpH },
panel: { h: bl.width && bl.height, w: bl.width },
edgeRow: edge ? { id: edge.id, rowTop: edge.rowTop, rowBot: edge.rowBot, clipTop: edge.clipTop, clipBot: edge.clipBot } : null,
deadClick,
sb: sbGeom ? { x: round(sbGeom.x), y: round(sbGeom.y), w: round(sbGeom.w), h: round(sbGeom.h) } : null,
thumb: { top: thumbTop, mid: thumbMid, bottom: thumbBot },
rowsAtTop: atTop.map((r) => ({ id: r.id, shown: r.shown, clip: [r.clipTop, r.clipBot], alphas: r.alphas, allIn: r.allIn })),
ok: outRowsOk && edgeOk && sbOk && (!deadClick || deadClick.skipped || deadClick.registered === 0),
};
out.pass = out.pass && out.buyClip.ok;
out.screen = { w: ss.scale.width, h: ss.scale.height };
out.topY = sv.topY ?? null;
out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })();

View File

@ -16,8 +16,11 @@
* - BUY — stepping a stock row buys that good at the shop's buy price;
* Confirm spends the credits and fills the bays.
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
* ends, off-window rows are clipped, and selection is data (stepping an
* off-screen row still works).
* ends, rows are CLIPPED to the window (plate slice at the edge, text
* only while fully inside — nothing may render over the title or onto
* the Confirm bar), and a right-edge SCROLLBAR (track + thumb) tracks
* the scroll position; selection is data (stepping an off-screen row
* still works).
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
* credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm.
@ -452,8 +455,60 @@ console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, cli
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
// ELEMENT CLIPPING — the spill the player saw: a partially-out row used to
// render WHOLE (ghosting over the title above / the Confirm bar below).
// Now: `shown` = overlaps the window; every element that IS rendered fits
// WHOLLY inside (this build can't crop text pixels, so partially-out
// elements hide). Check the invariant on the whole list, at the top of
// the scroll where the bottom rows are the edge case.
list.scrollTo(0);
const vpTop = list.vpY, vpBot = list.vpY + list.vpH;
const inBox = (el) => { 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; };
let edgeRow = null;
for (const row of list.rows) {
const eL = [...Object.values(row.objs)];
const visible = eL.filter((el) => el && el.alpha === 1);
if (row.shown) {
for (const el of visible) assert.ok(inBox(el), `rendered element of row ${row.id} is WHOLLY inside the viewport (y=${el.y.toFixed(1)}, h=${el.height ?? '?'})`);
// a slice row: the window cuts it — some elements in, some out
if (visible.length < eL.filter((el) => el).length) edgeRow = row;
} else {
assert.equal(visible.length, 0, `row ${row.id} is off-window → nothing of it renders`);
}
}
assert.ok(edgeRow, 'at scroll 0 a bottom row is PARTIALLY in (plate slice) with its text clipped out');
assert.ok(edgeRow.clipBot <= vpBot + 0.5, '… the plate slice ends at the window bottom (not past it)');
assert.ok(list.rows[0].objs.name.alpha === 1 && list.rows[0].objs.plus.alpha === 1, '… and the fully-inside top row renders everything');
// SCROLLBAR — a track at the viewport's right edge + a thumb that tracks
// the scroll (and stays inside the panel). Hidden when content fits.
const sb = list._sbGeom();
assert.ok(sb.x >= list.vpX + list.vpW, 'scrollbar sits right of the viewport (in the right pad)');
assert.ok(sb.x + sb.w <= list.width, '… inside the panel');
assert.equal(sb.y, list.vpY, '… aligned to the viewport top');
assert.equal(sb.h, list.vpH, '… spanning the viewport height');
assert.ok(list._sbThumb, 'content outgrows the window → a thumb exists');
const th = list._sbThumb;
assert.ok(th.h >= sb.min && th.h <= sb.h, `thumb height is sane (${th.h.toFixed(1)}px, min ${sb.min}, max ${sb.h})`);
assert.ok(th.h < sb.h, '… and shorter than the track (content > viewport)');
assert.ok(Math.abs(th.h - sb.h * (list.vpH / list.contentH)) < 1, 'thumb height ∝ viewport/content share');
assert.ok(Math.abs(th.y - sb.y) < 0.5, 'scroll 0 → thumb at the track top');
list.scrollTo(list.maxScroll);
assert.ok(Math.abs(list._sbThumb.y - (sb.y + sb.h - th.h)) < 0.5, 'scroll max → thumb at the track bottom');
list.scrollTo(list.maxScroll / 2);
assert.ok(list._sbThumb.y > sb.y + 1 && list._sbThumb.y < sb.y + sb.h - th.h, '… mid-scroll sits between');
// Jump math: press at the track top/bottom → scroll 0 / maxScroll.
assert.equal(list._sbScrollForPoint(sb.y), 0, 'press at track top → scroll 0');
assert.ok(Math.abs(list._sbScrollForPoint(sb.y + sb.h) - list.maxScroll) < 1, 'press at track bottom → max scroll');
// Content that FITS the window → no scrollbar at all.
const fullStock = list.stock.map((s) => ({ ...s }));
list.refresh({ stock: [{ id: 'solo', name: 'Solo', price: 1 }] });
assert.equal(list.maxScroll, 0, 'single row → no scroll');
assert.equal(list._sbThumb, null, '… and no thumb (a full-track bar would lie)');
// Selection is DATA, not position: stepping a good works whether its row
// is on screen or not (the player can scroll, then adjust, freely).
list.refresh({ stock: fullStock });
const last = list.stock[list.stock.length - 1].id;
list.bumpBuy(last, 1);
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');

98
dev/wd-shot.mjs Normal file
View File

@ -0,0 +1,98 @@
/**
* Screenshot helper for this env: geckodriver + headless Firefox.
* node dev/wd-shot.mjs <url> <setupExpr> <shotFile> [width] [height] [timeoutMs]
* <setupExpr> is a WebDriver execute/sync script (function body) that runs on
* the page before the screenshot; may be '' to skip. The page is given time
* to finish loading (the expression is polled until it returns non-null).
*/
import { spawn, execFileSync } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
const [url, setupExpr, shotFile, w = '1280', h = '720', timeoutMs = '30000'] = process.argv.slice(2);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let geckodriver;
try { geckodriver = execFileSync('sh', ['-c', 'command -v geckodriver'], { encoding: 'utf8' }).trim(); }
catch { console.error('need geckodriver on PATH'); process.exit(2); }
const firefoxCandidates = [];
try {
const ff = execFileSync('sh', ['-c', 'command -v firefox'], { encoding: 'utf8' }).trim();
if (ff) firefoxCandidates.push(ff);
} catch { /* ignore */ }
try {
const snapRev = readdirSync('/snap/firefox', { withFileTypes: true }).map((d) => d.name);
for (const rev of [null, ...snapRev]) {
const base = rev ? `/snap/firefox/${rev}` : '/snap/firefox/current';
const p = `${base}/usr/lib/firefox/firefox`;
if (existsSync(p)) firefoxCandidates.push(p);
}
} catch { /* no snap firefox */ }
if (firefoxCandidates.length === 0) { console.error('no firefox binary'); process.exit(2); }
const PORT = 4460 + (process.pid % 50);
const driver = spawn(geckodriver, ['--port', String(PORT), '--log', 'fatal'], { stdio: 'ignore' });
const base = `http://127.0.0.1:${PORT}`;
const fail = (msg, code = 1) => { try { driver.kill('SIGKILL'); } catch { /* ok */ } console.error(msg); process.exit(code); };
const wd = async (method, path, body) => {
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
const text = await res.text();
let data = null; try { data = JSON.parse(text); } catch { /* ignore */ }
if (!res.ok) throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`);
return data;
};
try {
let up = false;
for (let i = 0; i < 60; i++) {
try { const v = await (await fetch(`${base}/status`)).json(); if (v?.value?.ready || v?.status === 'ok' || v?.value) { up = true; break; } } catch { /* not up */ }
await sleep(250);
}
if (!up) fail('geckodriver never came up');
let sess; let lastErr = null;
for (const ff of firefoxCandidates) {
try {
sess = await wd('POST', '/session', {
capabilities: { alwaysMatch: {
browserName: 'firefox',
'moz:firefoxOptions': { args: ['-headless'], binary: ff },
} },
});
break;
} catch (err) { lastErr = String(err?.message ?? err); }
}
if (!sess) fail(`no firefox candidate accepted: ${lastErr}`);
const sid = sess.value?.sessionId;
if (!sid) fail('no session id');
if (w && h) {
try { await wd('POST', `/session/${sid}/window`, { handle: 'current', state: { width: Number(w), height: Number(h) } }); } catch { /* keep default size */ }
}
await wd('POST', `/session/${sid}/url`, { url });
if (setupExpr) {
const deadline = Date.now() + Number(timeoutMs);
let last = null;
while (Date.now() < deadline) {
await sleep(250);
try {
const r = await wd('POST', `/session/${sid}/execute/sync`, { script: setupExpr, args: [] });
if (r.value !== undefined && r.value !== null) { last = r.value; break; }
} catch (err) { last = `ERR ${String(err?.message ?? err)}`; }
}
if (last === null) fail('setup expression never returned a value');
if (typeof last === 'string' && last.startsWith('ERR')) fail(last);
console.log('setup →', JSON.stringify(last).slice(0, 400));
await sleep(400);
}
const shot = await wd('GET', `/session/${sid}/screenshot`);
writeFileSync(shotFile, Buffer.from(shot.value, 'base64'));
console.log('saved', shotFile);
await wd('DELETE', `/session/${sid}`).catch(() => {});
} finally {
try { driver.kill('SIGKILL'); } catch { /* ok */ }
}

View File

@ -23,11 +23,16 @@
*
* SCROLLING — 8–16 rows don't fit in the panel, so the list scrolls:
* drag on the list body, or the mouse wheel (Phaser 4's object-level
* pointermove + wheel events, the MapWindow.js recipe). Rows outside the
* viewport are hidden (row-based clipping — this Phaser build, 4.2.1 Giedi,
* has no camera/mask clip and no ScrollContainer). Scrolling never touches
* the SELECTION: buyQty is plain data keyed by good id, so stepping a good
* works whether or not its row is on screen.
* 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
@ -88,13 +93,21 @@ export class BuyList extends Phaser.GameObjects.Container {
this._surface = null; // the scroll surface (drag + wheel)
this._drag = null; // { y } while a press drags the list
// --- 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)
}
// ------------------------------------------------------------------ panel
@ -172,6 +185,112 @@ export class BuyList extends Phaser.GameObjects.Container {
});
}
// ----------------------------------------------------------- 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 };
}
// ------------------------------------------------------------------ rows
/**
* Set the list's contents from the shop's stock (Economy.shopFor → buy:
@ -186,6 +305,9 @@ export class BuyList extends Phaser.GameObjects.Container {
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);
return this;
}
@ -303,12 +425,13 @@ export class BuyList extends Phaser.GameObjects.Container {
useHandCursor: true,
hitArea: area,
// Dead when the row is scrolled out of the viewport (alpha-0 buttons
// must not be tappable — gate the hit test on row visibility too).
hitAreaCallback: (hit, px, py) => (row.shown && live(px, py)),
// 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) fn();
if (row.shown && this._elInVp(el)) fn();
});
}
@ -375,12 +498,20 @@ export class BuyList extends Phaser.GameObjects.Container {
return this;
}
// Row-based clip: lay rows at (y0 − scroll), hide the ones off-viewport
// (this Phaser build has no camera/mask clip). A row counts as visible
// while it OVERLAPS the viewport, so partial rows fade in as you scroll.
// 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++) {
@ -388,14 +519,27 @@ export class BuyList extends Phaser.GameObjects.Container {
}
}
// 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 < this.vpY + this.vpH && bottom > this.vpY;
const a = row.shown ? 1 : 0;
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();
@ -407,9 +551,16 @@ export class BuyList extends Phaser.GameObjects.Container {
o.minus?.setPosition?.(cx + bx.minus, y);
o.qty?.setPosition?.(cx + bx.qty, y);
o.plus?.setPosition?.(cx + bx.plus, y);
for (const el of Object.values(o)) el?.setAlpha?.(a);
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();
}
// --------------------------------------------------------- DATA (the core)
@ -469,14 +620,22 @@ export class BuyList extends Phaser.GameObjects.Container {
const bl = this.bl;
if (o.bg && typeof o.bg.clear === 'function' && typeof o.bg.fillStyle === 'function') {
o.bg.clear?.();
o.bg.fillStyle(_hex(sel ? (bl.rowSelBg ?? '#12283a') : (bl.rowBg ?? '#0e1728')), sel ? (bl.rowSelBgAlpha ?? 0.85) : (bl.rowBgAlpha ?? 0.55));
o.bg.lineStyle?.(sel ? 2 : 1, _hex(sel ? (bl.selected ?? '#00e5ff') : (bl.rowBorder ?? '#1d2e4c')), 255);
// Draw centered on the bg object's OWN local origin — _clipRows()
// already moves the object to the row center (cx, y), and Graphics
// content renders in the object's local space. Filling at the row's
// ABSOLUTE list coords here used to double the offset (bars drifted
// right by cx and down by y, with stretched vertical spacing).
o.bg.fillRect?.(-this.vpW / 2, -this.rowH / 2, this.vpW, this.rowH);
// 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')));