326 lines
14 KiB
JavaScript
326 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|