/** * ShopView — the buy/sell (and buyback) screen over the shop clip video. * * ┌─────────────────────────────────────────────────────────────┐ * │ [ Buy/Sell | Buyback ] ← tabs │ * │ │ * │ ┌SELL 4×4┐ ┌ NET ┐ ┌BUY 4×4┐ │ * │ │ bays │ │ +420 │ │ stock │ │ * │ │ (cargo)│ │ cr │ │ (12–16)│ │ * │ └────────┘ │[Conf]│ └────────┘ │ * │ │[Clear]│ │ * └─────────────────────────────────────────────────────────────┘ * * The two sides are TradeGrids (js/ui/shop/TradeGrid.js): * · SELL (left) — the player's cargo bays. Tap a filled bay to check it * off (a bay = 1 unit). Locked bays are the hold's upgrade room. * · BUY (right) — the shop's stock, each with a −/qty/+ stepper. * The live NET preview (green ≥ 0, red < 0) and Confirm/Clear live in the * center; Confirm is disabled while the trade is invalid (can't go negative, * hold would overflow). SELL→BUY happens atomically (sell to free room for a * buy). * * BUYBACK (session-local only) — the second tab. After the player SELLs * something, the goods they sold are listed here and can be bought BACK — * priced at the sale price (a neutral undo, so sell→buyback can't loop into * free credits). It disappears when the player leaves the shop (no * persistence; data/shopui.json). * * This component owns the SESSION state (soldThisSession, the active tab, the * selection). The PRICING + preview math is js/economy/Economy.js (fixed * per-shop prices + tradePreview). The credits/cargo ledger is ship.credits * + ship.cargo (CargoHold). Headless-testable: the logic (preview / confirm / * clear / buyback) is plain data; the Phaser rendering is best-effort. */ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; import * as E from '../../economy/Economy.js'; import { MenuButton } from '../MenuButton.js'; import { toColor, toCss } from '../../utils/Color.js'; import { fontStack } from '../../utils/Theme.js'; import { TradeGrid } from './TradeGrid.js'; export class ShopView extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). * @param {object} o — { ship, galaxy, cls, systemId, gameScene, onLeave } * ship — the Ship (credits + cargo). * galaxy — the generated galaxy (Economy pricing). * cls — the shop's economic class (Economy.economicClass). * systemId — this system (the distance term for sell prices). * gameScene — to refresh the credits/minerals HUD after a trade. * onLeave — called by close() so the SurfaceScene can restore the * surface loop (the deck's "Leave Shop" does this too). * anchor — { x, y, w } the sub-bar's anchor: the deck's "Shop" * button center x, the deck's top-rail y (the bar's bottom * edge rests on it), and the button width (right-aligns the * bar) — the MenuSubBar recipe (see SurfaceScene.shopAnchor). */ constructor(scene, o) { super(scene, 0, 0); this.scene = scene; this.ship = o.ship; this.galaxy = o.galaxy; this.cls = o.cls; this.systemId = o.systemId; this.gameScene = o.gameScene; this.onLeave = o.onLeave; // The tab sub-bar anchor (the deck's Shop button). Falls back to a // bottom-center bar if the scene doesn't provide one (headless/dev). this.anchor = o.anchor ?? { x: this._W() / 2, y: this._H() - 58, w: 120 }; // The trade panel's TOP edge — just under the surface HUD's planet text // (the scene computes it from its own hudName/hudLine). Falls back to a // small top margin if the scene doesn't provide one (headless/dev). this.topY = Number.isFinite(o.topY) ? o.topY : (this._H() * 0.16); this.maxSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16)); this.capacity = this.ship.cargo ? this.ship.cargo.capacity : 0; // --- session state --------------------------------------------------- this.tab = 'trade'; // 'trade' | 'buyback' this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source) this.buybackSel = new Map(); // goodId -> qty to buy back this.sellGrid = null; this.buyGrid = null; this._content = null; // the active content container this._previewText = null; this._confirmBtn = null; this._clearBtn = null; this._tabBuyback = null; this._buildTabs(); this.enter(); // Present in the scene, above the shop clip (depth 10) + deck (50), below // toasts. setScrollFactor(0) = screen-fixed (the SurfaceScene is a full // overlay). Best-effort so a headless fake scene still constructs. this.setScrollFactor?.(0); this.setDepth?.(100); this.scene.add?.existing?.(this); } // --------------------------------------------------------------------- // ENTRY + TABS // --------------------------------------------------------------------- enter() { // Entry state: the shop video + tab bar only. The player must click // "Buy/Sell" to reveal the trade panel (this keeps the screen open so the // shop clip plays before the UI takes over). Buyback is grayed until a // sale exists this visit. this.tab = null; this._refreshTabs(); return this; } isBuybackEnabled() { return this.soldThisSession.some((r) => r.qty > 0); } setTab(t) { if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked this.tab = t; this._destroyContent(); if (t === 'trade') this._showTrade(); else this._showBuyback(); this._refreshTabs(); return this; } _buildTabs() { // The tab sub-bar: the same recipe as the deck's Menu sub-bar // (js/ui/MenuSubBar.js) — a console-blue panel with cut corners + the // signature top rail, holding cyberpunk MenuButtons. It rests ABOVE the // command deck, right-aligned to the deck's "Shop" button (this.anchor), // so it reads as the same family of UI as the Menu drawer. const a = this.anchor; const H = config.get('save.subBar.height', 58); const pad = config.get('save.subBar.padding', 16); const gap = config.get('save.subBar.gap', 12); const tabs = config.get('shopui.tabs', {}); const c = config.section('save.colors', {}); const btnCfg = config.section('save.subBar.button', {}); const items = [ { id: 'trade', label: tabs.buySell ?? 'Buy/Sell', accent: tabs.activeAccent ?? c.neon ?? '#00e5ff' }, { id: 'buyback', label: tabs.buyback ?? 'Buyback', accent: c.dim ?? '#7d92c4' }, ]; // Build the buttons first (they measure their own width) so the row // layout uses the REAL widths — no second, divergent text-measure pass. const mkBtn = (item) => new MenuButton( this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id), { screenFixed: true, fontSize: btnCfg.fontSize ?? 13, paddingX: btnCfg.paddingX ?? 18, paddingY: btnCfg.paddingY ?? 9, letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'), }, ); const btns = items.map(mkBtn); const widths = btns.map((b) => b.style?.width ?? 120); const W = Math.min( this._W() - 24, pad * 2 + widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1), ); // Position: bottom edge on the deck's top rail (a.y), right edge on the // Shop button's right edge — the MenuSubBar recipe. The sub-bar is a // CHILD of the ShopView (which stays at the screen origin, 0,0) at // (cx, a.y) — so the trade panel (a SIBLING at 0,0) still renders in // screen space, not offset down to the deck. const sceneW = this._W(); let cx = a.x + a.w / 2 - W / 2; cx = Phaser.Math.Clamp(cx, W / 2 + 12, sceneW - W / 2 - 12); const bar = new Phaser.GameObjects.Container(this.scene, cx, a.y); bar.setScrollFactor?.(0); this.subBar = bar; this.subBarW = W; this.subBarH = H; this.add?.(bar); // The body FIRST (v4 paints children in list order — the opaque panel // fill must come before the buttons or it would cover them). const bodyG = this.scene.add?.graphics?.(); if (bodyG) { bodyG.setScrollFactor?.(0); bar.add?.(bodyG); this._drawSubBarBody(bodyG, W, H, c); } // The buttons LAST (above the body), centered in the row. let bx = -W / 2 + pad + widths[0] / 2; items.forEach((it, i) => { const btn = btns[i]; btn.setX?.(bx); bar.add?.(btn); if (it.id === 'trade') this._tabTrade = btn; else this._tabBuyback = btn; bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0); }); // Buyback starts grayed (disabled) until a sale exists this visit. this._refreshTabs(); } /** The sub-bar panel body (the MenuSubBar recipe at full height). */ _drawSubBarBody(g, W, H, c) { if (!g || typeof g.clear !== 'function') return; const panelTop = toColor(c.panelTop ?? '#0e1930'); const panelBottom = toColor(c.panelBottom ?? '#050912'); const neon = toColor(c.neon ?? '#00e5ff'); const magenta = toColor(c.magenta ?? '#ff2d6f'); const ink = toColor(c.ink ?? '#eaf6ff'); const cut = Math.max(3, Math.min(12, H * 0.18)); g.clear(); const pts = [ { x: -W / 2 + cut, y: -H }, { x: W / 2 - cut, y: -H }, { x: W / 2, y: -H + cut }, { x: W / 2, y: 0 }, { x: -W / 2, y: 0 }, { x: -W / 2, y: -H + cut }, ]; const has = (fn) => typeof g[fn] === 'function'; if (has('fillPoints')) { g.fillStyle(panelBottom, 1); g.fillPoints(pts, true); g.fillStyle(panelTop, 0.55); g.fillPoints(pts, true); } if (has('fillRect')) { g.fillStyle(neon, 0.05); g.fillRect(-W / 2, -H, 26, H); // cyan left g.fillStyle(magenta, 0.06); g.fillRect(W / 2 - 26, -H, 26, H); // magenta right const rail = 2; g.fillStyle(neon, 0.9); g.fillRect(-W / 2 + cut, -H, W - cut * 2, rail); g.fillStyle(ink, 0.8); g.fillRect(-W / 2 + cut, -H + 0.5, W - cut * 2, 0.8); g.fillStyle(magenta, 0.5); g.fillRect(-W / 2 + cut, -H + rail, W - cut * 2, 1); g.fillStyle(magenta, 0.35); g.fillRect(-W / 2, -1.5, W, 1.5); // seam } } /** A tab button press (MenuButton → _onTabTap). */ _onTabTap(id) { if (id === 'trade') this.setTab('trade'); else if (id === 'buyback' && this.isBuybackEnabled()) this.setTab('buyback'); } _refreshTabs() { // MenuButtons carry their own state (base/hover/disabled) — the only thing // that changes here is Buyback's locked state (grayed until a sale exists). this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled()); } // --------------------------------------------------------------------- // CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons) // --------------------------------------------------------------------- _showTrade() { const grid = config.get('shopui.grid', {}); const centerW = grid.centerW ?? 208; const W = this._W(); const sellGrid = new TradeGrid(this.scene, { side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity, cls: this.cls, systemId: this.systemId, sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy), buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy), }); const buyGrid = new TradeGrid(this.scene, { side: 'buy', maxSlots: this.maxSlots, cls: this.cls, systemId: this.systemId, sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy), buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy), }); this.sellGrid = sellGrid; this.buyGrid = buyGrid; // Layout: [SELL grid] [center] [BUY grid]. The panel's TOP edge sits at // this.topY — just under the surface HUD's planet text (upper-left) — so // the whole panel reads as one unit near the top of the screen (not // hanging down to the deck/sub-bar). // // IMPORTANT: a TradeGrid lays its plate/cells out in local (0,0)→(w,h) // and a Container's (x,y) is a pure TRANSLATION of that local space // (verified with dev/cbox-test.html) — so the grid's position IS the // panel's top-left corner. Treating it as a centered origin (topY + h/2) // drops the panel half a grid lower and clips the bottom rows. const totalW = sellGrid.width + centerW + buyGrid.width; const leftX = Math.max(8, (W - totalW) / 2); const gridH = sellGrid.height; sellGrid.setPosition(leftX, this.topY); const rightX = leftX + sellGrid.width + centerW; buyGrid.setPosition(rightX, this.topY); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); content.setScrollFactor?.(0); content.add?.(sellGrid); content.add?.(buyGrid); // center column: NET preview + Confirm + Clear — vertically centered with // the grids (the NET above, the buttons below), so the whole panel is one // cohesive block near the top of the screen. const cx = leftX + sellGrid.width + centerW / 2; const cy = this.topY + gridH / 2; this._previewLabel = this._makeText(cx, cy - 52, config.get('shopui.preview.label', 'NET'), { fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center', }); this._previewText = this._makeText(cx, cy - 14, 'NET 0 cr', { fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center', }); content.add?.(this._previewLabel); content.add?.(this._previewText); // Confirm / Clear — the same cyberpunk MenuButton as the tab bar. const c = config.section('save.colors', {}); const btnCfg = config.section('save.subBar.button', {}); this._confirmBtn = new MenuButton( this.scene, cx, cy + 34, config.get('shopui.buttons.confirmLabel', 'Confirm'), () => this.confirm(), { screenFixed: true, fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12, letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.confirmAccent', '#7ce8a4')), }, ); this._confirmBtn.setAlpha?.(0); content.add?.(this._confirmBtn); this._clearBtn = new MenuButton( this.scene, cx, cy + 104, config.get('shopui.buttons.clearLabel', 'Clear'), () => this.clear(), { screenFixed: true, fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12, letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.clearAccent', '#7d92c4')), }, ); this._clearBtn.setAlpha?.(0); content.add?.(this._clearBtn); this._content = content; this.add?.(content); // Data + live preview wiring. sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy }); sellGrid.onSelectionChange = () => this._syncPreview(); buyGrid.onSelectionChange = () => this._syncPreview(); this._syncPreview(); // Reveal: SELL slides in from the left, BUY from the right. sellGrid.slideIn(); buyGrid.slideIn(); } // --------------------------------------------------------------------- // CONTENT — the buyback panel (goods sold this session, buy back) // --------------------------------------------------------------------- _showBuyback() { const W = this._W(); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); content.setScrollFactor?.(0); const rows = this._bbRows(); const contentY = this._H() / 2; const cx = W / 2; const title = this._makeText(cx, contentY + 10, 'BUY BACK', { fontSize: '21px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('header') }); // Ethnocentric — section face content.add?.(title); let y = contentY + 56; const rowH = 56; for (const r of rows) { const line = this._makeText(cx - 120, y, `${r.name} · sold ${r.qty} @ ${Math.round(r.price)} cr`, { fontSize: '15px', color: '#eaf6ff', align: 'left' }); content.add?.(line); const minus = this._makeText(cx + 60, y, '−', { fontSize: '20px', color: '#7d92c4', align: 'center' }); const qty = this._makeText(cx + 110, y, String(r.sel), { fontSize: '20px', color: '#00e5ff', align: 'center' }); const plus = this._makeText(cx + 160, y, '+', { fontSize: '20px', color: '#7d92c4', align: 'center' }); for (const el of [line, minus, qty, plus]) content.add?.(el); const hit = [-16, -14, 32, 28]; this._bindTap(minus, () => this.bumpBuyback(r.id, -1), hit); this._bindTap(plus, () => this.bumpBuyback(r.id, +1), hit); y += rowH; } const cy = y + 24; this._previewLabel = this._makeText(cx, cy - 8, config.get('shopui.preview.label', 'NET'), { fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center' }); content.add?.(this._previewLabel); this._previewText = this._makeText(cx, cy + 22, 'NET 0 cr', { fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center' }); content.add?.(this._previewText); const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54); this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4')); this._confirmBtn.setPosition(cx, cy + 92); this._confirmBtn.tap(() => this.confirm()); this._confirmBtn.setAlpha(0); content.add?.(this._confirmBtn.container); this._content = content; this.add?.(content); this._syncPreview(); } _bbRows() { return this.soldThisSession.map((r) => ({ id: r.id, name: r.name ?? r.id, qty: r.qty, price: r.price, sel: this.buybackSel.get(r.id) ?? 0, })); } bumpBuyback(id, d = 1) { const row = this.soldThisSession.find((r) => r.id === id); if (!row) return this; const cur = this.buybackSel.get(id) ?? 0; const next = Math.max(0, Math.min(row.qty, cur + Math.floor(Number(d) || 0))); if (next === 0) this.buybackSel.delete(id); else this.buybackSel.set(id, next); this._syncPreview(); return this; } getBuybackMap() { const m = {}; for (const [id, q] of this.buybackSel) if (q > 0) m[id] = q; return m; } // --------------------------------------------------------------------- // PREVIEW + CONFIRM + CLEAR (the testable core) // --------------------------------------------------------------------- preview() { const cargo = this.ship.cargo ? this.ship.cargo.snapshot() : []; const capacity = this.capacity; const credits = this.ship.credits; if (this.tab === 'trade') { return E.tradePreview({ cls: this.cls, systemId: this.systemId, galaxy: this.galaxy, cargo, capacity, credits, sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {}, buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {}, }); } // buyback — priced at the sale price (buyOverrides), a neutral undo. const buyMap = {}; const buyOverrides = {}; for (const r of this._bbRows()) { if (r.sel > 0) { buyMap[r.id] = r.sel; buyOverrides[r.id] = r.price; } } return E.tradePreview({ cls: this.cls, systemId: this.systemId, galaxy: this.galaxy, cargo, capacity, credits, sellMap: {}, buyMap, buyOverrides, }); } confirm() { const pv = this.preview(); if (!pv.valid) { this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true); return false; } this._applyTrade(pv); this._afterTrade(pv); return true; } clear() { if (this.tab === 'trade') { this.sellGrid?.clearSelection(); this.buyGrid?.clearSelection(); } else { this.buybackSel = new Map(); } this._syncPreview(); return this; } _applyTrade(pv) { const ship = this.ship; if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal); if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal); for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id)); } _afterTrade(pv) { if (this.tab === 'trade') { for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name); this.sellGrid?.clearSelection(); this.buyGrid?.clearSelection(); } else { for (const b of pv.buy) this._reduceSold(b.id, b.qty); this.buybackSel = new Map(); } this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); this.refreshHud(); this._syncPreview(); this._refreshTabs(); this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr → hold ${pv.cargoAfter}/${this.capacity}`, false); } _addSold(id, qty, price, name) { let row = this.soldThisSession.find((r) => r.id === id); if (!row) { row = { id, name: name ?? id, qty: 0, price: price ?? 0 }; this.soldThisSession.push(row); } row.qty += qty; if (price) row.price = price; } _reduceSold(id, qty) { const row = this.soldThisSession.find((r) => r.id === id); if (!row) return; row.qty -= qty; if (row.qty <= 0) this.soldThisSession = this.soldThisSession.filter((r) => r.id !== id); } refreshHud() { this.gameScene?.refreshCreditsHud?.(); } // --------------------------------------------------------------------- // PREVIEW SYNC (live NET + Confirm/Clear visibility) // --------------------------------------------------------------------- _syncPreview() { const pv = this.preview(); const anySel = this._hasAnySelection(); if (this._previewText && typeof this._previewText.setText === 'function') { const sign = pv.net >= 0 ? '+' : ''; this._previewText.setText(`NET ${sign}${Math.round(pv.net)} cr`); const col = pv.net === 0 ? config.get('shopui.preview.zeroColor', '#7d92c4') : (pv.net > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f')); if (typeof this._previewText.setColor === 'function') this._previewText.setColor(col); } const confirmOn = anySel; const confirmEnabled = anySel && pv.valid; // MenuButton: hide (alpha) when no selection, and GRAY (setDisabled) while // the trade is invalid — a disabled button swallows its own clicks. if (this._confirmBtn) { this._confirmBtn.setAlpha?.(confirmOn ? 1 : 0); this._confirmBtn.setDisabled?.(!confirmEnabled); } this._clearBtn?.setAlpha?.(confirmOn ? 1 : 0); } _hasAnySelection() { if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false); return [...this.buybackSel.values()].some((q) => q > 0); } _setStatus(msg, bad) { // Best-effort status line (headless no-ops). The credits HUD count-up is // the primary feedback; this is the in-panel confirmation. if (this._statusText && typeof this._statusText.setText === 'function') { this._statusText.setText(msg); if (typeof this._statusText.setColor === 'function') this._statusText.setColor(bad ? config.get('shopui.preview.negColor', '#ff2d6f') : config.get('shopui.preview.posColor', '#7ce8a4')); } } // --------------------------------------------------------------------- // LIFECYCLE // --------------------------------------------------------------------- close() { this._destroyContent(); this.destroy(); if (typeof this.onLeave === 'function') this.onLeave(); return this; } _destroyContent() { if (this._content) { this._content.destroy?.(); this._content = null; } this.sellGrid?.destroy?.(); this.buyGrid?.destroy?.(); this.sellGrid = null; this.buyGrid = null; this._previewText = null; this._previewLabel = null; this._confirmBtn = null; this._clearBtn = null; } destroy() { this._destroyContent(); this._tabTrade?.destroy?.(); this._tabBuyback?.destroy?.(); if (typeof super.destroy === 'function') super.destroy(); } // --------------------------------------------------------------------- // SMALL HELPERS (Phaser-agnostic; the headless fake provides add.text/ // graphics and the stub Container; real Phaser provides the rest) // --------------------------------------------------------------------- _W() { return this.scene.scale?.width ?? 1280; } _H() { return this.scene.scale?.height ?? 720; } _makeText(x, y, str, style = {}) { // Owned by whichever container the caller adds it to (or this) — real // Phaser tears the whole tree down on destroy(); the headless stub doesn't // recurse, but the tests build a fresh scene each time so that's fine. const t = this.scene.add?.text?.(x, y, str, { align: 'center', fontFamily: fontStack('body'), ...style }); // Centauri default; callers may override if (t) t.setOrigin?.(0.5, 0.5); return t; } _makeButton(label, w, h, accent) { const g = this.scene.add?.graphics?.(); const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('body') }); const btn = new Phaser.GameObjects.Container(this.scene, 0, 0); if (g) { this._drawBtn(g, w, h, accent); btn.add?.(g); } if (t) { t.setOrigin?.(0.5, 0.5); btn.add?.(t); } const api = { container: btn, setPosition: (x, y) => { btn.setPosition?.(x, y); }, setAlpha: (a) => { btn.setAlpha?.(a); }, setAccent: (color) => { this._drawBtn(g, w, h, color); }, tap: (fn) => { this._bindTap(g ?? t, fn, [-w / 2, -h / 2, w, h]); }, destroy: () => { g?.destroy?.(); t?.destroy?.(); btn.destroy?.(); }, }; return api; } _drawBtn(g, w, h, accent) { if (!g || typeof g.clear !== 'function') return; g.clear?.(); if (typeof g.fillStyle === 'function') { const a = _hex(accent ?? '#00e5ff'); g.fillStyle(a, 0.16); g.lineStyle?.(1, a, 1); } g.fillRect?.(-w / 2, -h / 2, w, h); } _bindTap(obj, fn, hitRect) { if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return; // Phaser v4 (Giedi): a bare setInteractive() leaves no hitAreaCallback and // crashes the input (MenuButton sets an explicit hitArea + callback for this). const opts = { useHandCursor: true }; const Rect = Phaser.Geom?.Rectangle; if (Rect && hitRect) { const area = new Rect(hitRect[0], hitRect[1], hitRect[2], hitRect[3]); opts.hitArea = area; opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py); } obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); }); } } function _hex(v) { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; }