diff --git a/assets/images/originals/lava-planet-01.png b/assets/images/originals/lava-planet-01.png index b732493..58c21cb 100644 Binary files a/assets/images/originals/lava-planet-01.png and b/assets/images/originals/lava-planet-01.png differ diff --git a/assets/images/originals/lava-surface-01.png b/assets/images/originals/lava-surface-01.png new file mode 100644 index 0000000..ca41f22 Binary files /dev/null and b/assets/images/originals/lava-surface-01.png differ diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs index c93907f..11087fc 100644 --- a/dev/shop-shot.mjs +++ b/dev/shop-shot.mjs @@ -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; })(); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index b81ff11..751e0d3 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -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'); diff --git a/dev/wd-shot.mjs b/dev/wd-shot.mjs new file mode 100644 index 0000000..2bc96fa --- /dev/null +++ b/dev/wd-shot.mjs @@ -0,0 +1,98 @@ +/** + * Screenshot helper for this env: geckodriver + headless Firefox. + * node dev/wd-shot.mjs [width] [height] [timeoutMs] + * 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 */ } +} diff --git a/js/ui/shop/BuyList.js b/js/ui/shop/BuyList.js index 910dc11..8f3a8ec 100644 --- a/js/ui/shop/BuyList.js +++ b/js/ui/shop/BuyList.js @@ -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')));