Fix shop buy-row bar position and stepper hitbox anchoring

- Center the row background plate on its own local origin so it no longer double-offsets with the object's world position, eliminating the right/down drift and stretched spacing.
- Switch stepper glyphs to a centered (0.5, 0.5) origin and recompute the tap area live inside hitAreaCallback against the current glyph size, so the box stays centered on the visible button even as qty text re-measures.
- Add dev/shop-shot probes that assert bar fillRect coords and verify plus/minus clicks register on the glyph while a point in the old mis-anchored gap does not.
- Extend landing.json with additional land/surface/takeoff/shop video entries.
This commit is contained in:
Brian Fertig 2026-09-12 08:54:50 -06:00
parent 3d82316a13
commit 71f8bf6fe4
3 changed files with 160 additions and 18 deletions

View File

@ -69,7 +69,14 @@
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "capital-land-neutral.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }
],
"stationVideos": [

View File

@ -164,6 +164,115 @@ const bounds = (o) => {
sv.sellGrid.hoverCell(0);
out.tooltip.finalSell = tipState(sv.sellGrid);
// ---- BUY ROW BAR PROBE (the semi-transparent plates behind each row) ---
// The bar must sit BEHIND its row: the bg object is positioned at the row
// CENTER, and its fillRect is drawn around the object's OWN local origin.
// (A double offset — bg moved to the row center AND filled at the row's
// absolute coords — used to fling the bars right + down with stretched
// spacing. Capture the real fillRect call to verify both halves.)
const bl = sv.buyList;
const barRows = [];
for (const r of bl.rows.slice(0, 3)) {
const calls = [];
const orig = r.objs.bg?.fillRect;
if (orig) r.objs.bg.fillRect = (...a) => { calls.push(a.map(Math.round)); return orig.apply(r.objs.bg, a); };
bl._renderRows();
if (orig) r.objs.bg.fillRect = orig;
barRows.push({
id: r.id,
bgCenter: { x: round(r.objs.bg?.x ?? -1), y: round(r.objs.bg?.y ?? -1) },
rowCenter: { x: round(bl.vpX + bl.vpW / 2), y: round(r.y0 - bl._scroll) },
fillRect: calls[0] ?? null,
});
}
out.buyRowBars = {
vpW: bl.vpW, rowH: bl.rowH,
rows: barRows,
ok: barRows.every((b) =>
b.bgCenter.x === b.rowCenter.x && b.bgCenter.y === b.rowCenter.y &&
b.fillRect?.length === 4 &&
b.fillRect[0] === -Math.round(bl.vpW / 2) && b.fillRect[1] === -Math.round(bl.rowH / 2) &&
b.fillRect[2] === bl.vpW && b.fillRect[3] === bl.rowH),
};
out.pass = out.pass && out.buyRowBars.ok;
// ---- BUY STEPPER HITBOX PROBE (− / qty / + taps) -----------------------
// This build calls hitAreaCallback(hitArea, localX, localY, obj) with
// localX/Y in the object's TOP-LEFT-anchored frame (the input adds
// displayOrigin — see pointWithinHitArea/getLocalPoint in the phaser
// build), so the glyph spans (0,0)-(w,h) there regardless of origin and
// the tap square must be centered on the glyph (w/2, h/2). A square
// centered on (0,0) sits upper-left of the visible button. Verify
// end-to-end with REAL canvas mouse events:
// - a click ON a visible glyph center must register (plus +1, minus −1);
// - a click just outside the new square, still inside the span of the
// old (mis-anchored) square, must NOT register.
const r0 = bl.rows[0];
const stepProbe = (el) => ({
origin: { x: el?.originX ?? null, y: el?.originY ?? null },
size: { w: round(el?.width ?? 0), h: round(el?.height ?? 0) },
});
const stepNames = ['minus', 'qty', 'plus'];
const stepChecks = {};
for (const k of stepNames) stepChecks[k] = stepProbe(r0.objs[k]);
const clickCanvas = (wx, wy) => {
const c = game.canvas;
const r = c.getBoundingClientRect();
const cx = r.left + (wx / 1280) * r.width;
const cy = r.top + (wy / 720) * r.height;
const opts = { clientX: cx, clientY: cy, bubbles: true, view: window, button: 0 };
c.dispatchEvent(new MouseEvent('mousedown', opts));
c.dispatchEvent(new MouseEvent('mouseup', opts));
};
const plusEl = r0.objs.plus;
const minusEl = r0.objs.minus;
const qtyEl = r0.objs.qty;
const pCx = bl.x + plusEl.x; // glyph center in world/canvas coords (origin 0.5)
const pCy = bl.y + plusEl.y;
const mCx = bl.x + minusEl.x;
const PAD = 6; // must match _bindStep's tap padding
bl.buyQty.delete(r0.id); bl._renderRows();
await sleep(80);
const qStart = bl.buyQty.get(r0.id) ?? 0;
clickCanvas(pCx, pCy); // PLUS: on the visible glyph
await sleep(120);
const qAfterPlus = bl.buyQty.get(r0.id) ?? 0;
// Discriminator for the OLD (mis-anchored) box: the old plus box spanned
// [pCx-16, pCx+4] (anchored on the glyph top-left corner). The NEW boxes
// (glyph-centered) leave a small GAP between qty and plus — intersect it
// with the old box and pick the middle: hits under the old code, misses
// under the new.
const spacing = Math.round(plusEl.x - qtyEl.x);
const qtyWNow = qtyEl.width;
const gapL = pCx - spacing + qtyWNow / 2 + PAD; // qty box right edge
const gapR = pCx - plusEl.width / 2 - PAD; // plus box left edge
const L = Math.max(gapL, pCx - 16);
const R = Math.min(gapR, pCx + 4);
const missX = Math.round((L + R) / 2);
const miss = { missX, window: [Math.round(gapL), Math.round(gapR)], gapPx: Math.round((gapR - gapL) * 10) / 10, insideOldPlusBox: missX >= pCx - 16 && missX <= pCx + 4 };
clickCanvas(missX, pCy);
await sleep(120);
const qAfterMiss = bl.buyQty.get(r0.id) ?? 0;
clickCanvas(mCx, pCy); // MINUS: on the visible glyph
await sleep(120);
const qAfterMinus = bl.buyQty.get(r0.id) ?? 0;
out.buySteppers = {
rows: stepChecks,
clicks: {
plusOnGlyph: qAfterPlus - qStart,
outsideBox: qAfterMiss - qAfterPlus,
minusOnGlyph: qAfterMinus - qAfterMiss,
},
miss,
ok: stepNames.every((k) => stepChecks[k].origin?.x === 0.5 && stepChecks[k].origin?.y === 0.5)
&& gapR > gapL
&& (qAfterPlus - qStart) === 1
&& (qAfterMiss - qAfterPlus) === 0
&& (qAfterMinus - qAfterMiss) === -1,
};
out.pass = out.pass && out.buySteppers.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

@ -249,39 +249,62 @@ export class BuyList extends Phaser.GameObjects.Container {
);
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)
const mkStep = (str) => this._makeText(0, 0, str, {
fontSize: `${this.st.fontSize ?? 19}px`,
color: this.st.btn ?? '#7d92c4', align: 'center',
});
// 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('+');
const hit = (el) => (el && el.width > 0 ? el.width + 8 : 22);
const bx = this._btnXs();
const stepHit = Math.max(hit(minus), hit(plus), 22) / 2;
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), bx.minus, stepHit, row);
this._bindStep(qty, () => this.bumpBuy(id, +1), bx.qty, stepHit, row);
this._bindStep(plus, () => this.bumpBuy(id, +1), bx.plus, stepHit, row);
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, x, half, row) {
_bindStep(el, fn, row) {
if (!el || typeof el.setInteractive !== 'function' || typeof el.on !== 'function') return;
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(-half, -half, half * 2, half * 2) : null;
// 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 — gate the hit test on row visibility too).
hitAreaCallback: (p, px, py) => (row.shown ? (area ? Rect.Contains(p, px, py) : true) : false),
hitAreaCallback: (hit, px, py) => (row.shown && live(px, py)),
});
el.on('pointerdown', (p) => {
p?.stopPropagation?.();
@ -444,13 +467,16 @@ export class BuyList extends Phaser.GameObjects.Container {
// selection cyan), the qty text lit.
const sel = q > 0;
const bl = this.bl;
const cx = this.vpX + this.vpW / 2;
const y = row.y0 - this._scroll;
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);
o.bg.fillRect?.(cx - this.vpW / 2, y - this.rowH / 2, this.vpW, this.rowH);
// 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);
}
if (o.qty && typeof o.qty.setColor === 'function') {
o.qty.setColor(toCss(sel ? (this.st.activeColor ?? '#00e5ff') : (this.st.color ?? '#eaf6ff')));