/** * ScoutPanel — the shop's GOODS SCOUT: the strategy view that replaces the * cargo grid when the player taps a BUY-list row (outside the stepper). * * ┌────────────────────────────┐ * │ TRADE SCOUT ✕ │ * │ ┌────┐ Refined Fuel │ * │ │icon│ ENERGY & FUEL · T2 │ * │ └────┘ BUY HERE 9 cr │ * │ The lifeblood… │ * │ ────────────────────────── │ * │ GROWN ON Gas Giant │ * │ SIGHT ON Rocky · Ice … │ * │ ────────────────────────── │ * │ BEST EXITS — CHARTED │ * │ ① New Concord Gas 41 cr │ * │ ② … │ * │ ────────────────────────── │ * │ TAP ANYWHERE TO CLOSE │ * └────────────────────────────┘ * * The DATA (which planets want the good, the top-N charted exits + profit) * is computed by js/economy/TradeScout.js (pure). This component only * renders that report in the panel's w×h box and reports taps back to the * ShopView (tap anywhere = close — the ShopView restores the cargo grid). * * Headless-safe: the Phaser rendering is best-effort (guarded); the layout * math is fixed offsets so a stub scene still constructs. */ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; import { fontStack } from '../../utils/Theme.js'; import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js'; export class ScoutPanel extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). * @param {object} o — { width, height, onClose } (ShopView sizes it to the * SELL grid's box; onClose fires on any tap of the panel). */ constructor(scene, o = {}) { super(scene, 0, 0); this.scene = scene; const s = config.section('shopui.scout', {}); this.width = Math.max(300, Number(o.width) || 446); this.height = Math.max(240, Number(o.height) || 480); this.pad = s.pad ?? 18; this.onClose = typeof o.onClose === 'function' ? o.onClose : null; this._objs = []; // shell objects (plate, title, tap layer) this._content = []; // per-show content (rebuilt by show()) this._data = null; // the last scoutGood() report this._buildShell(); this._buildTapLayer(); // LAST — the tap rect sits on top this.setAlpha?.(0); // hidden until show() + reveal() } get data() { return this._data; } // ------------------------------------------------------------------ shell _buildShell() { const s = config.section('shopui.scout', {}); const p = config.section('shopui.panel', {}); const gr = this.scene.add?.graphics?.(); if (gr) { this.plate = gr; this._objs.push(gr); gr.clear?.(); if (typeof gr.fillStyle === 'function') { gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), (p.bgAlpha ?? 0.7) + 0.15); // a touch more opaque than the sides gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), 255); } gr.fillRect?.(0, 0, this.width, this.height); this.add?.(gr); } const t = s.title ?? {}; const title = this.scene.add?.text?.( this.pad, 10, String(t.label ?? 'TRADE SCOUT'), { fontSize: `${t.fontSize ?? 18}px`, color: t.color ?? '#eaf6ff', align: 'left', fontFamily: fontStack('header'), letterSpacing: 2 }, ); if (title) { title.setOrigin?.(0, 0); this.title = title; this._objs.push(title); this.add?.(title); } const close = this.scene.add?.text?.( this.width - this.pad, 12, 'TAP TO CLOSE', { fontSize: '10px', color: '#3d4c74', align: 'right' }, ); if (close) { close.setOrigin?.(1, 0); this._objs.push(close); this.add?.(close); } } // Tap ANYWHERE on the panel closes it (the "click anywhere else" rule — // the ShopView listens and restores the cargo grid). _buildTapLayer() { const g = this.scene.add?.graphics?.(); if (!g) return; this._tapLayer = g; this._objs.push(g); this.add?.(g); if (typeof g.setInteractive !== 'function' || typeof g.on !== 'function') return; const Rect = Phaser.Geom?.Rectangle; const area = Rect ? new Rect(0, 0, this.width, this.height) : null; g.setInteractive({ useHandCursor: false, hitArea: area, hitAreaCallback: (p, px, py) => (area ? Rect.Contains(p, px, py) : true), }); g.on('pointerdown', (p) => { p?.stopPropagation?.(); this.onClose?.(); }); } // ---------------------------------------------------------------- content /** * Render a scoutGood() report (js/economy/TradeScout.js). Rebuilds the * content (the panel shell persists). Pure layout — no pricing here. */ show(d) { if (!d || typeof d.id !== 'string') return this; this._data = d; this._destroyContent(); const s = config.section('shopui.scout', {}); const W = this.width; const pad = this.pad; const add = (el) => { if (el) { this._content.push(el); this.add?.(el); } return el; }; const text = (x, y, str, style) => { const t = this.scene.add?.text?.(x, y, String(str ?? ''), { align: 'left', fontFamily: fontStack('body'), ...style }); return t ?? null; }; // --- identity block: icon + name + meta + local prices --------------- const iconPx = s.icon ?? 88; const iconCx = pad + iconPx / 2; const iconCy = 46 + iconPx / 2; if (this.scene.textures && typeof this.scene.textures.exists === 'function' && this.scene.textures.exists(GOODS_TEXTURE_KEY)) { const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0); if (icon) { icon.setOrigin?.(0.5, 0.5); icon.setScale?.(iconPx / 128); // the sheet's frames are 128×128 icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(d.id)); icon.setPosition?.(iconCx, iconCy); add(icon); } } const tx = pad + iconPx + 16; const nm = s.name ?? {}; add(text(tx, 58, d.name, { fontSize: `${nm.fontSize ?? 19}px`, color: nm.color ?? '#eaf6ff', fontFamily: fontStack('header') })).setOrigin?.(0, 0); const cat = d.category ? (config.get(`goods.categories.${d.category}.label`, d.category)) : ''; const meta = [cat, d.tier ? `TIER ${d.tier}` : null].filter(Boolean).join(' · '); const mt = s.meta ?? {}; add(text(tx, 84, meta, { fontSize: `${mt.fontSize ?? 11}px`, color: mt.color ?? '#7d92c4' })).setOrigin?.(0, 0); // local prices (the baseline the exits' profits are measured against) const pb = s.priceBuy ?? {}; const priceStr = d.localSell != null ? `BUY HERE ${d.localBuy} CR · SELLS HERE ${d.localSell} CR` : `BUY HERE ${d.localBuy} CR · NOT BOUGHT HERE`; // ONE wrapped line: the "not bought here" variant is wider than the // panel at 446px, so wrap instead of running into the buy list. const priceT = add(text(tx, 108, priceStr, { fontSize: `${pb.fontSize ?? 12}px`, color: pb.color ?? '#ffc94d' })); priceT?.setOrigin?.(0, 0); priceT?.setWordWrap?.(true); priceT?.setWordWrapWidth?.(W - tx - pad); // flavor (full width, under the identity block) const fl = s.flavor ?? {}; const flavor = add(text(pad, 150, d.flavor || '—', { fontSize: `${fl.fontSize ?? 12}px`, color: fl.color ?? '#7d92c4', })); flavor?.setOrigin?.(0, 0); flavor?.setWordWrap?.(true); flavor?.setWordWrapWidth?.(W - pad * 2); // --- divider + class sections ---------------------------------------- const y0 = 186; this._divider(pad, y0, W - pad * 2); const sec = s.section ?? {}; const label = (y, str) => { const t = add(text(pad, y, str.toUpperCase(), { fontSize: `${sec.fontSize ?? 10}px`, color: sec.color ?? '#3d4c74' })); t?.setOrigin?.(0, 0); return t; }; const chips = (y, list) => { const t = list?.length ? add(text(pad + 120, y, list.map((c) => c.label).join(' · '), { fontSize: `${(s.chip ?? {}).fontSize ?? 11}px`, color: (s.chip ?? {}).color ?? '#eaf6ff' })) : add(text(pad + 120, y, '—', { fontSize: '11px', color: '#3d4c74' })); t?.setOrigin?.(0, 0); // keep it INSIDE the panel — a 4-class demand list is wider than the // chip column, so wrap rather than run into the buy list. t?.setWordWrap?.(true); t?.setWordWrapWidth?.(W - (pad + 120) - pad); return t; }; let y = y0 + 22; label(y, 'Grown on'); const pro = chips(y, d.producer); y += (pro?.height ?? 14) + 10; label(y, 'Sought on'); const dem = chips(y, d.demand); // the best demand class's economy line — below the (possibly wrapped) chip if (d.demand?.length) { const chipH = dem?.height ?? 14; const eco = add(text(pad + 120, y + chipH + 6, d.demand[0].economy, { fontSize: '11px', color: '#7d92c4' })); eco?.setOrigin?.(0, 0); eco?.setWordWrap?.(true); eco?.setWordWrapWidth?.(W - pad * 2 - 120); y += chipH + 6 + (eco?.height ?? 14) + 10; } else { y += 26; } // --- BEST EXITS — the charted systems ranked by sell price ------------ this._divider(pad, y, W - pad * 2); y += 20; label(y, `Best exits — charted (${d.exits.length ? 'top ' + d.exits.length : 'none yet'})`); y += 24; const er = s.exitRow ?? {}; const ranks = s.ranks ?? ['#ffc94d', '#c8d4e8', '#d09058']; if (d.exits.length === 0) { const empty = add(text(pad, y, s.empty ?? 'No charted exit yet — every CAPITAL buys everything.', { fontSize: '12px', color: '#7d92c4' })); empty?.setOrigin?.(0, 0); empty?.setWordWrap?.(true); empty?.setWordWrapWidth?.(W - pad * 2); } else { const rowH = er.h ?? 40; const nameX = pad + 22; const barMax = W - pad * 2 - 150; // room for the price/profit block d.exits.forEach((e, i) => { // rank medal const rank = add(text(pad + 4, y + 8, String(i + 1), { fontSize: '15px', color: ranks[i] ?? '#c8d4e8', fontFamily: fontStack('header') })); rank?.setOrigin?.(0, 0); // system name + its class const nm2 = add(text(nameX, y + 4, e.systemName, { fontSize: `${er.fontSize ?? 13}px`, color: er.color ?? '#eaf6ff' })); nm2?.setOrigin?.(0, 0); const cl = add(text(nameX, y + 20, `${e.clsLabel}${e.hops != null ? ` · ${e.hops} HOPS` : ''}`, { fontSize: '10px', color: '#7d92c4' })); cl?.setOrigin?.(0, 0); // price + profit (right block) const pe = s.exitPrice ?? {}; const price = add(text(W - pad, y + 6, `${e.price} CR`, { fontSize: `${pe.fontSize ?? 13}px`, color: pe.color ?? '#7ce8a4' })); price?.setOrigin?.(1, 0); const pf = s.exitProfit ?? {}; const pos = e.profit >= 0; const profit = add(text(W - pad, y + 24, `${pos ? '+' : '−'}${Math.abs(e.profit)}/UNIT`, { fontSize: `${pf.fontSize ?? 11}px`, color: pos ? (pf.posColor ?? '#7ce8a4') : (pf.negColor ?? '#ff2d6f') })); profit?.setOrigin?.(1, 0); // the profit bar — width ∝ profit (against the best exit) if (e.profit > 0 && d.maxProfit > 0) { const bar = this.scene.add?.graphics?.(); if (bar && typeof bar.fillStyle === 'function') { const bw = Math.max(6, Math.round(barMax * (e.profit / d.maxProfit))); const by = y + 33; bar.fillStyle(_hex(s.barColor ?? '#7ce8a4'), 0.18); bar.fillRect(nameX, by, bw + 6, 4); // glow underlay bar.fillStyle(_hex(s.barColor ?? '#7ce8a4'), 0.8); bar.fillRect(nameX, by + 1, bw, 2); // core add(bar); } } y += rowH; }); } // --- footer hint ------------------------------------------------------- const foot = add(text(W / 2, this.height - 14, s.footer ?? 'TAP ANYWHERE TO RETURN TO CARGO', { fontSize: '10px', color: '#3d4c74', align: 'center' })); foot?.setOrigin?.(0.5, 1); foot?.setWordWrap?.(true); foot?.setWordWrapWidth?.(W - pad * 2); return this; } _divider(x, y, w) { const g = this.scene.add?.graphics?.(); if (!g || typeof g.fillRect !== 'function') return; g.fillStyle(_hex('#22405f'), 0.9); g.fillRect(x, y, w, 1); // the neon tick on the left (the console's signature) g.fillStyle(_hex('#00e5ff'), 0.5); g.fillRect(x, y, 26, 1); this._content.push(g); this.add?.(g); } // ------------------------------------------------------------- animation /** Fade the panel in (instant on a stub scene without tweens). */ reveal() { if (this.scene.tweens && typeof this.scene.tweens.add === 'function') { this.setAlpha?.(0); this.scene.tweens.add({ targets: this, alpha: 1, duration: config.get('shopui.scout.revealMs', 140), ease: 'Cubic.Out' }); } else { this.setAlpha?.(1); } return this; } hide() { this.setAlpha?.(0); return this; } _destroyContent() { for (const o of this._content) { // In this Giedi build container children live in `.list`; `remove(o, true)` // pulls the child out of the container AND destroys it. Without the // remove, destroyed objects would accumulate in the container's child // list on every show(). (Falls back to plain destroy on the stub.) if (o && typeof this.remove === 'function') this.remove(o, true); else o?.destroy?.(); } this._content = []; } destroy() { this._destroyContent(); for (const o of this._objs) o?.destroy?.(); this._objs = []; if (typeof super.destroy === 'function') super.destroy(); } } // '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed. function _hex(v) { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; }