orbit/js/ui/QuestTrackerHud.js

646 lines
23 KiB
JavaScript

/**
* QuestTrackerHud — the quest tracker that slides in right below the
* system dossier (top-left), the moment the dossier has folded:
*
* ┌ SYSTEM NAME ▸ (the dossier — folded to its title)
* │
* │ QUESTS ▁▁▁▁▁ (green section title — always visible)
* │ ◆ START YOUR JOURNEY 75% (the ONE priority quest — its
* │ ✓ RESEARCH TETHER LEVEL 2 checklist renders UNDER its
* │ · MINE 200 MINERALS… — 120/200 title and tracks LIVE)
* │ · MINE THE BELT 33% (the rest: plain titles, main story
* │ …up to maxVisible … first, then side quests)
* │ [ SHOW MORE ] (only when MORE than maxVisible are active
* └ — opens the QUESTS console on the first
* hidden quest)
*
* Or, with nothing tracked: QUESTS / "NONE TRACKED" (grayed out).
*
* Lifecycle (driven by GameScene — see GameScene.updateHud /
* startExpand): the tracker is VISIBLE ⇔ the dossier is FOLDED, armed by
* the dossier's FIRST fold (the one-shot auto-fold, or the player's
* manual fold — either is the first fold). When the dossier re-opens the
* tracker SLIDES OFF (offscreen left) first, then the dossier expands;
* when the dossier folds again the tracker slides back in. The slide is
* a ~320 ms ease-out translate (data/quests.json → tracker.slideMs).
*
* The tracker is a VIEW — the same live split as the QUESTS console:
* the scene computes the snapshot (GameScene.questSnapshot — progress is
* LIVE: research, mined, tether, discovery) and owns the effects
* (set/clear the priority on the quest ledger, open the console). It
* polls every frame and repaints only when the fingerprint changes, so
* the checklist ticks over as tasks complete.
*
* The ONE priority quest (js/quests/QuestState.js): the player sets it
* (a row's popup → SET PRIORITY, or the console's SET PRIORITY) and
* clears it (row popup → CLEAR PRIORITY); otherwise the first active
* story quest is the priority (ledger auto-assignment), and claiming it
* auto-advances to the next story/side quest.
*
* A row click opens the row's POPUP (a small cut-corner panel, the
* mining menu's contract): a click on a button is the button's (Menu-
* Button's own pointerdown); any click elsewhere closes the popup and
* that click is consumed — the scene's pointerdown enforces the
* contract (GameScene: contains() / popupContains() below).
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER = fontStack('header', FONT_FALLBACK);
const BODY = fontStack('body', FONT_FALLBACK);
/** The tracker's palette — green is the tracker's accent (data/quests.json). */
const C = {
green: themeColor('green', 0x67e863),
ink: themeColor('ink', 0xeaf6ff),
dim: themeColor('dim', 0x7d92c4),
faint: themeColor('faint', 0x3d4c74),
panel: themeColor('panel', 0x0a1120),
};
/** Display order: main story first, then side quests, then anything else. */
function catRank(q) {
return q?.category === 'main' ? 0 : q?.category === 'side' ? 1 : 2;
}
/**
* The tracker's visible rows: the first `max` in display order — except
* the priority quest, which is ALWAYS in the list (its checklist is the
* point of the tracker): when it would sit past the cap it takes the
* last slot (original order is preserved).
*/
function pickVisible(active, priorityId, max) {
let vis = active.slice(0, max);
if (priorityId && !vis.some((q) => q.id === priorityId)) {
const p = active.find((q) => q.id === priorityId);
if (p) {
vis = active.slice(0, Math.max(0, max - 1));
vis.push(p);
vis.sort((a, b) => active.indexOf(a) - active.indexOf(b));
}
}
return vis;
}
export class QuestTrackerHud {
/**
* @param {Phaser.Scene} scene the owning scene (GameScene — the tracker
* does not exist on the surface)
* @param {object} o
* @param {number} o.anchorX — the left edge (the dossier's X)
* @param {number} o.anchorY — the top edge (just below the dossier title)
* @param {() => object|null} o.getSnapshot — the live dossier snapshot
* (GameScene.questSnapshot; polled every frame)
* @param {(activeIds: string[]) => string|null} o.resolvePriority —
* the ledger's priority resolution (GameScene.questState.resolvePriority)
* @param {(id: string) => void} [o.onSetPriority] — SET PRIORITY pressed
* @param {() => void} [o.onClearPriority] — CLEAR PRIORITY pressed
* @param {(id: string) => void} [o.onViewQuest] — VIEW QUEST pressed
* @param {(id: string) => void} [o.onShowMore] — SHOW MORE pressed (the
* first hidden quest)
*/
constructor(scene, o = {}) {
this.scene = scene;
this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null;
this.resolvePriority = typeof o.resolvePriority === 'function' ? o.resolvePriority : null;
this.onSetPriority = typeof o.onSetPriority === 'function' ? o.onSetPriority : null;
this.onClearPriority = typeof o.onClearPriority === 'function' ? o.onClearPriority : null;
this.onViewQuest = typeof o.onViewQuest === 'function' ? o.onViewQuest : null;
this.onShowMore = typeof o.onShowMore === 'function' ? o.onShowMore : null;
const cfg = config.section('quests.tracker', {});
this.maxVisible = Math.max(1, Math.floor(cfg.maxVisible ?? 5));
this.slideMs = Math.max(80, Math.floor(cfg.slideMs ?? 320));
this.X = Math.max(8, Math.floor(o.anchorX ?? 16));
this.Y0 = Math.max(30, Math.floor(o.anchorY ?? 40));
this.rowW = 250; // the row plates' width (the percent sits at their right edge)
this._offX = -(this.rowW + 60); // offscreen-left (the slide's start/end)
// Everything lives in ONE container — the slide is a single x
// translate of the container (at (0,0) at rest, so local == screen).
this.root = new Phaser.GameObjects.Container(scene, 0, 0);
scene.add.existing(this.root); // v4: a directly-constructed GameObject must register
this.root.setScrollFactor(0); // UI — pinned to the screen
this.root.setDepth(30); // with the dossier and the other corner readouts
this.root.setVisible(false);
// --- The section title: green, ALWAYS there while the HUD exists ---
this.title = scene
.add.text(this.X, this.Y0, String(cfg.title ?? 'QUESTS').toUpperCase(), {
fontFamily: HEADER,
fontSize: '12px',
color: toCss(C.green),
fontStyle: 'bold',
letterSpacing: 3,
})
.setOrigin(0, 0)
.setScrollFactor(0);
this.root.add(this.title);
this.rule = scene.add.graphics().setScrollFactor(0);
this.rule.clear();
this.rule.lineStyle(1, C.green, 0.45);
this.rule.lineBetween(this.X, this.Y0 + 18, this.X + 54, this.Y0 + 18);
this.root.add(this.rule);
// The dynamic content (rows / checklist / none / show-more) + the
// row popup — repainted on fingerprint change.
this._kids = []; // the current paint's scene objects (torn down on repaint)
this._rowRects = []; // { id, x, y, w, h } — the clickable rows
this._rowRectById = new Map();
this._moreRect = null; // the SHOW MORE button's rect
this._popup = null; // { questId, rect, sourceRect, openedAt, btns, obj }
this._lastPopup = null; // { rect, at } — the just-closed popup (the same-frame guard)
this._fp = null; // the last painted fingerprint
this._priorityId = null; // the last resolved priority (for the popup)
this.state = 'hidden'; // hidden | showing | shown | hiding
this._destroyed = false;
this.root.setPosition(this._offX, 0); // start offscreen-left
this.update(scene.time.now); // first paint (hidden — it slides in when the dossier folds)
}
// ------------------------------------------------------------ lifecycle
/** Is a slide in flight? (the scene guards the dossier toggle on this) */
isBusy() {
return this.state === 'showing' || this.state === 'hiding';
}
/** Occupies the screen (fully in, or still sliding in). */
get shown() {
return this.state === 'shown' || this.state === 'showing';
}
/** Slide IN from offscreen-left (the dossier just folded). */
show(now) {
if (this.state === 'shown' || this.state === 'showing') return;
this.closePopup();
this.state = 'showing';
this.root.setVisible(true);
this.root.setX(this._offX);
this._slide(0, () => {
this.state = 'shown';
});
}
/**
* Slide OFF to offscreen-left (the dossier is about to re-open — make
* room BEFORE it unfolds). `onDone` fires when the slide lands (or
* immediately when already hidden) — the scene starts the dossier
* expand from there.
*/
hide(now, onDone) {
if (this.state === 'hidden') {
if (typeof onDone === 'function') onDone();
return;
}
if (this.state === 'hiding') return; // the in-flight hide fires ITS onDone
this.closePopup();
this.state = 'hiding';
this._slide(this._offX, () => {
this.root.setVisible(false);
this.state = 'hidden';
if (typeof onDone === 'function') onDone();
});
}
/** One slide (a single x tween of the root container). */
_slide(toX, after) {
// Kill any in-flight slide first (a show→hide race leaves two tweens
// fighting over the same x — killTweensOf is the codebase's pattern).
this.scene.tweens.killTweensOf(this.root);
this.scene.tweens.add({
targets: this.root,
x: toX,
duration: this.slideMs,
ease: 'Sine.easeOut',
onComplete: () => {
if (typeof after === 'function') after();
},
});
}
// ------------------------------------------------------------ painting
/**
* Per-frame (GameScene.update): poll the live snapshot, resolve the
* priority, and repaint only when the fingerprint changes (a check
* completing, a percent ticking, the priority moving, a quest
* claimed/granted). The slide state is tween-driven, not polled.
*/
update(_time) {
let snap = null;
try {
snap = this.getSnapshot ? this.getSnapshot() : null;
} catch {
snap = null;
}
const quests = Array.isArray(snap?.quests) ? snap.quests : [];
// ACTIVE = held + not claimed, display order: main story first, then
// side quests (stable within a category — the data file's order).
const active = quests
.filter((q) => q && !q.claimed)
.sort((a, b) => catRank(a) - catRank(b));
const ids = active.map((q) => q.id);
let priorityId = null;
if (this.resolvePriority) {
try {
priorityId = this.resolvePriority(ids) ?? null;
} catch {
priorityId = null;
}
}
const visible = pickVisible(active, priorityId, this.maxVisible);
const overflowFirst = active.length > visible.length ? active[visible.length] : null;
const fp = JSON.stringify({
p: priorityId,
a: ids,
c: active.map((q) => {
const checks = q.checks ?? [];
return (
checks.map((c) => (c?.done ? 1 : 0)).join('') +
'|' +
checks.map((c) => String(c?.detail ?? '')).join(',').slice(0, 40)
);
}),
o: overflowFirst ? overflowFirst.id : null,
});
if (fp === this._fp) return;
this._fp = fp;
this._priorityId = priorityId;
this._paint(active, visible, priorityId, overflowFirst);
}
/** Tear down the dynamic content (the title + rule are permanent). */
_teardown() {
for (const k of this._kids) {
try {
k.destroy();
} catch {
/* already gone */
}
}
this._kids = [];
this._rowRects = [];
this._rowRectById = new Map();
this._moreRect = null;
this.closePopup();
}
_paint(active, visible, priorityId, overflowFirst) {
this._teardown();
const s = this.scene;
const add = (obj) => {
this._kids.push(obj);
this.root.add(obj);
return obj;
};
const cfg = config.section('quests.tracker', {});
let y = this.Y0 + 32;
if (active.length === 0) {
add(
s.add
.text(this.X, y, String(cfg.noneTracked ?? 'NONE TRACKED'), {
fontFamily: BODY,
fontSize: '11px',
color: toCss(C.faint),
letterSpacing: 2,
})
.setOrigin(0, 0)
.setScrollFactor(0),
);
return;
}
for (const q of visible) {
const isPrio = q.id === priorityId;
const doneN = (q.checks ?? []).filter((c) => c?.done).length;
const total = (q.checks ?? []).length;
const pct = total > 0 ? Math.round((100 * doneN) / total) : 0;
const rx = this.X - 2;
const ry = y;
const rw = this.rowW;
const rh = 20;
// The row plate (a VISIBLE object — the v4 hit-test rule) + the
// left tick (green = the priority quest, faint = the rest).
const plate = add(s.add.graphics().setScrollFactor(0));
plate.clear();
plate.fillStyle(C.panel, 0.35);
plate.fillRect(rx, ry, rw, rh);
plate.fillStyle(isPrio ? C.green : C.faint, isPrio ? 0.95 : 0.7);
plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6);
plate.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(rx, ry, rw, rh),
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
plate.on('pointerover', () => {
if (this.shown) this.scene.playSfx?.('ui_hover');
});
plate.on('pointerdown', () => {
if (!this.shown || this.isBusy()) return;
this.scene.playSfx?.('ui_click');
this.openPopup(q, { x: rx, y: ry, w: rw, h: rh });
});
add(
s.add
.text(this.X + 8, ry + 3, String(q.title ?? '').toUpperCase(), {
fontFamily: BODY,
fontSize: '12px',
color: toCss(isPrio ? C.ink : C.dim),
letterSpacing: 1,
})
.setOrigin(0, 0)
.setScrollFactor(0),
);
add(
s.add
.text(this.X + rw, ry + rh / 2, `${pct}%`, {
fontFamily: BODY,
fontSize: '11px',
color: toCss(isPrio ? C.green : C.dim),
letterSpacing: 1,
})
.setOrigin(1, 0.5)
.setScrollFactor(0),
);
const rect = { id: q.id, x: rx, y: ry, w: rw, h: rh };
this._rowRects.push(rect);
this._rowRectById.set(q.id, rect);
y += rh + 6;
// The priority quest's CHECKLIST — under its title, LIVE (the
// fingerprint repaints as checks complete): done = a green check +
// the dim label; open = a gray bullet, the label, and the detail.
if (isPrio) {
for (const c of q.checks ?? []) {
const label = String(c?.label ?? '');
const detail = String(c?.detail ?? '');
const line = c?.done
? `${label}`
: detail && detail !== '??'
? `· ${label}${detail}`
: `· ${label}`;
add(
s.add
.text(this.X + 14, y, line, {
fontFamily: BODY,
fontSize: '11px',
color: toCss(c?.done ? C.dim : C.faint),
letterSpacing: 0.5,
})
.setOrigin(0, 0)
.setScrollFactor(0),
);
y += 16;
}
y += 4; // a breath before the next quest
}
}
// SHOW MORE — only when there is more beyond the cap (opens the
// QUESTS console on the first hidden quest — its tab is switched
// there, GameScene.openQuestAt → QuestWindow.openAt).
if (overflowFirst) {
const bw = 150;
const bh = 30;
const bx = this.X + bw / 2;
const by = y + bh / 2 + 4;
const btn = new MenuButton(s, bx, by, String(cfg.showMore ?? 'SHOW MORE'), () => {
this.onShowMore?.(overflowFirst.id);
}, {
width: bw,
fontSize: 11,
paddingX: 8,
paddingY: 6,
letterSpacing: 2,
upper: true,
screenFixed: true,
bgColor: C.panel,
hoverColor: C.panel,
textColor: toCss(C.ink),
});
btn.style.stroke = C.dim;
btn.style.neon = C.green;
btn.paint('base');
add(btn);
this._moreRect = { x: bx - bw / 2, y: by - bh / 2, w: bw, h: bh };
y += bh + 8;
}
}
// ------------------------------------------------------------ the row popup
/**
* The row's context popup (a click on a quest row): a small cut-corner
* panel to the right of the row with the quest's name and two buttons —
* SET PRIORITY (CLEAR PRIORITY, when this quest IS the priority) and
* VIEW QUEST (the QUESTS console on this quest). A click on a button
* is the button's (MenuButton's own pointerdown); any click ELSEWHERE
* closes the popup and is consumed (GameScene's pointerdown, via
* popupContains() below).
* @param {object} quest the snapshot quest (GameScene.questSnapshot shape)
* @param {{x:number,y:number,w:number,h:number}} rowRect the row clicked
*/
openPopup(quest, rowRect) {
if (!quest) return;
this.closePopup();
const s = this.scene;
const isPrio = this._priorityId === quest.id;
const W = 212;
const mk = (label, onClick) =>
new MenuButton(s, 0, 0, label, onClick, {
width: W - 24,
fontSize: 11,
paddingX: 8,
paddingY: 6,
letterSpacing: 1.5,
upper: true,
screenFixed: true,
bgColor: C.panel,
hoverColor: C.panel,
textColor: toCss(C.ink),
});
const bPrio = mk(isPrio ? 'CLEAR PRIORITY' : 'SET PRIORITY', () => {
if (isPrio) this.onClearPriority?.();
else this.onSetPriority?.(quest.id);
this.closePopup();
});
bPrio.style.stroke = isPrio ? C.faint : C.green;
bPrio.style.neon = isPrio ? C.dim : C.green;
bPrio.paint('base');
const bView = mk('VIEW QUEST', () => {
this.onViewQuest?.(quest.id);
this.closePopup();
});
bView.style.stroke = C.dim;
bView.style.neon = C.green;
bView.paint('base');
const h1 = bPrio.style.height;
const h2 = bView.style.height;
const H = 26 + 6 + h1 + 6 + h2 + 8;
// The panel plate (a cut-corner plate — CyberShape at its centre).
const g = s.add.graphics().setScrollFactor(0);
g.clear();
CyberShape.draw(g, W, H, {
notch: Math.min(12, H * 0.18),
fill: C.panel,
fillAlpha: 0.96,
stroke: isPrio ? C.faint : C.green,
strokeAlpha: 0.85,
lineWidth: 1.5,
glow: isPrio ? C.dim : C.green,
glowAlpha: 0.18,
});
g.setPosition(W / 2, H / 2);
const header = s.add
.text(10, 8, String(quest.title ?? '').toUpperCase(), {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.dim),
letterSpacing: 1.5,
})
.setOrigin(0, 0)
.setScrollFactor(0);
bPrio.setPosition(W / 2, 26 + h1 / 2);
bView.setPosition(W / 2, 26 + h1 + 6 + h2 / 2);
// Placement: right of the row (falling back to its left when the
// right side runs off the screen edge).
const sw = s.scale.width;
let px = rowRect.x + rowRect.w + 14;
if (px + W > sw - 8) px = Math.max(8, rowRect.x - W - 14);
const py = Math.max(8, rowRect.y - 4);
// ONE container — the popup slides/fades as a unit and is hit-tested
// as a whole (the scene's contract: inside = the popup's, outside =
// the close).
const cont = new Phaser.GameObjects.Container(s, px, py);
s.add.existing(cont);
cont.setScrollFactor(0);
cont.setDepth(31); // just above the tracker's own plate (30)
cont.add(g);
cont.add(header);
cont.add(bPrio);
cont.add(bView);
this._popup = {
questId: quest.id,
rect: { x: px, y: py, w: W, h: H },
sourceRect: { ...rowRect }, // the row that opened it (the scene's same-click test)
openedAt: this.scene.time.now,
btns: [bPrio, bView],
obj: cont,
};
this.scene.playSfx?.('ui_window');
}
/** Close the row popup (a no-op when it isn't up). */
closePopup() {
const p = this._popup;
this._popup = null;
if (!p) return;
this._lastPopup = { rect: p.rect, at: this.scene.time.now }; // the same-frame guard
for (const b of p.btns) {
try {
b.destroy();
} catch {
/* already gone */
}
}
try {
p.obj.destroy(true);
} catch {
/* already gone */
}
this.scene.playSfx?.('ui_close');
}
get popupOpen() {
return this._popup !== null;
}
/** Is (px,py) on the open popup's plate? (the scene's pointerdown contract) */
popupContains(px, py) {
const r = this._popup?.rect;
return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
/**
* Is (px,py) on the row that OPENED the popup? (The scene's contract:
* the opening click — same frame as openPopup — is swallowed; a later
* deliberate re-click on that row folds the popup.)
*/
sourceContains(px, py) {
const r = this._popup?.sourceRect;
return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
/** The popup was opened within `ms` of the scene's clock (same-frame test). */
popupOpenedWithin(ms) {
const at = this._popup?.openedAt;
return typeof at === 'number' && (this.scene.time.now - at) < ms;
}
/**
* Was the popup just closed (within one frame) and is (px,py) inside
* its former plate? (The scene's contract: a click the popup's OWN
* buttons just handled — they close it from their side first — is the
* popup's click, never a world click. The mining menu's guard.)
*/
closedPopupAt(px, py) {
const lp = this._lastPopup;
if (!lp || typeof lp.at !== 'number') return false;
if (this.scene.time.now - lp.at >= 40) return false;
const r = lp.rect;
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
/**
* Is (px,py) inside the tracker's footprint — a row, the SHOW MORE
* button, or the open popup? (The scene's pointerdown: inside = the
* tracker's own click, never a fly-here; the mining menu's contract.)
*/
contains(px, py) {
if (this.popupContains(px, py)) return true;
for (const r of this._rowRects) {
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return true;
}
const m = this._moreRect;
if (m && px >= m.x && px <= m.x + m.w && py >= m.y && py <= m.y + m.h) return true;
return false;
}
/** The current resolved priority (the popup's SET/CLEAR choice). */
get priorityId() {
return this._priorityId;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this.closePopup();
this._teardown();
try {
this.title.destroy();
} catch {
/* already gone */
}
try {
this.rule.destroy();
} catch {
/* already gone */
}
try {
this.root.destroy(true);
} catch {
/* already gone */
}
}
}