orbit/js/ui/shop/ScoutPanel.js

363 lines
16 KiB
JavaScript
Raw Permalink 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 │
* │ └────┘ 9 CR │
* │ The lifeblood… │
* │ ────────────────────────── │
* │ MADE IN New Concord … │ ← model v2: the ONE ORIGIN system
* │ SOUGHT ON Rocky · Ice … │ (the world that makes it, daily batch)
* │ ────────────────────────── │
* │ BEST EXITS │
* │ ① New Concord 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
* │ ② … │ (the scene's ConfirmOverlay)
* └────────────────────────────┘
*
* 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;
* tap a BEST-EXIT row = onExitTap(exit) — the scene offers to SET it as
* the destination).
*
* 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, onExitTap } (ShopView sizes
* it to the SELL grid's box; onClose fires on any tap of the panel;
* onExitTap(exit) fires when a BEST-EXIT row is tapped — the scene
* offers the "Set Destination" popup for that system).
*/
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.onExitTap = typeof o.onExitTap === 'function' ? o.onExitTap : 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._exitRows = []; // per-show best-exit rows { exit, hit, onTap }
this._buildShell();
this._buildTapLayer(); // LAST — the tap rect sits on top
this.setAlpha?.(0); // hidden until show() + reveal()
}
// NOTE: this accessor is NOT named `data` — Phaser's GameObject reserves
// `.data` (its Data manager), and GameObject.destroy() calls
// `this.data.destroy()`. A plain report object there would crash the
// destroy pass ("this.data.destroy is not a function").
get report() { 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();
this._exitRows = [];
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);
// the buy price — the baseline the exits' profits are measured against.
// Credits only; no "bought/sells here" wording.
const pb = s.priceBuy ?? {};
const priceT = add(text(tx, 108, `${d.localBuy} CR`, { fontSize: `${pb.fontSize ?? 12}px`, color: pb.color ?? '#ffc94d' }));
priceT?.setOrigin?.(0, 0);
// 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;
// THE ORIGIN (model v2) — the ONE system that makes this good (its
// world's shop sells it cheap, in a daily batch). Hub-pool goods have
// no origin world — they ride the station/capital reseller network.
// The producer classes ride along as the "made of" hint (planet type
// — the secondary fit signal, the world's thematic family).
label(y, 'Made in');
const originChips = [
{ label: d.origin?.systemName ?? (d.origin?.hub ? 'HUB RESALE' : '—') },
...(Array.isArray(d.producer) ? d.producer : []).map((c) => ({ label: c.label })),
];
const pro = chips(y, originChips);
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');
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 + the WORLD it pays at (its name + hops — the class
// label is dropped: the world name is what the destination points at)
const nm2 = add(text(nameX, y + 4, e.systemName, { fontSize: `${er.fontSize ?? 13}px`, color: er.color ?? '#eaf6ff' }));
nm2?.setOrigin?.(0, 0);
const parts = [e.objectName, e.hops != null ? `${e.hops} HOPS` : null].filter(Boolean);
const cl = add(text(nameX, y + 20, parts.join(' · '), { 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);
}
}
// the row's TAP SURFACE — "SET DESTINATION" for this exit's WORLD
// (its planet — or station / the home world — the route's final
// stop). Built during show() (AFTER the panel's tap layer was made
// in the constructor), so it wins the pointer over "tap anywhere
// = close"; stopPropagation keeps that layer from swallowing the
// tap. The handler is a plain function on the row record
// (dev/tests can fire it without a real pointer —
// ScoutPanel._exitRows[i].onTap()).
const hit = this.scene.add?.graphics?.();
if (hit) {
const hx = pad - 6, hy = y - 4, hw = W - pad * 2 + 12, hh = rowH;
const onTap = (p) => { p?.stopPropagation?.(); this.onExitTap?.(e); };
if (typeof hit.setInteractive === 'function' && typeof hit.on === 'function') {
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(hx, hy, hw, hh) : null;
hit.setInteractive({
useHandCursor: true,
hitArea: area,
hitAreaCallback: (pp, px, py) => (area ? Rect.Contains(pp, px, py) : true),
});
hit.on('pointerdown', onTap);
}
this._exitRows.push({ exit: e, hit, onTap });
add(hit);
}
y += rowH;
});
}
// (no footer hint — the shell's top-right "TAP TO CLOSE" already says
// it, and the line collided with the last exit row in a short panel;
// less text = less overlap in this box)
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;
}