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