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:
parent
71f8bf6fe4
commit
e9eda003b4
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 |
|
|
@ -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; })();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
@ -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')));
|
||||
|
|
|
|||
Loading…
Reference in New Issue