Add quest tracker HUD with priority system and live checklist
- New QuestTrackerHud component slides in below the system dossier once it folds, showing active quests (main story first) with a live progress checklist under the priority quest - Priority quest: player can set/clear via row popup or console; auto-assigns to first active story quest; cleared state sticks until a grant or claim re-arms it - QuestWindow gains SET PRIORITY / CLEAR PRIORITY controls and openAt() to land on a specific quest (used by SHOW MORE / VIEW QUEST) - GameScene wires the tracker into the dossier lifecycle: hidden while expanded, slides out before expand, slides in on collapse; pointerdown contract prevents world clicks from leaking through rows and popups - SurfaceScene delegates priority changes to the sleeping GameScene's ledger - QuestState serializes/deserializes priority + cleared flag for save round-trips - quests.json gains a tracker section (title, maxVisible, slideMs, toast configs) - New dev/quest-tracker.test.mjs pins the full contract; dev/quests.test.mjs extended with priority and tracker-knob checks; system-hud test updated for the extra tracker elements
This commit is contained in:
parent
f8d62dfe09
commit
9e7d7c1257
|
|
@ -300,6 +300,8 @@ node dev/world-names.test.mjs # world-name casing: display casing must never
|
|||
node dev/saves.test.mjs # the save bank + capture/restore hand-off (incl. builds)
|
||||
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
|
||||
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
|
||||
node dev/quest-tracker.test.mjs # the quest tracker HUD: slide in/out, live checklist, priority, SHOW MORE, the popup contract
|
||||
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
|
||||
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
|
||||
node dev/music.test.mjs # the shared music voice: guards + music.json contract
|
||||
```
|
||||
|
|
|
|||
|
|
@ -58,6 +58,25 @@
|
|||
"color": "#ff5c77",
|
||||
"text": "REQUIREMENTS INCOMPLETE — {missing}"
|
||||
},
|
||||
"tracker": {
|
||||
"_comment": "The QUEST TRACKER HUD (js/ui/QuestTrackerHud.js) — the thin quest list that slides in from offscreen-left right below the system dossier ONCE the dossier has folded (auto-fold or the player's manual fold — first fold arms it; visible ⇔ dossier folded). ACTIVE = held + not claimed. Titles stack main-story-first then side-quests, up to maxVisible, the priority quest's checklist renders under its title and tracks LIVE (GameScene.questSnapshot); more than maxVisible → SHOW MORE (opens the QUESTS console on the first hidden quest). The ONE priority quest (js/quests/QuestState.js): the player sets/clears it (row popup + the console's SET PRIORITY), else the first active story quest is the priority; claiming it auto-advances to the next story/side quest. priorityToast/clearToast fire on set/clear (data colors: green = the tracker accent, dim = the cleared note).",
|
||||
"enabled": true,
|
||||
"title": "QUESTS",
|
||||
"noneTracked": "NONE TRACKED",
|
||||
"showMore": "SHOW MORE",
|
||||
"maxVisible": 5,
|
||||
"slideMs": 320,
|
||||
"priorityToast": {
|
||||
"glyph": "◆",
|
||||
"color": "#67e863",
|
||||
"text": "PRIORITY SET — {title}"
|
||||
},
|
||||
"clearToast": {
|
||||
"glyph": "·",
|
||||
"color": "#7d92c4",
|
||||
"text": "PRIORITY CLEARED"
|
||||
}
|
||||
},
|
||||
"quests": [
|
||||
{
|
||||
"id": "start_your_journey",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,867 @@
|
|||
/**
|
||||
* Quest tracker HUD test (dev tool, run with Node — no browser):
|
||||
*
|
||||
* node dev/quest-tracker.test.mjs
|
||||
*
|
||||
* Runs the REAL tracker (js/ui/QuestTrackerHud.js), the REAL MenuButton,
|
||||
* the REAL quest ledger (js/quests/QuestState.js) and the REAL
|
||||
* GameScene dossier lifecycle (createSystemHud / updateHud /
|
||||
* startExpand / startCollapse / toggleHud / questSnapshot /
|
||||
* setPriorityQuest / clearPriorityQuest / claimQuest) against stubbed
|
||||
* scene plumbing (tweens run instantly, time is manual — the
|
||||
* system-hud.test.mjs pattern), and pins the tracker's contract:
|
||||
*
|
||||
* - the HUD is HIDDEN until the dossier has FOLDED (auto-fold OR the
|
||||
* player's manual fold — the first fold arms it): it slides in from
|
||||
* offscreen-left (a single x tween, ~320 ms per quests.json);
|
||||
* - when the dossier RE-OPENS the tracker slides out FIRST (make room),
|
||||
* then the dossier unfolds — and a toggle mid-slide is ignored;
|
||||
* - always-on green "QUESTS" title (the #67e863 accent), grayed-out
|
||||
* "NONE TRACKED" when no quests are tracked;
|
||||
* - active = held + not claimed; titles stack MAIN STORY first, then
|
||||
* side quests, capped at 5 (quests.json → tracker.maxVisible) with a
|
||||
* SHOW MORE button (the first hidden quest) when there are more —
|
||||
* and the priority quest is ALWAYS in the list (it takes the last
|
||||
* slot when it would sit past the cap);
|
||||
* - the ONE priority quest (the ledger's resolvePriority): auto = the
|
||||
* first active story quest; the player's set/clear sticks (cleared
|
||||
* stays off until a grant/claim); claiming advances to the next;
|
||||
* its checklist renders UNDER its title and tracks LIVE (the real
|
||||
* GameScene.questSnapshot — research / mined / tether / discovery);
|
||||
* - a row click opens the row popup (SET PRIORITY / CLEAR PRIORITY +
|
||||
* VIEW QUEST); the button clicks fire the scene's actions; the
|
||||
* mining-menu click contract (contains / popupContains /
|
||||
* sourceContains / closedPopupAt) holds;
|
||||
* - the scene's SET PRIORITY / CLEAR PRIORITY toast + the console's
|
||||
* SET PRIORITY seam (QuestWindow.openAt lands on the quest).
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- Stub just enough of Phaser for the class declarations + the tracker ----
|
||||
class ContainerStub {
|
||||
constructor(scene, x = 0, y = 0) {
|
||||
this.scene = scene;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.alpha = 1;
|
||||
this.visible = true;
|
||||
this.scale = 1;
|
||||
this.childrenList = [];
|
||||
}
|
||||
add(o) {
|
||||
if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c));
|
||||
else this.childrenList.push(o);
|
||||
return this;
|
||||
}
|
||||
setVisible(v) {
|
||||
this.visible = v;
|
||||
return this;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
setScale(s) {
|
||||
this.scale = s;
|
||||
return this;
|
||||
}
|
||||
setDepth() {
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setSize(w, h) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
setX(x) {
|
||||
this.x = x;
|
||||
return this;
|
||||
}
|
||||
destroy(recursive) {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (recursive) {
|
||||
for (const c of this.childrenList) {
|
||||
try {
|
||||
c.destroy?.();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class RectStub {
|
||||
constructor(x, y, width, height) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
contains(px, py) {
|
||||
return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height;
|
||||
}
|
||||
static Contains(r, px, py) {
|
||||
return r.contains(px, py);
|
||||
}
|
||||
}
|
||||
const ClassStub = class {};
|
||||
const PhaserStub = {
|
||||
Scene: ClassStub,
|
||||
Physics: { Arcade: { Sprite: ClassStub } },
|
||||
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub },
|
||||
Geom: { Rectangle: RectStub },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
Math: {
|
||||
Linear: (a, b, t) => a + (b - a) * t,
|
||||
FloatBetween: (a, b) => a + Math.random() * (b - a),
|
||||
Angle: { Wrap: (a) => a },
|
||||
Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)),
|
||||
},
|
||||
BlendModes: { ADD: 2 },
|
||||
};
|
||||
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton ----------
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href);
|
||||
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
|
||||
const { QuestState, questDef, questDefs } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/quests/QuestState.js')).href
|
||||
);
|
||||
const { QuestTrackerHud } = await import(
|
||||
pathToFileURL(join(__dirname, '../js/ui/QuestTrackerHud.js')).href
|
||||
);
|
||||
const { MenuButton } = await import(pathToFileURL(join(__dirname, '../js/ui/MenuButton.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
// --- Stub scene plumbing (the system-hud pattern, + the tracker's needs) ---
|
||||
class FakeText {
|
||||
constructor(x, y, str, style) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.text = str;
|
||||
this.depth = 0;
|
||||
this.alpha = 1;
|
||||
this.angle = 0;
|
||||
this.color = style?.color ?? null;
|
||||
this.style = style ?? null;
|
||||
}
|
||||
get width() {
|
||||
return this.text.length * 9; // deterministic fake metrics
|
||||
}
|
||||
get height() {
|
||||
return 19;
|
||||
}
|
||||
setText(s) {
|
||||
this.text = s;
|
||||
return this;
|
||||
}
|
||||
setColor(c) {
|
||||
this.color = c;
|
||||
return this;
|
||||
}
|
||||
setOrigin() {
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setBlendMode() {
|
||||
return this;
|
||||
}
|
||||
setDepth(d) {
|
||||
this.depth = d;
|
||||
return this;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class FakeGraphics {
|
||||
constructor() {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.visible = true;
|
||||
this.alpha = 1;
|
||||
this.handlers = {};
|
||||
this.input = null;
|
||||
}
|
||||
on(ev, fn) {
|
||||
(this.handlers[ev] ??= []).push(fn);
|
||||
return this;
|
||||
}
|
||||
emit(ev) {
|
||||
for (const fn of this.handlers[ev] ?? []) fn();
|
||||
return this;
|
||||
}
|
||||
fire(ev) {
|
||||
for (const fn of this.handlers[ev] ?? []) fn();
|
||||
return this;
|
||||
}
|
||||
setInteractive(o) {
|
||||
this.input = o;
|
||||
return this;
|
||||
}
|
||||
clear() {
|
||||
return this;
|
||||
}
|
||||
lineStyle() {
|
||||
return this;
|
||||
}
|
||||
fillStyle() {
|
||||
return this;
|
||||
}
|
||||
fillRect() {
|
||||
return this;
|
||||
}
|
||||
fillPoints() {
|
||||
return this;
|
||||
}
|
||||
strokePoints() {
|
||||
return this;
|
||||
}
|
||||
strokeRect() {
|
||||
return this;
|
||||
}
|
||||
lineBetween() {
|
||||
return this;
|
||||
}
|
||||
setVisible(v) {
|
||||
this.visible = v;
|
||||
return this;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setDepth() {
|
||||
return this;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class FakeRect {
|
||||
constructor(x, y, w, h, color, alpha) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.color = color;
|
||||
this.alpha = alpha;
|
||||
}
|
||||
setOrigin() {
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setX(v) {
|
||||
this.x = v;
|
||||
return this;
|
||||
}
|
||||
setAlpha(a) {
|
||||
this.alpha = a;
|
||||
return this;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
function makeAdd() {
|
||||
const texts = [];
|
||||
const glist = [];
|
||||
const factory = (obj, list) => {
|
||||
const orig = obj.destroy.bind(obj);
|
||||
obj.destroy = () => {
|
||||
const i = list.indexOf(obj);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
return orig();
|
||||
};
|
||||
return obj;
|
||||
};
|
||||
return {
|
||||
texts,
|
||||
glist,
|
||||
text: (x, y, str, style) => {
|
||||
const t = factory(new FakeText(x, y, str, style), texts);
|
||||
texts.push(t);
|
||||
return t;
|
||||
},
|
||||
graphics: () => {
|
||||
const g = factory(new FakeGraphics(), glist);
|
||||
glist.push(g);
|
||||
return g;
|
||||
},
|
||||
rectangle: (x, y, w, h, color, alpha) => new FakeRect(x, y, w, h, color, alpha),
|
||||
circle: (x, y, radius, color, alpha) =>
|
||||
({
|
||||
x, y, radius, fill: color, alpha,
|
||||
setStrokeStyle() { return this; },
|
||||
setDepth() { return this; },
|
||||
setScale() { return this; },
|
||||
setAlpha(a) { this.alpha = a; return this; },
|
||||
destroy() { this.destroyed = true; return this; },
|
||||
}),
|
||||
existing: (o) => o,
|
||||
};
|
||||
}
|
||||
function makeScene() {
|
||||
const add = makeAdd();
|
||||
const scene = {
|
||||
time: {
|
||||
now: 0,
|
||||
// Deferred (not run): toasts live for the assertion window, the
|
||||
// button press-release stays pending (harmless in the harness).
|
||||
delayedCall: (_ms, _fn) => ({ remove() {} }),
|
||||
},
|
||||
// Tweens run INSTANTLY in the harness: land the final value now.
|
||||
tweens: {
|
||||
killTweensOf: () => {},
|
||||
add(opts) {
|
||||
const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets];
|
||||
for (const [k, v] of Object.entries(opts)) {
|
||||
if (k === 'targets' || k === 'duration' || k === 'ease' || k === 'yoyo' || k === 'onComplete') continue;
|
||||
for (const tg of targets) tg[k] = v;
|
||||
}
|
||||
if (typeof opts.onComplete === 'function') opts.onComplete();
|
||||
return { remove() {} };
|
||||
},
|
||||
},
|
||||
add,
|
||||
scale: { width: 1280, height: 720 },
|
||||
played: [],
|
||||
playSfx(name) {
|
||||
this.played.push(name);
|
||||
},
|
||||
};
|
||||
return scene;
|
||||
}
|
||||
|
||||
// The dossier's real galaxy + report (the same as the system-hud test).
|
||||
const galaxy = Galaxy.create('tracker-hud-test');
|
||||
const rec = galaxy.currentSystem();
|
||||
const content = galaxy.ensureContent(rec.id);
|
||||
const report = formatSystemReport(content);
|
||||
|
||||
// ===========================================================================
|
||||
// 1) Component: hidden at first, the green QUESTS title, NONE TRACKED
|
||||
// ===========================================================================
|
||||
{
|
||||
const scene = makeScene();
|
||||
scene.time.now = 1000;
|
||||
const trk = new QuestTrackerHud(scene, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => null,
|
||||
resolvePriority: () => null,
|
||||
});
|
||||
const offX = -(trk.rowW + 60);
|
||||
check('fresh tracker: HIDDEN, parked offscreen-left', trk.state === 'hidden' && trk.root.visible === false && trk.root.x === offX);
|
||||
check('fresh tracker: isBusy() false, shown false', trk.isBusy() === false && trk.shown === false);
|
||||
const title = scene.add.texts.find((t) => t.text === 'QUESTS');
|
||||
check('the QUESTS title is there, at the anchor, GREEN (#67e863)',
|
||||
!!title && title.x === 16 && title.y === 40 && title.color === '#67e863');
|
||||
check('nothing tracked yet → grayed-out NONE TRACKED',
|
||||
scene.add.texts.some((t) => t.text === 'NONE TRACKED' && t.color === '#3d4c74'));
|
||||
// show → slide IN (instant tween) → shown at x=0, visible.
|
||||
trk.show(scene.time.now);
|
||||
check('show(): slides in — visible at x=0, state shown', trk.shown === true && trk.root.visible === true && trk.root.x === 0);
|
||||
check('show(): not busy once landed', trk.isBusy() === false);
|
||||
// hide → slide OFF → hidden + invisible + onDone (the scene chains the
|
||||
// dossier expand off it).
|
||||
let hiddenCb = false;
|
||||
trk.hide(scene.time.now, () => { hiddenCb = true; });
|
||||
check('hide(): slides off — state hidden, root hidden, onDone fired', trk.state === 'hidden' && trk.root.visible === false && hiddenCb === true);
|
||||
let hiddenAgain = false;
|
||||
trk.hide(scene.time.now, () => { hiddenAgain = true; });
|
||||
check('hide() while already hidden fires onDone immediately (no hang)', hiddenAgain === true);
|
||||
trk.destroy();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 2) Component: live snapshot — the priority checklist, the % readout,
|
||||
// main-before-side, the 5-row cap + SHOW MORE, claimed drop-offs
|
||||
// ===========================================================================
|
||||
{
|
||||
// Synthetic quest set (valid ids — the ledger validates against
|
||||
// data/quests.json): 2 main + 2 side, all granted, one claimed.
|
||||
const saved = config.data;
|
||||
const mkQ = (id, category, title, done, claimed) => ({
|
||||
id,
|
||||
category,
|
||||
title,
|
||||
issuer: 'TEST',
|
||||
description: 'x',
|
||||
icon: 'diamond',
|
||||
checks: ['R', 'M', 'T', 'D'].map((c, i) => ({ type: 'mineralsMined', amount: 1, label: `CHECK ${c}`, done: i < done })),
|
||||
reward: { minerals: 1 },
|
||||
claimed,
|
||||
});
|
||||
config.init({
|
||||
...configData,
|
||||
quests: {
|
||||
...configData.quests,
|
||||
quests: [
|
||||
mkQ('test_main_b', 'main', 'Main B', 1, false),
|
||||
mkQ('test_main_a', 'main', 'Main A', 4, false),
|
||||
mkQ('test_side_b', 'side', 'Side B', 0, false),
|
||||
mkQ('test_side_a', 'side', 'Side A', 2, true),
|
||||
],
|
||||
},
|
||||
});
|
||||
const scene = makeScene();
|
||||
scene.time.now = 2000;
|
||||
const ledger = new QuestState();
|
||||
ledger.give('test_main_b');
|
||||
ledger.give('test_main_a');
|
||||
ledger.give('test_side_b');
|
||||
ledger.give('test_side_a');
|
||||
ledger.claim('test_side_a'); // claimed in the snapshot → drops off
|
||||
// The snapshot = the shape of GameScene.questSnapshot, over the granted set.
|
||||
const snap = {
|
||||
categories: [{ id: 'main', label: 'MAIN STORY' }, { id: 'side', label: 'SIDE QUESTS' }],
|
||||
quests: [
|
||||
{ id: 'test_main_b', category: 'main', title: 'Main B', checks: [{ label: 'CHECK R', done: true, detail: 'DONE' }, { label: 'CHECK M', done: false, detail: '2/4' }, { label: 'CHECK T', done: false, detail: '2/4' }, { label: 'CHECK D', done: false, detail: '2/4' }], claimed: false, complete: false, progress: '1/4' },
|
||||
{ id: 'test_main_a', category: 'main', title: 'Main A', checks: ['R', 'M', 'T', 'D'].map((c) => ({ label: `CHECK ${c}`, done: true, detail: 'DONE' })), claimed: false, complete: true, progress: '4/4' },
|
||||
{ id: 'test_side_b', category: 'side', title: 'Side B', checks: ['R', 'M', 'T', 'D'].map((c) => ({ label: `CHECK ${c}`, done: false, detail: '0/4' })), claimed: false, complete: false, progress: '0/4' },
|
||||
{ id: 'test_side_a', category: 'side', title: 'Side A', checks: ['R', 'M'].map((c) => ({ label: `CHECK ${c}`, done: true, detail: 'DONE' })), claimed: true, complete: false, progress: '2/4' },
|
||||
],
|
||||
};
|
||||
const trk = new QuestTrackerHud(scene, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snap,
|
||||
resolvePriority: (ids) => ledger.resolvePriority(ids),
|
||||
});
|
||||
trk.show(scene.time.now);
|
||||
trk.update(scene.time.now);
|
||||
|
||||
const titleOf = (name) => scene.add.texts.find((t) => t.text === name);
|
||||
check('main story FIRST in the stack (Main B, Main A — side last)',
|
||||
titleOf('MAIN B') && titleOf('MAIN A') && titleOf('SIDE B') &&
|
||||
titleOf('MAIN B').y < titleOf('MAIN A').y && titleOf('MAIN A').y < titleOf('SIDE B').y);
|
||||
check('the CLAIMED quest drops off the list (Side A — claimed)', titleOf('SIDE A') === undefined);
|
||||
check('the % readout sits at the row\u2019s right edge (1/4 → 25%)',
|
||||
(() => {
|
||||
const pct = scene.add.texts.find((t) => t.text === '25%');
|
||||
const row0 = trk._rowRects[0];
|
||||
return !!pct && pct.x === 16 + trk.rowW && pct.y === row0.y + 10 && row0.id === 'test_main_b';
|
||||
})());
|
||||
check('auto-priority = the first active story quest (Main B)', ledger.priority === 'test_main_b');
|
||||
const checklistRows = scene.add.texts.filter((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30);
|
||||
check('the priority checklist renders UNDER its title (4 lines, indented)',
|
||||
checklistRows.length === 4 && checklistRows.every((t) => t.x === 30));
|
||||
check('a completed check reads ✓ + the dim label',
|
||||
scene.add.texts.some((t) => t.text.startsWith('✓') && t.color === '#7d92c4'));
|
||||
check('an open check reads a gray bullet + label + the detail',
|
||||
scene.add.texts.some((t) => t.text.startsWith('·') && t.text.includes('CHECK M') && t.color === '#3d4c74'));
|
||||
check('non-priority rows carry NO checklist (only the priority does)',
|
||||
scene.add.texts.filter((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30).length === 4);
|
||||
|
||||
// The player's SET PRIORITY moves the checklist (live repaint).
|
||||
ledger.setPriority('test_main_a');
|
||||
trk.update(scene.time.now);
|
||||
check('SET PRIORITY (Main A) → its checklist renders (4 ✓)',
|
||||
ledger.priority === 'test_main_a' &&
|
||||
scene.add.texts.filter((t) => /^✓/.test(String(t.text).trim().slice(0, 1)) && t.x === 30).length === 4);
|
||||
// CLEAR — the checklist goes away (plain titles again)…
|
||||
ledger.clearPriority();
|
||||
trk.update(scene.time.now);
|
||||
check('CLEAR PRIORITY → no checklist anywhere (plain titles)',
|
||||
ledger.priority === null && ledger.priorityCleared === true &&
|
||||
!scene.add.texts.some((t) => /^✓|^·/.test(String(t.text).trim().slice(0, 1)) && t.x === 30));
|
||||
// …and it STICKS: the next poll does NOT re-arm (no grant/claim since).
|
||||
trk.update(scene.time.now);
|
||||
check('the clear sticks across polls (auto-assign stays off)', ledger.priority === null);
|
||||
|
||||
trk.destroy();
|
||||
config.init(saved);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3) Component: the 5-row cap, SHOW MORE, the priority always in list
|
||||
// ===========================================================================
|
||||
{
|
||||
// Six active quests (3 main, 3 side) — more than the maxVisible cap (5).
|
||||
const savedConfig = config.data;
|
||||
config.init({
|
||||
...configData,
|
||||
quests: {
|
||||
...configData.quests,
|
||||
tracker: { ...configData.quests.tracker, maxVisible: 5 },
|
||||
},
|
||||
});
|
||||
const scene = makeScene();
|
||||
scene.time.now = 3000;
|
||||
const mk = (id, cat, title) => ({ id, category: cat, title, checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' });
|
||||
const snap = {
|
||||
categories: [],
|
||||
quests: [
|
||||
mk('m1', 'main', 'M1'), mk('m2', 'main', 'M2'), mk('m3', 'main', 'M3'),
|
||||
mk('s1', 'side', 'S1'), mk('s2', 'side', 'S2'), mk('s3', 'side', 'S3'),
|
||||
],
|
||||
};
|
||||
const seenShowMore = [];
|
||||
const trk = new QuestTrackerHud(scene, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snap,
|
||||
resolvePriority: (ids) => ids[0] ?? null, // auto = first active (M1)
|
||||
onShowMore: (id) => seenShowMore.push(id),
|
||||
});
|
||||
trk.show(scene.time.now);
|
||||
trk.update(scene.time.now);
|
||||
const titles = ['M1', 'M2', 'M3', 'S1', 'S2', 'S3'].map((n) => scene.add.texts.find((t) => t.text === n));
|
||||
check('more than 5 active → exactly FIVE titles listed',
|
||||
titles.filter(Boolean).length === 5 && titles[0] && titles[1] && titles[2] && titles[3] && (titles[4] !== undefined));
|
||||
check('the cap is MAIN-first: M1 M2 M3 S1 + the last slot',
|
||||
titles[0].y < titles[1].y && titles[1].y < titles[2].y && titles[2].y < titles[3].y && titles[3].y < (titles[4]?.y ?? Infinity));
|
||||
check('a SHOW MORE button is present (a MenuButton with a rect area)',
|
||||
trk._moreRect !== null && titles[4] !== undefined);
|
||||
check('the hidden quest is S3 (6th) — the one SHOW MORE targets',
|
||||
trk._rowRects.length === 5 && !trk._rowRects.some((r) => r.id === 's3'));
|
||||
// SHOW MORE clicked (its MenuButton's stored pointerdown) → the scene
|
||||
// action fires with the first hidden quest (s3).
|
||||
const moreG = trk._kids.find((k) => k instanceof MenuButton);
|
||||
check('the SHOW MORE button is a real MenuButton', moreG !== undefined);
|
||||
moreG?.panel?.fire?.('pointerdown');
|
||||
check('SHOW MORE → onShowMore(first hidden quest = s3)', seenShowMore.length === 1 && seenShowMore[0] === 's3');
|
||||
|
||||
trk.destroy();
|
||||
// The priority beyond the cap (s3) is ALWAYS in the list (last slot) —
|
||||
// a fresh scene (the previous tracker's rows would share the text list).
|
||||
const scene2 = makeScene();
|
||||
scene2.time.now = 3000;
|
||||
const trk2 = new QuestTrackerHud(scene2, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snap,
|
||||
resolvePriority: () => 's3', // the player featured the 6th quest
|
||||
});
|
||||
trk2.show(scene2.time.now);
|
||||
trk2.update(scene2.time.now);
|
||||
const t2 = ['M1', 'M2', 'M3', 'S1', 'S3'].map((n) => scene2.add.texts.find((t) => t.text === n && t.x === 16 + 8));
|
||||
check('a priority past the cap takes the LAST slot (S3 in, S2 out)',
|
||||
t2.every(Boolean) && !scene2.add.texts.some((t) => t.text === 'S2' && t.x === 16 + 8));
|
||||
trk2.destroy();
|
||||
config.init(savedConfig);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4) Component: the row popup — SET/CLEAR PRIORITY + VIEW QUEST + the
|
||||
// click contract (contains / popupContains / source / closedAt)
|
||||
// ===========================================================================
|
||||
{
|
||||
const scene = makeScene();
|
||||
scene.time.now = 4000;
|
||||
const snap = {
|
||||
categories: [],
|
||||
quests: [{ id: 'q1', category: 'main', title: 'Only Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }],
|
||||
};
|
||||
const events = [];
|
||||
let prioNow = null; // the ledger's effective priority (the player cleared it)
|
||||
const trk = new QuestTrackerHud(scene, {
|
||||
anchorX: 16,
|
||||
anchorY: 40,
|
||||
getSnapshot: () => snap,
|
||||
resolvePriority: () => prioNow,
|
||||
onSetPriority: (id) => events.push(['set', id]),
|
||||
onClearPriority: () => events.push(['clear']),
|
||||
onViewQuest: (id) => events.push(['view', id]),
|
||||
});
|
||||
trk.show(scene.time.now);
|
||||
trk.update(scene.time.now);
|
||||
const row = trk._rowRects[0];
|
||||
const plate = trk._kids.find((k) => k instanceof FakeGraphics);
|
||||
check('the row is a hit target (an interactive plate with a rect area)',
|
||||
!!plate?.input?.hitArea && plate.input.hitArea.contains(row.x + 5, row.y + 5));
|
||||
// Click the row → the popup opens.
|
||||
plate.fire('pointerdown');
|
||||
check('a row click opens the popup (SET PRIORITY + VIEW QUEST — this row is NOT the priority)',
|
||||
trk.popupOpen === true &&
|
||||
scene.add.texts.some((t) => t.text === 'SET PRIORITY') &&
|
||||
scene.add.texts.some((t) => t.text === 'VIEW QUEST') &&
|
||||
!scene.add.texts.some((t) => t.text === 'CLEAR PRIORITY'));
|
||||
const pop = trk._popup;
|
||||
check('the popup sits right of the row (same band, clear of it)',
|
||||
pop.rect.x >= row.x + row.w && Math.abs(pop.rect.y - row.y) <= 10);
|
||||
check('contract: the popup plate contains its own point',
|
||||
trk.popupContains(pop.rect.x + 5, pop.rect.y + 5) === true);
|
||||
check('contract: the SOURCE row contains the opening click',
|
||||
trk.sourceContains(row.x + 5, row.y + 5) === true);
|
||||
check('contract: contains() covers row + popup (the tracker footprint)',
|
||||
trk.contains(row.x + 5, row.y + 5) === true && trk.contains(pop.rect.x + 5, pop.rect.y + 5) === true);
|
||||
check('contract: far away is NOT in the footprint', trk.contains(1200, 600) === false);
|
||||
check('same-frame: the popup was opened "now" (the opening-click test)', trk.popupOpenedWithin(40) === true);
|
||||
|
||||
// VIEW QUEST (the button's own pointerdown) → the action + the popup closes.
|
||||
const bView = trk._popup.btns.find((b) => b.labelText?.text === 'VIEW QUEST');
|
||||
check('VIEW QUEST is a real MenuButton', bView instanceof MenuButton);
|
||||
bView?.panel?.fire?.('pointerdown');
|
||||
check('VIEW QUEST → onViewQuest(q1) + the popup closes', events.length === 1 && events[0][0] === 'view' && events[0][1] === 'q1' && trk.popupOpen === false);
|
||||
check('contract: a point in the just-closed plate is the same-frame guard',
|
||||
trk.closedPopupAt(pop.rect.x + 5, pop.rect.y + 5) === true);
|
||||
|
||||
// SET PRIORITY (re-open the popup).
|
||||
events.length = 0;
|
||||
plate.fire('pointerdown');
|
||||
const bSet = trk._popup.btns.find((b) => b.labelText?.text === 'SET PRIORITY');
|
||||
bSet?.panel?.fire?.('pointerdown');
|
||||
check('SET PRIORITY → onSetPriority(q1) + closes', events.length === 1 && events[0][0] === 'set' && events[0][1] === 'q1' && trk.popupOpen === false);
|
||||
|
||||
// Now the quest IS the priority (the ledger agreed) → the popup offers
|
||||
// CLEAR PRIORITY instead (the live repaint).
|
||||
prioNow = 'q1';
|
||||
trk.update(scene.time.now);
|
||||
plate.fire('pointerdown');
|
||||
const bClear = trk._popup.btns.find((b) => b.labelText?.text === 'CLEAR PRIORITY');
|
||||
check('when the row IS the priority, the popup offers CLEAR PRIORITY (not SET)',
|
||||
bClear !== undefined && !trk._popup.btns.some((b) => b.labelText?.text === 'SET PRIORITY'));
|
||||
events.length = 0;
|
||||
bClear?.panel?.fire?.('pointerdown');
|
||||
check('CLEAR PRIORITY → onClearPriority() + closes', events.length === 1 && events[0][0] === 'clear' && trk.popupOpen === false);
|
||||
trk.destroy();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 5) Integration: the real dossier lifecycle drives the tracker
|
||||
// ===========================================================================
|
||||
function makeGameScene() {
|
||||
const s = makeScene();
|
||||
const proto = GameScene.prototype;
|
||||
const scene = Object.assign(Object.create(proto), {
|
||||
...s,
|
||||
systemRecord: rec,
|
||||
systemContent: content,
|
||||
galaxy,
|
||||
// The ledger (real) + the live snapshot inputs (the scene's effects).
|
||||
questState: new QuestState(),
|
||||
totalMined: 0,
|
||||
researchState: { isUnlocked: () => false },
|
||||
tetherLevelFor: () => 0,
|
||||
discovery: null,
|
||||
homeWorldName: rec.name,
|
||||
// The toast/text plumbing the real consoleToast + claimQuest touch.
|
||||
mineralHud: { set() {} },
|
||||
refreshMineralHud() {},
|
||||
ship: { addMinerals() {} },
|
||||
consoleToastG: null,
|
||||
// The SFX + audio fields (the real playSfxOn path).
|
||||
playedSfx: [],
|
||||
});
|
||||
scene.questState.give(questDefs().find((q) => q.starter === true)?.id);
|
||||
return scene;
|
||||
}
|
||||
const STEP = 16;
|
||||
const step = (scene, from, to) => {
|
||||
for (let t = from; t <= to; t += STEP) GameScene.prototype.updateHud.call(scene, t);
|
||||
};
|
||||
const T0 = 100000;
|
||||
|
||||
{
|
||||
const s = makeGameScene();
|
||||
GameScene.prototype.createSystemHud.call(s);
|
||||
const trk = s.questTracker;
|
||||
check('createSystemHud builds the tracker (right below the name)', !!trk && trk.X === 16 && trk.Y0 === 40);
|
||||
check('before the first fold: the tracker is HIDDEN (dossier open)', trk.state === 'hidden' && trk.root.visible === false);
|
||||
// Arrive (open by default) — the tracker must NOT show yet.
|
||||
step(s, T0, T0 + 4000);
|
||||
check('dossier arrived OPEN — tracker still hidden', s.hudPhase === 'expanded' && trk.state === 'hidden');
|
||||
// The one-shot 10 s auto-fold → the tracker slides IN when it lands.
|
||||
step(s, T0 + 4000, T0 + 13000);
|
||||
check('the 10 s auto-fold fired (dossier collapsed)', s.hudPhase === 'collapsed');
|
||||
check('…and the tracker SLID IN (shown, visible, at rest)', trk.state === 'shown' && trk.root.visible === true && trk.root.x === 0);
|
||||
check('the starter quest auto-features as the priority (the checklist is up)',
|
||||
s.questState.priority === questDefs().find((q) => q.starter === true)?.id &&
|
||||
trk._rowRects.length === 1);
|
||||
const starterId = questDefs().find((q) => q.starter === true)?.id;
|
||||
const snapCheckStates = (sc) => (sc.questSnapshot()?.quests ?? []).find((q) => q.id === starterId)?.checks ?? [];
|
||||
check('the checklist lines read the LIVE snapshot (all open at 0%)',
|
||||
['Research Tether Level 2', 'Mine 200 Minerals from asteroids', 'Build a Level 2 Tether on your Home world', 'Discover another planet or space station'].every(
|
||||
(l) => s.add.texts.some((t) => t.x === 30 && t.text.includes(l)),
|
||||
) &&
|
||||
s.add.texts.every((t) => t.x !== 30 || /^·/.test(t.text.trim().slice(0, 1))));
|
||||
|
||||
// RE-OPEN (the player toggles): the tracker slides out FIRST, then the
|
||||
// dossier unfolds.
|
||||
s.time.now = T0 + 13000;
|
||||
GameScene.prototype.toggleHud.call(s);
|
||||
check('a re-open slides the tracker out FIRST (hidden before the expand runs)',
|
||||
trk.state === 'hidden' && trk.root.visible === false);
|
||||
check('…then the dossier expands (constructing → expanded)',
|
||||
s.hudPhase === 'constructing' || s.hudPhase === 'expanded');
|
||||
step(s, T0 + 13000, T0 + 16000);
|
||||
check('re-opened: the dossier is open again, the tracker stays hidden',
|
||||
s.hudPhase === 'expanded' && trk.state === 'hidden');
|
||||
|
||||
// FOLD again → the tracker slides back IN.
|
||||
s.time.now = T0 + 16000;
|
||||
GameScene.prototype.toggleHud.call(s);
|
||||
step(s, T0 + 16000, T0 + 19000);
|
||||
check('a later fold re-shows the tracker (visible ⇔ folded)',
|
||||
s.hudPhase === 'collapsed' && trk.state === 'shown' && trk.root.visible === true);
|
||||
|
||||
// A toggle MID-SLIDE is ignored (the busy guard).
|
||||
trk.state = 'hiding'; // simulate an in-flight slide (the harness tweens are instant)
|
||||
const tlBefore = s.hudTimeline;
|
||||
const phaseBefore = s.hudPhase;
|
||||
s.time.now = T0 + 19000;
|
||||
GameScene.prototype.toggleHud.call(s);
|
||||
check('a toggle while a slide is in flight is ignored (no timeline, phase intact)',
|
||||
s.hudTimeline === tlBefore && s.hudPhase === phaseBefore);
|
||||
trk.state = 'shown'; // (restore the settled state)
|
||||
|
||||
// LIVE checklist: the real questSnapshot ticks the checks over.
|
||||
const checksBefore = snapCheckStates(s);
|
||||
s.researchState = { isUnlocked: () => true };
|
||||
s.totalMined = 200;
|
||||
s.tetherLevelFor = () => 2;
|
||||
s.discoveredWorldCount = () => 1; // (shadow the prototype — the 4th check)
|
||||
s.questTracker.update(s.time.now);
|
||||
const checksAfter = snapCheckStates(s);
|
||||
check('the checklist tracks LIVE: 0/4 → 4/4 as the run progresses',
|
||||
checksBefore.every((c) => c.done === false) && checksAfter.every((c) => c.done === true));
|
||||
check('the % readout hits 100%', s.add.texts.some((t) => t.text === '100%'));
|
||||
|
||||
// The scene's priority actions (real setPriorityQuest / clearPriorityQuest).
|
||||
const saved = config.data;
|
||||
const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'x', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 1, label: 'MINE 1' }], reward: { minerals: 1 } };
|
||||
const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'x', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 1, label: 'MINE 1' }], reward: { minerals: 1 } };
|
||||
config.init({ ...config.data, quests: { ...config.data.quests, quests: [...config.data.quests.quests, MAIN2, SIDE1] } });
|
||||
try {
|
||||
s.questState.give('test_side_a');
|
||||
s.questTracker.update(s.time.now);
|
||||
check('the new side quest appears in the stack (main-first, side after)',
|
||||
trk._rowRects.length === 2 && trk._rowRects[0].id === starterId && trk._rowRects[1].id === 'test_side_a');
|
||||
s.time.now += 100;
|
||||
GameScene.prototype.setPriorityQuest.call(s, 'test_side_a');
|
||||
check('SET PRIORITY (scene) → the ledger features the SIDE quest',
|
||||
s.questState.priority === 'test_side_a');
|
||||
s.questTracker.update(s.time.now);
|
||||
check('…and the checklist moves to it (the tracker is a live view)',
|
||||
trk._rowRects.length === 2 && trk._priorityId === 'test_side_a');
|
||||
check('the set toast fired (PRIORITY SET — TEST SIDE A)',
|
||||
s.add.texts.some((t) => t.text === 'PRIORITY SET — TEST SIDE A'));
|
||||
s.time.now += 100;
|
||||
GameScene.prototype.clearPriorityQuest.call(s);
|
||||
check('CLEAR PRIORITY (scene) → the ledger clears it (the flag sticks)',
|
||||
s.questState.priority === null && s.questState.priorityCleared === true);
|
||||
s.questTracker.update(s.time.now);
|
||||
check('…and the checklist is gone (plain titles again)',
|
||||
trk._priorityId === null);
|
||||
check('the clear toast fired (PRIORITY CLEARED)', s.add.texts.some((t) => t.text === 'PRIORITY CLEARED'));
|
||||
} finally {
|
||||
config.init(saved);
|
||||
}
|
||||
|
||||
// CLAIM (real claimQuest) — the claimed priority DROPS off the ledger
|
||||
// and the auto-assignment re-arms (the cleared flag is lifted).
|
||||
s.questState.setPriority(starterId); // re-feature it (it is still active)
|
||||
const snap = s.questSnapshot();
|
||||
const q = snap.quests.find((x) => x.id === starterId);
|
||||
check('the starter quest is COMPLETE now (all 4 checks live-done)', q?.complete === true);
|
||||
GameScene.prototype.claimQuest.call(s, starterId);
|
||||
check('CLAIM (scene) → the ledger claims it (no double-pay)',
|
||||
s.questState.isClaimed(starterId) === true);
|
||||
check('…the claimed priority drops off the ledger (auto re-arms: cleared flag lifted)',
|
||||
s.questState.priority === null && s.questState.priorityCleared === false);
|
||||
s.questTracker.update(s.time.now);
|
||||
check('…the tracker shows the claim state (the row drops off — no active quest left)',
|
||||
trk._rowRects.length === 0 || trk._rowRects.every((r) => r.id !== starterId));
|
||||
s.questTracker.destroy();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 6) A MANUAL fold before the 10 s mark ALSO arms the tracker (first fold)
|
||||
// ===========================================================================
|
||||
{
|
||||
const s = makeGameScene();
|
||||
GameScene.prototype.createSystemHud.call(s);
|
||||
const trk = s.questTracker;
|
||||
step(s, T0, T0 + 4000); // arrive, open by default
|
||||
check('arrived open — tracker hidden', s.hudPhase === 'expanded' && trk.state === 'hidden');
|
||||
// The player folds at 8 s (before the auto-fold window)…
|
||||
s.time.now = T0 + 8000;
|
||||
GameScene.prototype.toggleHud.call(s);
|
||||
let foldedAt = null;
|
||||
for (let t = T0 + 8000; t <= T0 + 11000; t += STEP) {
|
||||
if (foldedAt === null && s.hudPhase === 'collapsed') foldedAt = t;
|
||||
GameScene.prototype.updateHud.call(s, t);
|
||||
}
|
||||
check('the manual 8 s fold landed (before the 10 s auto-fold)', foldedAt !== null && foldedAt < T0 + 10000);
|
||||
check('…and the tracker SLID IN (the first fold armed it)', trk.state === 'shown' && trk.root.visible === true);
|
||||
// …and the one-shot auto-fold stays cancelled (the player already folded).
|
||||
step(s, T0 + 11000, T0 + 14000);
|
||||
check('the cancelled auto-fold does not re-fold a re-opened dossier',
|
||||
s.autoCollapseArmed === false);
|
||||
s.questTracker.destroy();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 7) QuestWindow.openAt — the SHOW MORE / VIEW QUEST target (the seam)
|
||||
// ===========================================================================
|
||||
{
|
||||
// Pin the openAt selection logic on the REAL QuestWindow class shape
|
||||
// (a lightweight probe — the full window needs textures/videos the
|
||||
// harness does not carry; openAt is pure selection + repaint).
|
||||
const { QuestWindow } = await import(pathToFileURL(join(__dirname, '../js/ui/QuestWindow.js')).href);
|
||||
check('QuestWindow exposes openAt (the console lands on a quest)',
|
||||
typeof QuestWindow.prototype.openAt === 'function');
|
||||
check('QuestWindow accepts the priority seam (onSetPriority/getPriority)',
|
||||
/onSetPriority/.test(QuestWindow.toString()) && /getPriority/.test(QuestWindow.toString()));
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} quest-tracker test(s) FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nquest-tracker: all checks passed');
|
||||
|
|
@ -18,6 +18,12 @@
|
|||
* - the quest ledger (js/quests/QuestState.js) behaves: idempotent
|
||||
* give/claim, claimed ⊆ granted, a save round-trip, and a
|
||||
* corrupted save degrades to an empty ledger (not a throw);
|
||||
* - the PRIORITY (the tracker HUD's featured quest): set/clear,
|
||||
* the auto-assignment (first active story quest), the cleared-
|
||||
* flag sticks until a grant/claim, and the save round-trip;
|
||||
* - the tracker HUD's knobs (quests.json → tracker: the green title,
|
||||
* the NONE TRACKED line, the SHOW MORE label, the 5-row cap, the
|
||||
* slide duration, the set/clear toasts);
|
||||
* - both command decks carry the QUESTS slot, right of SHIP.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -180,6 +186,138 @@ check('helpers: starterQuestIds() is exactly [the starter]',
|
|||
})());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6b. The quest ledger — the PRIORITY (the tracker HUD's featured quest)
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const st = new QuestState();
|
||||
st.give(starter.id);
|
||||
check('priority: fresh — no priority', st.priority === null);
|
||||
check('priority: setPriority() refuses a quest not held', st.setPriority('no_such_quest') === false);
|
||||
check('priority: setPriority() sets a held quest', st.setPriority(starter.id) === true && st.priority === starter.id);
|
||||
check('priority: setPriority() is idempotent for the current one', st.setPriority(starter.id) === false);
|
||||
check('priority: resolve auto-assigns the single active quest', st.resolvePriority([starter.id]) === starter.id);
|
||||
check('priority: resolve keeps a stored priority that is still active', st.resolvePriority([starter.id]) === starter.id);
|
||||
check('priority: clearPriority() clears it', st.clearPriority() === true && st.priority === null && st.priorityCleared === true);
|
||||
check('priority: clearPriority() with nothing set is a no-op', st.clearPriority() === false);
|
||||
check('priority: a cleared priority sticks — resolve returns null',
|
||||
st.resolvePriority([starter.id]) === null);
|
||||
check('priority: a claimed priority drops off the ledger', (() => {
|
||||
const st2 = new QuestState();
|
||||
st2.give(starter.id);
|
||||
st2.setPriority(starter.id);
|
||||
st2.claim(starter.id);
|
||||
return st2.priority === null;
|
||||
})());
|
||||
check('priority: setPriority() refuses a CLAIMED quest', (() => {
|
||||
const st2 = new QuestState();
|
||||
st2.give(starter.id);
|
||||
st2.claim(starter.id);
|
||||
return st2.setPriority(starter.id) === false;
|
||||
})());
|
||||
}
|
||||
// Multi-quest behavior — the real data set has ONE quest today (side
|
||||
// quests are deferred by design), so the ordering rules are pinned on a
|
||||
// synthetic set: a second MAIN + one SIDE (config is a plain singleton —
|
||||
// re-init for the test, restore after).
|
||||
{
|
||||
const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 20, label: 'MINE 20' }], reward: { minerals: 20 } };
|
||||
const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } };
|
||||
const savedConfig = config.data;
|
||||
config.init({ ...config.data, quests: { ...quests, quests: [...quests.quests, MAIN2, SIDE1] } });
|
||||
try {
|
||||
const order = questDefs().map((q) => q.id); // [starter, MAIN2, SIDE1] — file order
|
||||
const main = questDefs().filter((q) => q.category === 'main').map((q) => q.id);
|
||||
const side = questDefs().filter((q) => q.category === 'side').map((q) => q.id);
|
||||
const display = [...main, ...side];
|
||||
check('priority: display order is main story first, then side quests',
|
||||
JSON.stringify(display) === JSON.stringify([order[0], order[1], order[2]]));
|
||||
|
||||
const st = new QuestState();
|
||||
for (const id of display) st.give(id);
|
||||
check('priority: resolve auto-assigns the FIRST active quest (display order)',
|
||||
st.resolvePriority(display) === display[0] && st.priority === display[0]);
|
||||
check('priority: resolve keeps a stored priority that is still active',
|
||||
(st.setPriority(display[1]) === true && st.resolvePriority(display) === display[1]));
|
||||
check('priority: the player can feature a SIDE quest over the story order',
|
||||
(st.setPriority(side[0]) === true && st.resolvePriority(display) === side[0]));
|
||||
|
||||
// A cleared priority STICKS (the auto-assignment stays off)...
|
||||
check('priority: a cleared priority sticks — resolve returns null',
|
||||
(st.clearPriority() === true && st.resolvePriority(display) === null));
|
||||
// ...until a new quest ARRIVES (give re-arms the auto-assignment) —
|
||||
check('priority: a new grant re-arms the auto-assignment', (() => {
|
||||
const fresh = new QuestState();
|
||||
fresh.give(display[0]);
|
||||
fresh.resolvePriority([display[0]]);
|
||||
fresh.clearPriority();
|
||||
if (fresh.resolvePriority([display[0]]) !== null) return false; // still cleared
|
||||
fresh.give(display[1]); // a new quest arrives → re-armed
|
||||
return fresh.priorityCleared === false && fresh.resolvePriority([display[0], display[1]]) === display[0];
|
||||
})());
|
||||
// ...or one is CLAIMED (claim re-arms it — the active set changed).
|
||||
check('priority: claiming re-arms the auto-assignment (advances to the next)', (() => {
|
||||
const st2 = new QuestState();
|
||||
for (const id of display) st2.give(id);
|
||||
st2.resolvePriority(display);
|
||||
st2.clearPriority();
|
||||
st2.claim(display[0]); // the first drops off the active set
|
||||
return st2.resolvePriority([display[1], display[2]]) === display[1] && st2.priority === display[1];
|
||||
})());
|
||||
|
||||
// The save round-trip keeps the priority (and drops one that is no
|
||||
// longer active — claimed or unknown).
|
||||
const st3 = new QuestState();
|
||||
for (const id of display) st3.give(id);
|
||||
st3.setPriority(side[0]);
|
||||
const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st3.toJSON())));
|
||||
check('priority: save round-trip keeps the stored priority (a side quest)',
|
||||
restored.priority === side[0] && restored.priorityCleared === false);
|
||||
check('priority: a save pointing at a CLAIMED quest drops it', (() => {
|
||||
const r = QuestState.fromJSON({ granted: [display[0]], claimed: [display[0]], priority: display[0] });
|
||||
return r.priority === null;
|
||||
})());
|
||||
check('priority: a save pointing at an UNKNOWN quest drops it', (() => {
|
||||
const r = QuestState.fromJSON({ granted: [display[0]], priority: 'no_such_quest' });
|
||||
return r.priority === null;
|
||||
})());
|
||||
} finally {
|
||||
config.init(savedConfig); // restore the real data set
|
||||
}
|
||||
}
|
||||
{
|
||||
const st = new QuestState();
|
||||
st.give(starter.id);
|
||||
st.resolvePriority([starter.id]);
|
||||
st.clearPriority();
|
||||
const restoredClear = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())));
|
||||
check('priority: save round-trip keeps the cleared flag',
|
||||
restoredClear.priority === null && restoredClear.priorityCleared === true);
|
||||
check('priority: a PRE-priority save (no field) restores as null', (() => {
|
||||
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [] });
|
||||
return r.priority === null && r.priorityCleared === false;
|
||||
})());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 6c. quests.json — the tracker HUD's knobs
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const t = quests.tracker ?? {};
|
||||
check('tracker: section present with the master switch', typeof t.enabled === 'boolean');
|
||||
check('tracker: enabled (the HUD is live)', t.enabled === true);
|
||||
check('tracker: title + empty-state lines', typeof t.title === 'string' && t.title.length > 0 && typeof t.noneTracked === 'string' && t.noneTracked.length > 0);
|
||||
check('tracker: SHOW MORE label', typeof t.showMore === 'string' && t.showMore.length > 0);
|
||||
check('tracker: maxVisible is a sane cap (≥1)', Number.isInteger(t.maxVisible) && t.maxVisible >= 1);
|
||||
check('tracker: slide duration configured', Number.isFinite(t.slideMs) && t.slideMs >= 80);
|
||||
for (const key of ['priorityToast', 'clearToast']) {
|
||||
check(`tracker: ${key} configured (glyph + text + color)`,
|
||||
!!t[key] && typeof t[key].glyph === 'string' && typeof t[key].text === 'string' && hex.test(t[key].color ?? ''));
|
||||
}
|
||||
check('tracker: the PRIORITY accent is the tracker green (#67e863)',
|
||||
(t.priorityToast?.color ?? '').toLowerCase() === '#67e863');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 7. The command decks — the QUESTS slot, right of SHIP (both decks)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -36,10 +36,63 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||
|
||||
// --- Stub just enough of Phaser for the module-level class declarations ----
|
||||
const ClassStub = class {};
|
||||
// A usable container (the QUEST tracker registers one in createSystemHud):
|
||||
const ContainerStub = class {
|
||||
constructor(scene, x = 0, y = 0) {
|
||||
this.scene = scene;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.visible = true;
|
||||
this.childrenList = [];
|
||||
}
|
||||
add(o) {
|
||||
if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c));
|
||||
else this.childrenList.push(o);
|
||||
return this;
|
||||
}
|
||||
setVisible(v) {
|
||||
this.visible = v;
|
||||
return this;
|
||||
}
|
||||
setSize() {
|
||||
return this;
|
||||
}
|
||||
setAlpha() {
|
||||
return this;
|
||||
}
|
||||
setDepth() {
|
||||
return this;
|
||||
}
|
||||
setScrollFactor() {
|
||||
return this;
|
||||
}
|
||||
setPosition(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
setX(x) {
|
||||
this.x = x;
|
||||
return this;
|
||||
}
|
||||
destroy(recursive) {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
if (recursive) {
|
||||
for (const c of this.childrenList) {
|
||||
try {
|
||||
c.destroy?.();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const PhaserStub = {
|
||||
Scene: ClassStub,
|
||||
Physics: { Arcade: { Sprite: ClassStub } },
|
||||
GameObjects: { Sprite: ClassStub, Container: ClassStub, Image: ClassStub },
|
||||
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub },
|
||||
Geom: { Rectangle: class {} },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
|
||||
Math: {
|
||||
|
|
@ -143,9 +196,19 @@ function makeScene() {
|
|||
texts.push(t);
|
||||
return t;
|
||||
},
|
||||
// The QUEST tracker (built by createSystemHud) registers its
|
||||
// container + its hairline rule (NONE TRACKED state — no rows).
|
||||
existing: (o) => o,
|
||||
graphics: () => ({
|
||||
clear() {}, lineStyle() {}, lineBetween() {}, fillStyle() {}, fillRect() {},
|
||||
setScrollFactor() { return this; }, setDepth() { return this; },
|
||||
setPosition(x, y) { this.x = x; this.y = y; return this; },
|
||||
destroy() { this.destroyed = true; return this; },
|
||||
}),
|
||||
},
|
||||
// Tweens run INSTANTLY in the harness: land the final value now.
|
||||
tweens: {
|
||||
killTweensOf: () => {},
|
||||
add(opts) {
|
||||
const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets];
|
||||
for (const [k, v] of Object.entries(opts)) {
|
||||
|
|
@ -180,7 +243,9 @@ GameScene.prototype.createSystemHud.call(scene);
|
|||
const titleW = report.title.length * 9;
|
||||
const titleH = 19;
|
||||
|
||||
check('title + caret + one text per detail line', scene.texts.length === 2 + detailValues.length);
|
||||
check('title + caret + one text per detail line (+ the tracker\u2019s own 2 texts)',
|
||||
scene.texts.length === 2 + detailValues.length + 2 &&
|
||||
scene.questTracker !== undefined);
|
||||
check('the name starts empty (it decodes in)', scene.hudTitle.text === '');
|
||||
check('details start empty (open by default, not pre-printed)', scene.hudDetail.every((d) => d.text.text === ''));
|
||||
check('the caret is the down triangle ▾, hidden at first', scene.hudCaret.text === '\u25be' && scene.hudCaret.alpha === 0);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@
|
|||
* asteroids, the home world's tether standing, the discovery ledger.
|
||||
* Only the two things the run owns are state:
|
||||
*
|
||||
* granted — which quest ids the player HOLDS (the starter quest is
|
||||
* granted on the run's first boot; future quests are granted
|
||||
* by whatever event issues them)
|
||||
* claimed — which of the held quests have PAID OUT their reward
|
||||
* granted — which quest ids the player HOLDS (the starter quest is
|
||||
* granted on the run's first boot; future quests are granted
|
||||
* by whatever event issues them)
|
||||
* claimed — which of the held quests have PAID OUT their reward
|
||||
* priority — the ONE priority quest the tracker HUD features (title +
|
||||
* live checklist). The player sets/clears it; the ledger
|
||||
* also auto-assigns it (see resolvePriority) so there is
|
||||
* always a priority while an active story quest exists.
|
||||
* cleared — the player CLEARED the priority; the auto-assignment is
|
||||
* re-armed by any grant or claim (the active set changed).
|
||||
*
|
||||
* …and that is exactly what gets saved (SaveData capture/prepare), so a
|
||||
* save can never hand out a reward twice or un-grant a quest.
|
||||
|
|
@ -44,6 +50,8 @@ export class QuestState {
|
|||
constructor() {
|
||||
this.granted = new Set();
|
||||
this.claimed = new Set();
|
||||
this.priority = null; // the active quest id, or null
|
||||
this.priorityCleared = false; // the player declined a priority (sticks until a grant/claim)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +63,7 @@ export class QuestState {
|
|||
if (!questDef(id)) return false;
|
||||
if (this.granted.has(id)) return false;
|
||||
this.granted.add(id);
|
||||
this.priorityCleared = false; // a new quest re-arms the auto-priority
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -76,11 +85,74 @@ export class QuestState {
|
|||
if (!this.granted.has(id)) return false;
|
||||
if (this.claimed.has(id)) return false;
|
||||
this.claimed.add(id);
|
||||
if (this.priority === id) this.priority = null; // it can't feature itself
|
||||
this.priorityCleared = false; // the active set changed → re-arm the auto-priority
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ priority
|
||||
|
||||
/**
|
||||
* Set the priority quest (the tracker HUD's featured quest: title +
|
||||
* checklist). Must be HELD and not yet CLAIMED. Idempotent — re-setting
|
||||
* the current priority is a no-op.
|
||||
* @returns {boolean} true when the priority CHANGED
|
||||
*/
|
||||
setPriority(id) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
if (!this.granted.has(id) || this.claimed.has(id)) return false;
|
||||
if (this.priority === id) return false;
|
||||
this.priority = id;
|
||||
this.priorityCleared = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the priority (the player's "no priority right now"). It STICKS
|
||||
* — the auto-assignment stays off until a new quest is granted or one
|
||||
* is claimed (either re-arms it, the active set changed).
|
||||
* @returns {boolean} true when there WAS a priority to clear
|
||||
*/
|
||||
clearPriority() {
|
||||
if (!this.priority) return false;
|
||||
this.priority = null;
|
||||
this.priorityCleared = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective priority from the ACTIVE quests (held + not
|
||||
* claimed) in DISPLAY order — main story first, then side quests
|
||||
* (data/quests.json order within a category):
|
||||
* - the stored priority when it is still active (the player's choice,
|
||||
* or the last auto-assignment);
|
||||
* - otherwise the FIRST active quest — unless the player cleared the
|
||||
* priority (clearPriority), in which case null until a grant/claim;
|
||||
* - otherwise null (nothing active).
|
||||
* Auto-assignment is persisted on the ledger (idempotent), so the
|
||||
* choice survives scene restarts + saves.
|
||||
* @param {string[]} activeIds the active quest ids, display order
|
||||
* @returns {string|null}
|
||||
*/
|
||||
resolvePriority(activeIds) {
|
||||
const ids = Array.isArray(activeIds) ? activeIds.filter((x) => typeof x === 'string' && x !== '') : [];
|
||||
if (this.priority && ids.includes(this.priority)) return this.priority;
|
||||
if (this.priorityCleared) return null;
|
||||
const first = ids[0] ?? null;
|
||||
if (first) {
|
||||
this.priority = first;
|
||||
this.priorityCleared = false;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { granted: [...this.granted], claimed: [...this.claimed] };
|
||||
return {
|
||||
granted: [...this.granted],
|
||||
claimed: [...this.claimed],
|
||||
priority: this.priority ?? null,
|
||||
priorityCleared: this.priorityCleared === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,6 +170,15 @@ export class QuestState {
|
|||
for (const id of grant(data.claimed)) {
|
||||
if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted
|
||||
}
|
||||
// The priority — a save predating it has no field (null). A saved id
|
||||
// that is no longer held (or was claimed) is dropped rather than
|
||||
// resurrected; the cleared flag survives as stored.
|
||||
st.priority =
|
||||
typeof data.priority === 'string' && data.priority !== '' &&
|
||||
st.granted.has(data.priority) && !st.claimed.has(data.priority)
|
||||
? data.priority
|
||||
: null;
|
||||
st.priorityCleared = data.priorityCleared === true;
|
||||
return st;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { CommsPanel } from '../ui/CommsPanel.js';
|
|||
import { ResearchWindow } from '../ui/ResearchWindow.js';
|
||||
import { MapWindow } from '../ui/MapWindow.js';
|
||||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
||||
import { QuestState, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
|
||||
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
|
||||
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
|
||||
|
|
@ -790,6 +791,8 @@ export class GameScene extends Phaser.Scene {
|
|||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.questSnapshot(),
|
||||
onClaim: (id) => this.claimQuest(id),
|
||||
onSetPriority: (id) => this.setPriorityQuest(id),
|
||||
getPriority: () => this.questState?.priority ?? null,
|
||||
onLocked: (tabId) =>
|
||||
this.consoleToast(
|
||||
tabId === 'side'
|
||||
|
|
@ -990,6 +993,31 @@ export class GameScene extends Phaser.Scene {
|
|||
this.toggleHud(); // the system name toggles the dossier details
|
||||
return;
|
||||
}
|
||||
// The quest tracker (below the dossier, depth 30) — the mining-
|
||||
// menu contract: while its row popup is up, a click inside the
|
||||
// popup is a button's (its own pointerdown); the same-frame click
|
||||
// on the row that OPENED it is swallowed; any other click closes
|
||||
// the popup and is consumed. With the popup down, a click on the
|
||||
// tracker's footprint is the tracker's (a row's own pointerdown
|
||||
// opens the popup) — never a fly-here; a click its popup buttons
|
||||
// just handled (they close it from their side first) is that
|
||||
// click, never a world click.
|
||||
if (this.questTracker && this.questTracker.shown) {
|
||||
const tx = pointer.x;
|
||||
const ty = pointer.y;
|
||||
if (this.questTracker.popupOpen) {
|
||||
if (this.questTracker.popupContains(tx, ty)) return;
|
||||
if (this.questTracker.sourceContains(tx, ty)) {
|
||||
if (this.questTracker.popupOpenedWithin(40)) return; // the opening click
|
||||
this.questTracker.closePopup(); // a later deliberate re-click — fold it
|
||||
return;
|
||||
}
|
||||
this.questTracker.closePopup();
|
||||
return;
|
||||
}
|
||||
if (this.questTracker.closedPopupAt(tx, ty)) return;
|
||||
if (this.questTracker.contains(tx, ty)) return;
|
||||
}
|
||||
|
||||
// Mining pop-up OPEN: a click on one of its buttons is the
|
||||
// button's (its own pointerdown listener); a click ANYWHERE else
|
||||
|
|
@ -1262,7 +1290,6 @@ export class GameScene extends Phaser.Scene {
|
|||
// dossier is pinned UI, so local space == screen space).
|
||||
this.hudTitleRect = { x: X - 4, y: y - 4, w: titleW + 24, h: titleH + 8 };
|
||||
y += 26;
|
||||
|
||||
// --- The details (open by default) ----------------------------------
|
||||
const detail = [];
|
||||
const line = (value, style) => {
|
||||
|
|
@ -1297,6 +1324,26 @@ export class GameScene extends Phaser.Scene {
|
|||
settled: false,
|
||||
})),
|
||||
};
|
||||
|
||||
// ---- The QUEST TRACKER — the thin quest list right below the name --
|
||||
// Slides in from offscreen-left the moment the dossier has FOLDED
|
||||
// (the one-shot auto-fold, or the player's manual fold — the first
|
||||
// fold arms it; visible ⇔ the dossier is folded). It is a VIEW over
|
||||
// the same live split as the console (questSnapshot — the checklist
|
||||
// ticks over as research/mining/builds/discovery complete) and the
|
||||
// same ledger (questState — the ONE priority quest). updateHud shows
|
||||
// it when a collapse lands; startExpand slides it out first (make
|
||||
// room); GameScene.update drives its per-frame poll.
|
||||
this.questTracker = new QuestTrackerHud(this, {
|
||||
anchorX: X,
|
||||
anchorY: 40, // just below the dossier title (where the details start)
|
||||
getSnapshot: () => this.questSnapshot(),
|
||||
resolvePriority: (ids) => this.questState.resolvePriority(ids),
|
||||
onSetPriority: (id) => this.setPriorityQuest(id),
|
||||
onClearPriority: () => this.clearPriorityQuest(),
|
||||
onViewQuest: (id) => this.openQuestAt(id),
|
||||
onShowMore: (id) => this.openQuestAt(id),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1344,6 +1391,10 @@ export class GameScene extends Phaser.Scene {
|
|||
if (tl.mode === 'collapse') {
|
||||
this.hudPhase = 'collapsed';
|
||||
this.rotateCaret(true); // details gone → caret swings to point right
|
||||
// The dossier has folded — the tracker slides in from offscreen
|
||||
// left (the first fold arms it; a later fold re-shows it after
|
||||
// startExpand slid it out to make room).
|
||||
this.questTracker?.show(this.time.now);
|
||||
} else {
|
||||
this.hudPhase = 'expanded';
|
||||
}
|
||||
|
|
@ -1386,18 +1437,28 @@ export class GameScene extends Phaser.Scene {
|
|||
* arrival, just without the name (it never left).
|
||||
*/
|
||||
startExpand(t) {
|
||||
this.hudPhase = 'constructing';
|
||||
this.playSfx('construct'); // the details type back in
|
||||
this.rotateCaret(false);
|
||||
this.hudTimeline = {
|
||||
mode: 'expand',
|
||||
lines: this.hudDetail.map((ln, i) => ({
|
||||
text: ln.text,
|
||||
value: ln.value,
|
||||
settled: false,
|
||||
dec: new ScrambleDecode(ln.value, t + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)),
|
||||
})),
|
||||
const begin = (tt) => {
|
||||
this.hudPhase = 'constructing';
|
||||
this.playSfx('construct'); // the details type back in
|
||||
this.rotateCaret(false);
|
||||
this.hudTimeline = {
|
||||
mode: 'expand',
|
||||
lines: this.hudDetail.map((ln, i) => ({
|
||||
text: ln.text,
|
||||
value: ln.value,
|
||||
settled: false,
|
||||
dec: new ScrambleDecode(ln.value, tt + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)),
|
||||
})),
|
||||
};
|
||||
};
|
||||
const trk = this.questTracker;
|
||||
if (trk && (trk.isBusy() || trk.shown)) {
|
||||
// Make room FIRST: the tracker slides offscreen-left, then the
|
||||
// dossier unfolds (its lines would paint straight over the list).
|
||||
trk.hide(this.time.now, () => begin(this.time.now));
|
||||
} else {
|
||||
begin(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1428,6 +1489,7 @@ export class GameScene extends Phaser.Scene {
|
|||
*/
|
||||
toggleHud() {
|
||||
if (this.hudTimeline) return;
|
||||
if (this.questTracker && this.questTracker.isBusy()) return; // a slide is in flight — let it land
|
||||
this.autoCollapseArmed = false;
|
||||
if (this.hudPhase === 'expanded') this.startCollapse(this.time.now);
|
||||
else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now);
|
||||
|
|
@ -1446,6 +1508,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.time.update(_time, delta);
|
||||
this.tweens.update();
|
||||
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
|
||||
this.questTracker?.update(_time); // the tracker: live checklist + priority
|
||||
this.ship.update(_time, delta);
|
||||
// Session time (saved with the game) — capped so a backgrounded tab
|
||||
// can't fast-forward it.
|
||||
|
|
@ -2755,6 +2818,62 @@ export class GameScene extends Phaser.Scene {
|
|||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* SET PRIORITY — the tracker HUD's featured quest (the row popup's
|
||||
* button, or the console's SET PRIORITY). The ledger is the source of
|
||||
* truth (setPriority is idempotent + persists); the tracker and the
|
||||
* console repaint on their next poll — the tracker's checklist moves
|
||||
* to the newly featured quest LIVE.
|
||||
*/
|
||||
setPriorityQuest(id) {
|
||||
if (!this.questState) return;
|
||||
if (!this.questState.setPriority(id)) return; // not held, claimed, or already the priority
|
||||
const def = questDef(id);
|
||||
const t = config.get('quests.tracker.priorityToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(def?.title ?? id).toUpperCase()),
|
||||
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)) },
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* CLEAR PRIORITY — the player's "no priority right now" (the row
|
||||
* popup's CLEAR PRIORITY). It STICKS on the ledger: the tracker shows
|
||||
* plain titles again, and the auto-assignment re-arms only when a new
|
||||
* quest is granted or one is claimed (the active set changed).
|
||||
*/
|
||||
clearPriorityQuest() {
|
||||
if (!this.questState) return;
|
||||
if (!this.questState.clearPriority()) return; // there was nothing to clear
|
||||
const t = config.get('quests.tracker.clearToast', {});
|
||||
this.consoleToast(String(t.text ?? 'PRIORITY CLEARED'), {
|
||||
glyph: t.glyph ?? '·',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('dim', 0x7d92c4)),
|
||||
});
|
||||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the QUESTS console ON A QUEST (the tracker's SHOW MORE — the
|
||||
* first hidden quest — and VIEW QUEST): the deckAction('quests') close
|
||||
* contract first, then the window lands on that quest (its tab active,
|
||||
* its plate selected — QuestWindow.openAt).
|
||||
*/
|
||||
openQuestAt(questId) {
|
||||
if (!this.questWindow) return;
|
||||
if (this.questWindow.isOpen) {
|
||||
this.questWindow.openAt(questId);
|
||||
return;
|
||||
}
|
||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
||||
if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
|
||||
this.questWindow.openAt(questId);
|
||||
this.questWindow.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* A quest-relevant event happened (research completed, ore mined, a
|
||||
* build finished, a world discovered, a reward claimed). Announce each
|
||||
|
|
|
|||
|
|
@ -554,6 +554,8 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
this.questWindow = new QuestWindow(this, {
|
||||
getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null,
|
||||
onClaim: (id) => this.claimSurfaceQuest(id),
|
||||
onSetPriority: (id) => this.setPrioritySurfaceQuest(id),
|
||||
getPriority: () => this.gameScene?.questState?.priority ?? null,
|
||||
onLocked: (tabId) =>
|
||||
this.consoleNote(
|
||||
tabId === 'side'
|
||||
|
|
@ -710,6 +712,29 @@ export class SurfaceScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SET PRIORITY from the surface console — the ledger lives on the
|
||||
* sleeping GameScene (the claimSurfaceQuest precedent), and the
|
||||
* console note is ours (we are the scene the player sees).
|
||||
*/
|
||||
setPrioritySurfaceQuest(id) {
|
||||
const g = this.gameScene;
|
||||
if (!g?.questState) return;
|
||||
if (!g.questState.setPriority(id)) return; // not held, claimed, or already the priority
|
||||
let title = id;
|
||||
try {
|
||||
title = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id;
|
||||
} catch {
|
||||
/* keep the id */
|
||||
}
|
||||
const t = config.get('quests.tracker.priorityToast', {});
|
||||
this.consoleNote(
|
||||
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(title).toUpperCase()),
|
||||
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
this.questWindow?.refresh();
|
||||
}
|
||||
|
||||
/** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */
|
||||
escAction() {
|
||||
if (this.buildWindow?.isOpen) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,645 @@
|
|||
/**
|
||||
* 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +142,11 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
* (GameScene.questSnapshot; polled while open, repaint on change)
|
||||
* @param {(questId: string) => void} [o.onClaim] — CLAIM REWARD pressed
|
||||
* (the scene verifies + pays out)
|
||||
* @param {(questId: string) => void} [o.onSetPriority] — SET PRIORITY
|
||||
* pressed (the tracker HUD's featured quest; the scene sets it on the
|
||||
* quest ledger + toasts — the tracker updates live)
|
||||
* @param {() => (string|null)} [o.getPriority] — the current priority
|
||||
* quest id (the detail plate's PRIORITY badge; a set/clear repaints)
|
||||
* @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
|
||||
* (the scene toasts)
|
||||
*/
|
||||
|
|
@ -154,12 +159,15 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
|
||||
this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null;
|
||||
this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null;
|
||||
this.onSetPriority = typeof o.onSetPriority === 'function' ? o.onSetPriority : null;
|
||||
this.getPriority = typeof o.getPriority === 'function' ? o.getPriority : null;
|
||||
this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
|
||||
|
||||
this.openState = 'closed'; // closed | opening | open | closing
|
||||
this.reveal = []; // { o, d, dur, mode, t0 }
|
||||
this.decodes = []; // { txt, dec } — polled ScrambleDecodes
|
||||
this.selected = null; // the selected quest id (the window's only state)
|
||||
this._pendingSelect = null; // openAt's quest — the reveal lands on it
|
||||
this._activeTab = 'main';
|
||||
this._tabs = [];
|
||||
this._rows = []; // the list's painted rows
|
||||
|
|
@ -737,7 +745,13 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
(q.checks ?? []).map((c) => (c.done ? 1 : 0)).join(''),
|
||||
(q.checks ?? []).map((c) => String(c.detail ?? '')).join('|').slice(0, 80),
|
||||
]);
|
||||
return JSON.stringify({ t: this._activeTab, s: this.selected, qs });
|
||||
let prio = null;
|
||||
try {
|
||||
prio = this.getPriority ? this.getPriority() : null;
|
||||
} catch {
|
||||
prio = null;
|
||||
}
|
||||
return JSON.stringify({ t: this._activeTab, s: this.selected, p: prio, qs });
|
||||
}
|
||||
|
||||
_teardownRows() {
|
||||
|
|
@ -1024,6 +1038,62 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
});
|
||||
}
|
||||
this._claimMode = mode;
|
||||
|
||||
// --- SET PRIORITY — the tracker HUD's featured quest (the green
|
||||
// accent). This quest IS the priority → a static "◆ PRIORITY" badge
|
||||
// (the player switches it by selecting ANOTHER quest's button);
|
||||
// otherwise the SET PRIORITY button. A CLAIMED quest gets no
|
||||
// priority control — it isn't active (the ledger's rule).
|
||||
let priorityId = null;
|
||||
try {
|
||||
priorityId = this.getPriority ? this.getPriority() : null;
|
||||
} catch {
|
||||
priorityId = null;
|
||||
}
|
||||
if (!quest.claimed) {
|
||||
const isPrio = priorityId === quest.id;
|
||||
const pw = 200;
|
||||
const px0 = claimX - 12 - pw;
|
||||
const py0 = claimY;
|
||||
const pg = add(s.graphics().setScrollFactor(0));
|
||||
pg.clear();
|
||||
panel(pg, px0, py0, pw, 34, {
|
||||
notch: 8,
|
||||
fill: isPrio ? 0x082010 : C.panel,
|
||||
fillAlpha: isPrio ? 0.7 : 0.5,
|
||||
stroke: C.green,
|
||||
strokeAlpha: isPrio ? 1 : 0.7,
|
||||
lineWidth: isPrio ? 2 : 1.5,
|
||||
glow: C.green,
|
||||
glowAlpha: isPrio ? 0.3 : 0.12,
|
||||
});
|
||||
add(
|
||||
s
|
||||
.text(px0 + pw / 2, py0 + 17, isPrio ? '◆ PRIORITY' : 'SET PRIORITY', {
|
||||
fontFamily: BODY,
|
||||
fontSize: '12px',
|
||||
color: toCss(isPrio ? C.green : C.ink),
|
||||
letterSpacing: 2,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0),
|
||||
);
|
||||
if (!isPrio) {
|
||||
pg.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Rectangle(px0, py0, pw, 34),
|
||||
hitAreaCallback: (area, px, py) => area.contains(px, py),
|
||||
});
|
||||
pg.on('pointerover', () => {
|
||||
if (this.isOpen) this.sfx('ui_hover');
|
||||
});
|
||||
pg.on('pointerdown', () => {
|
||||
if (!this.isOpen) return;
|
||||
this.sfx('ui_click');
|
||||
this.onSetPriority?.(quest.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_paintStatus() {
|
||||
|
|
@ -1075,6 +1145,33 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
this._startReveal(this.scene.time.now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open on a SPECIFIC quest (the tracker HUD's SHOW MORE / VIEW QUEST):
|
||||
* its tab becomes active and it becomes the selection — the reveal
|
||||
* (or, when the console is already open, a repaint) lands on it.
|
||||
*/
|
||||
openAt(questId) {
|
||||
if (typeof questId !== 'string' || questId === '') return;
|
||||
let snap = this._snap;
|
||||
if (!snap && typeof this.getSnapshot === 'function') {
|
||||
try {
|
||||
snap = this.getSnapshot();
|
||||
} catch {
|
||||
snap = null;
|
||||
}
|
||||
}
|
||||
const q = ((snap?.quests ?? [])).find((x) => x && x.id === questId) ?? null;
|
||||
if (!q) return;
|
||||
if (q.category) this._activeTab = q.category;
|
||||
if (this.openState === 'open') {
|
||||
this.selected = q.id;
|
||||
this._snap = snap ?? this._snap;
|
||||
this._paintAll(this._snap); // tab + list + plate follow the selection
|
||||
} else {
|
||||
this._pendingSelect = q.id; // the reveal (_startReveal) picks it up
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.openState === 'closed' || this.openState === 'closing') return;
|
||||
this.openState = 'closing';
|
||||
|
|
@ -1118,8 +1215,19 @@ export class QuestWindow extends Phaser.GameObjects.Container {
|
|||
push(this.listCont, 380, 300);
|
||||
push(this.detailCont, 440, 300);
|
||||
this.reveal = list;
|
||||
// re-select the first quest (fresh open)
|
||||
this._selectFirstForTab(this._activeTab);
|
||||
// the selection — a PENDING one (openAt — the tracker's SHOW MORE /
|
||||
// VIEW QUEST lands here) beats the tab's first quest (fresh open).
|
||||
if (typeof this._pendingSelect === 'string' && this._pendingSelect !== '') {
|
||||
const id = this._pendingSelect;
|
||||
this._pendingSelect = null;
|
||||
const q = ((this._snap?.quests ?? [])).find((x) => x && x.id === id) ?? null;
|
||||
if (q) {
|
||||
if (q.category) this._activeTab = q.category;
|
||||
this.selected = q.id;
|
||||
}
|
||||
} else {
|
||||
this._selectFirstForTab(this._activeTab);
|
||||
}
|
||||
// the glitch clock
|
||||
const g = config.get('quests.glitch', {});
|
||||
const [ia, ib] = g.intervalMs ?? [4200, 9200];
|
||||
|
|
|
|||
Loading…
Reference in New Issue