orbit/js/ui/shop/ScoutPanel.js

326 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}