943 lines
35 KiB
JavaScript
943 lines
35 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).
|
|
*
|
|
* A COMPLETE-but-unclaimed quest is READY — the reward is sitting there:
|
|
* its row turns AMBER (the console's READY color — amber = claimable, the
|
|
* green accent stays the tracker's), is promoted to the console's
|
|
* CUT-CORNER plate (the CLAIM plate in miniature), the percent reads
|
|
* READY, the plate breathes (a slow ~1.2 s sine on an amber tint — a
|
|
* pulse, never a strobe), and the rim SHIMMERS — a dual-shimmer rim
|
|
* (js/ui/PerimeterShimmer.js, data/quests.json → tracker.shimmer): a
|
|
* constant amber base rim + glow, and two comets in counter-streams
|
|
* around the perimeter (amber clockwise, neon cyan counter-clockwise),
|
|
* crossing twice per cycle — until the reward is CLAIMED: the row drops
|
|
* off with a one-shot rim flash (the reward pouring into the button). The
|
|
* flip is a one-shot MOMENT: a bright row-flash + a small ◆
|
|
* burst off the row's right edge (when the row is on screen — the
|
|
* scene's toast + 'discovery' chime carry it when the tracker is slid
|
|
* off), and the row's popup gains a CLAIM REWARD button (the console's
|
|
* claim-plate palette) that claims in place (GameScene.claimQuest — the
|
|
* console's own path). The fingerprint carries each quest's COMPLETE bit
|
|
* as well as its checks: a BOUNTY HUNT's mark lands on a frame AFTER its
|
|
* last check has painted (the kill logs every leg at once, the field-
|
|
* clear completes it) — that flip must still repaint the row READY.
|
|
*
|
|
* 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, toColor } from '../utils/Color.js';
|
|
import { fontStack, themeColor } from '../utils/Theme.js';
|
|
import { CyberShape } from './CyberShape.js';
|
|
import { MenuButton } from './MenuButton.js';
|
|
import { PerimeterShimmer } from './PerimeterShimmer.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),
|
|
amber: themeColor('amber', 0xffc94d), // the console's READY color — claimable
|
|
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;
|
|
}
|
|
|
|
/** `Number(v)` when finite, else 0 (config-safe). */
|
|
function numCfg(v) {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
* @param {(id: string) => void} [o.onClaim] — CLAIM REWARD pressed (a
|
|
* READY row's popup)
|
|
*/
|
|
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;
|
|
this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : 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)
|
|
|
|
// The READY row's DUAL-SHIMMER rim (data/quests.json →
|
|
// tracker.shimmer) + the plate's cut-corner promotion (notch px).
|
|
this._shimmerCfg = cfg.shimmer && typeof cfg.shimmer === 'object' ? cfg.shimmer : null;
|
|
this._shimmerOn = this._shimmerCfg !== null && this._shimmerCfg.enabled !== false;
|
|
this._notch = this._shimmerOn ? Math.max(0, Math.min(12, Math.floor(numCfg(this._shimmerCfg.notch)))) : 0;
|
|
this._shimmers = []; // the current paint's PerimeterShimmer instances
|
|
|
|
// 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._readyBaseline = null; // the last observed READY set (transitions fire the moment)
|
|
this._readyFired = new Set(); // the moment already fired — one celebration per quest, ever
|
|
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) {
|
|
// The READY rows' DUAL-SHIMMER rims (PerimeterShimmer) — per-frame,
|
|
// on the ABSOLUTE clock (a repaint mid-flight never resets the
|
|
// comets' phase). Skipped while hidden (invisible — nothing to burn).
|
|
if (this.state !== 'hidden' && this._shimmers.length > 0) {
|
|
for (const sh of this._shimmers) sh.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 readyNow = new Set(active.filter((q) => q.complete === true).map((q) => q.id));
|
|
// READY on the last paint, gone now = the reward was CLAIMED — grab
|
|
// the row's rect BEFORE the repaint tears it down (the exit flash is
|
|
// fired after the repaint, on top — the reward pouring into the
|
|
// button).
|
|
let claimFlashes = null;
|
|
if (this._readyBaseline !== null) {
|
|
const gone = [...this._readyBaseline].filter((id) => !readyNow.has(id));
|
|
if (gone.length > 0) {
|
|
claimFlashes = gone
|
|
.map((id) => this._rowRectById.get(id))
|
|
.filter((r) => r !== null && r !== undefined);
|
|
}
|
|
}
|
|
const fp = JSON.stringify({
|
|
p: priorityId,
|
|
a: ids,
|
|
c: active.map((q) => {
|
|
const checks = q.checks ?? [];
|
|
// The quest's OWN complete bit LEADS its entry — a BOUNTY HUNT's
|
|
// ledger mark lands AFTER its last check flips (the kill logs every
|
|
// leg at once — 100% painted, still open — and the field-clear
|
|
// completes it with NO check change behind it). Without the bit
|
|
// here that frame repaints nothing and the READY state — the
|
|
// CLAIM's door — never lands (QuestWindow._fpOf carries it; the
|
|
// tracker must match).
|
|
return (
|
|
(q.complete === true ? 1 : 0) +
|
|
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);
|
|
if (claimFlashes && this.shown) {
|
|
for (const r of claimFlashes) this._claimFlash(r);
|
|
}
|
|
this._readyTransitions(active); // the one-shot moment for any newly-READY row
|
|
}
|
|
|
|
// ------------------------------------------------------------ the READY state
|
|
/**
|
|
* A quest just became claimable — the one-shot MOMENT (the scene's
|
|
* toast + 'discovery' chime carry it when the row isn't on screen):
|
|
* a bright amber row-flash + a small ◆ glyph burst off the row's right
|
|
* edge. Runs right after the repaint the flip triggered (a check
|
|
* completing changes the fingerprint, so it lands on the SAME frame
|
|
* the row repaints READY-styled). The baseline absorbs whatever was
|
|
* ALREADY ready on the tracker's first poll (a loaded run), so a scene
|
|
* start doesn't re-fire.
|
|
*/
|
|
_readyTransitions(active) {
|
|
const readyIds = active.filter((q) => q.complete === true).map((q) => q.id);
|
|
if (this._readyBaseline === null) {
|
|
this._readyBaseline = new Set(readyIds); // first poll — establish, don't celebrate
|
|
return;
|
|
}
|
|
const fresh = readyIds.filter((id) => !this._readyBaseline.has(id) && !this._readyFired.has(id));
|
|
this._readyBaseline = new Set(readyIds);
|
|
for (const id of fresh) {
|
|
this._readyFired.add(id); // the run is monotonic, but this is the guard
|
|
if (!this.shown) continue; // slid off — the toast + chime carry it
|
|
const r = this._rowRectById.get(id);
|
|
if (r) this._burstRow({ x: r.x, y: r.y, w: r.w, h: r.h });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The row's completion moment: a bright amber flash over the row
|
|
* (~420 ms fade) + four ◆ glyphs drifting up-right off its right edge
|
|
* and fading (plain tweens, staggered). Transient — NOT in _kids (no
|
|
* teardown; each self-destroys) and added to the root AFTER the paint,
|
|
* so it renders on top and slides with the tracker if a slide starts.
|
|
*/
|
|
_burstRow(r) {
|
|
const s = this.scene;
|
|
const flash = s.add.graphics().setScrollFactor(0);
|
|
flash.clear();
|
|
flash.fillStyle(C.amber, 1);
|
|
flash.fillRect(r.x, r.y, r.w, r.h);
|
|
this.root.add(flash);
|
|
s.tweens.add({
|
|
targets: flash,
|
|
alpha: 0,
|
|
duration: 420,
|
|
ease: 'Sine.easeOut',
|
|
onComplete: () => {
|
|
try {
|
|
flash.destroy();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
},
|
|
});
|
|
const n = 4;
|
|
for (let i = 0; i < n; i++) {
|
|
const g = s.add
|
|
.text(r.x + r.w - 10, r.y + r.h / 2, '◆', {
|
|
fontFamily: BODY,
|
|
fontSize: `${9 + (i % 2) * 4}px`,
|
|
color: toCss(C.amber),
|
|
})
|
|
.setOrigin(0.5)
|
|
.setScrollFactor(0);
|
|
this.root.add(g);
|
|
s.tweens.add({
|
|
targets: g,
|
|
x: g.x + 16 + i * 9,
|
|
y: g.y - (10 + i * 8),
|
|
alpha: 0,
|
|
duration: 460 + i * 90,
|
|
delay: i * 40,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
try {
|
|
g.destroy();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map data/quests.json → tracker.shimmer onto PerimeterShimmer's
|
|
* options (the config carries its own hex colors — no theme lookup).
|
|
* `rowIndex` staggers simultaneous READY rows, so their comets cross
|
|
* on different beats instead of in unison down the stack.
|
|
*/
|
|
_shimmerOptions(rowIndex) {
|
|
const c = this._shimmerCfg ?? {};
|
|
return {
|
|
container: this.root,
|
|
notch: this._notch,
|
|
periodMs: c.periodMs,
|
|
dashFrac: c.dashFrac,
|
|
phase0: rowIndex * (numCfg(c.phaseOffsetPerRow) || 0.25),
|
|
base: c.base,
|
|
comets: c.comets,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The CLAIM exit moment: a READY row just dropped off the paint (its
|
|
* reward claimed) — one bright rim flash over its last rect
|
|
* (~150 ms fade, tracker.shimmer.claimFlashMs), the reward pouring
|
|
* into the CLAIM button. Transient (NOT in _kids — it self-destroys),
|
|
* added after the repaint so it renders on top.
|
|
*/
|
|
_claimFlash(r) {
|
|
const s = this.scene;
|
|
const c = this._shimmerCfg ?? {};
|
|
const ms = Math.max(80, Math.floor(numCfg(c.claimFlashMs)) || 150);
|
|
const notch = this._notch;
|
|
const pts = notch > 0
|
|
? CyberShape.rectPoints(r.x, r.y, r.w, r.h, notch)
|
|
: [
|
|
{ x: r.x, y: r.y },
|
|
{ x: r.x + r.w, y: r.y },
|
|
{ x: r.x + r.w, y: r.y + r.h },
|
|
{ x: r.x, y: r.y + r.h },
|
|
];
|
|
const col = toColor(c.base?.color, C.amber);
|
|
const g = s.add.graphics().setScrollFactor(0);
|
|
g.clear();
|
|
g.lineStyle(6, col, 0.5);
|
|
g.strokePoints(pts, true);
|
|
g.lineStyle(2, 0xffe9b0, 0.95);
|
|
g.strokePoints(pts, true);
|
|
this.root.add(g);
|
|
s.tweens.add({
|
|
targets: g,
|
|
alpha: 0,
|
|
duration: ms,
|
|
ease: 'Sine.easeOut',
|
|
onComplete: () => {
|
|
try {
|
|
g.destroy();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Tear down the dynamic content (the title + rule are permanent). */
|
|
_teardown() {
|
|
for (const sh of this._shimmers) {
|
|
try {
|
|
sh.destroy();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
this._shimmers = [];
|
|
for (const k of this._kids) {
|
|
try {
|
|
this.scene.tweens.killTweensOf(k); // the READY row's breathing tint
|
|
} catch {
|
|
/* no tweens */
|
|
}
|
|
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 (let i = 0; i < visible.length; i++) {
|
|
const q = visible[i];
|
|
const isPrio = q.id === priorityId;
|
|
const isReady = q.complete === true; // ACTIVE quests only — claimed are filtered out above
|
|
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). A READY
|
|
// row is promoted to the console's CUT-CORNER plate (the CLAIM
|
|
// plate in miniature — the shimmer rim below traces the same
|
|
// polygon); the left tick (green = priority, faint = the rest) is
|
|
// for the plain rows — the READY row carries the rim instead.
|
|
const notch = isReady ? this._notch : 0;
|
|
const platePts = notch > 0 ? CyberShape.rectPoints(rx, ry, rw, rh, notch) : null;
|
|
const plate = add(s.add.graphics().setScrollFactor(0));
|
|
plate.clear();
|
|
plate.fillStyle(C.panel, 0.35);
|
|
if (platePts) plate.fillPoints(platePts, true);
|
|
else plate.fillRect(rx, ry, rw, rh);
|
|
if (!isReady) {
|
|
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 });
|
|
});
|
|
|
|
// The READY tint — an amber wash OVER the plate, UNDER the text,
|
|
// breathing on a slow sine (a pulse, never a strobe) — and the
|
|
// DUAL-SHIMMER RIM around the cut-corner plate (a constant amber
|
|
// rim + two counter-streaming comets; data/quests.json →
|
|
// tracker.shimmer) — it runs until the claim repaints the row
|
|
// away (the one-shot claim flash in update() marks the exit).
|
|
if (isReady) {
|
|
const tint = add(s.add.graphics().setScrollFactor(0));
|
|
tint.clear();
|
|
tint.fillStyle(C.amber, 1);
|
|
if (platePts) tint.fillPoints(platePts, true);
|
|
else tint.fillRect(rx, ry, rw, rh);
|
|
tint.setAlpha(0.1);
|
|
s.tweens.add({
|
|
targets: tint,
|
|
alpha: 0.3,
|
|
duration: 1200,
|
|
ease: 'Sine.easeInOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
});
|
|
if (this._shimmerOn) {
|
|
this._shimmers.push(new PerimeterShimmer(s, rx, ry, rw, rh, this._shimmerOptions(i)));
|
|
}
|
|
}
|
|
|
|
add(
|
|
s.add
|
|
.text(this.X + 8, ry + 3, String(q.title ?? '').toUpperCase(), {
|
|
fontFamily: BODY,
|
|
fontSize: '12px',
|
|
color: toCss(isReady || isPrio ? C.ink : C.dim),
|
|
letterSpacing: 1,
|
|
})
|
|
.setOrigin(0, 0)
|
|
.setScrollFactor(0),
|
|
);
|
|
const labelStyle = {
|
|
fontFamily: BODY,
|
|
fontSize: '11px',
|
|
color: toCss(isReady ? C.amber : isPrio ? C.green : C.dim),
|
|
letterSpacing: 1,
|
|
};
|
|
if (isReady) {
|
|
labelStyle.fontStyle = 'bold';
|
|
labelStyle.letterSpacing = 1.5;
|
|
}
|
|
add(
|
|
s.add.text(this.X + rw, ry + rh / 2, isReady ? 'READY' : `${pct}%`, labelStyle)
|
|
.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 its buttons —
|
|
* CLAIM REWARD (READY rows only, first — claims in place), 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 isReady = quest.complete === true; // ACTIVE quests only — claimed are out of the list
|
|
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 btns = [];
|
|
// CLAIM REWARD — READY rows only, FIRST (the popup's primary action):
|
|
// the console's claim-plate palette (amber edge, dark amber fill —
|
|
// QuestWindow's CLAIM REWARD), claiming in place via the scene's
|
|
// claimQuest (the console's own path — idempotent, toast + tick).
|
|
if (isReady) {
|
|
const bClaim = mk('CLAIM REWARD', () => {
|
|
this.onClaim?.(quest.id);
|
|
this.closePopup();
|
|
});
|
|
bClaim.style.fill = 0x2a1f08;
|
|
bClaim.style.hoverFill = 0x3d2c0e;
|
|
bClaim.style.stroke = C.amber;
|
|
bClaim.style.neon = C.amber;
|
|
bClaim.paint('base');
|
|
btns.push(bClaim);
|
|
}
|
|
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');
|
|
btns.push(bPrio, bView);
|
|
|
|
const H = 26 + btns.reduce((a, b) => a + b.style.height, 0) + (btns.length - 1) * 6 + 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: isReady ? C.amber : isPrio ? C.faint : C.green,
|
|
strokeAlpha: 0.85,
|
|
lineWidth: 1.5,
|
|
glow: isReady ? C.amber : 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(isReady ? C.amber : C.dim),
|
|
letterSpacing: 1.5,
|
|
})
|
|
.setOrigin(0, 0)
|
|
.setScrollFactor(0);
|
|
let by = 26;
|
|
for (const b of btns) {
|
|
b.setPosition(W / 2, by + b.style.height / 2);
|
|
by += b.style.height + 6;
|
|
}
|
|
|
|
// 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);
|
|
// Topmost element of the live game: the CLAIM REWARD plate is never
|
|
// covered where it overlaps the compass arrows (40), the toasts
|
|
// (45), the deck blocks (50) or the sub-bar (60). Stays UNDER the
|
|
// modal save panel (70) and the full-screen consoles (80) — they
|
|
// own the screen while up.
|
|
cont.setDepth(65);
|
|
cont.add(g);
|
|
cont.add(header);
|
|
for (const b of btns) cont.add(b);
|
|
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,
|
|
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 */
|
|
}
|
|
}
|
|
}
|