diff --git a/assets/images/originals/tresle.png b/assets/images/originals/tresle.png new file mode 100644 index 0000000..a92673d Binary files /dev/null and b/assets/images/originals/tresle.png differ diff --git a/assets/videos/quests.mp4 b/assets/videos/quests.mp4 new file mode 100644 index 0000000..28ddbd0 Binary files /dev/null and b/assets/videos/quests.mp4 differ diff --git a/data/actionbar.json b/data/actionbar.json index 360626b..872e6e4 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -1,5 +1,5 @@ { - "_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Six evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).", + "_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Seven evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).", "enabled": true, "height": 56, "margin": { "left": 20, "right": 20, "bottom": 12 }, @@ -15,6 +15,7 @@ { "id": "research", "label": "Research", "accent": "#00e5ff" }, { "id": "scan", "label": "Scan", "accent": "#ffc94d" }, { "id": "ship", "label": "Ship", "accent": "#7ce8a4" }, + { "id": "quests", "label": "Quests", "accent": "#ffc94d" }, { "id": "map", "label": "Map", "accent": "#c084fc" }, { "id": null, "label": null }, { "id": "menu", "label": "Menu", "accent": "#ff2d6f" } @@ -25,6 +26,7 @@ { "id": "shop", "label": "Shop", "labelActive": "Leave Shop", "accent": "#00e5ff" }, { "id": "build", "label": "Build", "accent": "#ffc94d" }, { "id": "ship", "label": "Ship", "accent": "#7ce8a4" }, + { "id": "quests", "label": "Quests", "accent": "#ffc94d" }, { "id": null, "label": null }, { "id": "takeoff", "label": "Take Off", "accent": "#7ce8a4" }, { "id": "menu", "label": "Menu", "accent": "#ff2d6f" } diff --git a/data/manifest.json b/data/manifest.json index bf0644e..15dbeeb 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -26,6 +26,7 @@ "signalCompass.json", "save.json", "mineralhud.json", - "map.json" + "map.json", + "quests.json" ] } diff --git a/data/quests.json b/data/quests.json new file mode 100644 index 0000000..ac0beda --- /dev/null +++ b/data/quests.json @@ -0,0 +1,83 @@ +{ + "_comment": "The QUESTS console (deck slot right of SHIP, main deck + surface deck). The window mirrors the RESEARCH/MAP consoles (js/ui/QuestWindow.js): a 2:3 archive feed (assets/videos/quests.mp4 — the same aspect as assets/videos/research.mp4; NO SIGNAL plate until the clip is authored) on the left, and on the right the category tabs (Main Story / Side Quests — side quests are not defined yet: the tab is a STANDBY socket), a THIN list of active quests, and the big detail plate below it: title, issuer, description, the requirement checklist, and the reward, with the CLAIM action. A quest's progress is computed LIVE from the run (research, lifetime minerals mined from asteroids, the home tether's level, discovery) — js/quests/QuestState.js only tracks who holds which quest and whether its reward has been claimed, and that is what gets saved.", + "enabled": true, + "title": "OPERATIONS DOSSIER", + "meta": "MISSION LOG // ACTIVE CONTRACTS", + "video": { + "file": "quests.mp4", + "_comment": "The archive feed (assets/videos/). quests.mp4 mirrors the 2:3 aspect of research.mp4 — fit the same way.", + "aspect": [2, 3] + }, + "sweep": { + "_comment": "The band that crawls down the feed (the research/map console's sweep, tuned for this window).", + "enabled": true, + "intervalMs": [5200, 9800], + "durationMs": [1400, 2200] + }, + "glitch": { + "enabled": true, + "intervalMs": [4200, 9200], + "durationMs": [110, 260], + "slices": [1, 4], + "maxOffset": 9 + }, + "tabs": [ + { + "id": "main", + "label": "Main Story", + "accent": "#00e5ff", + "_comment": "The campaign's spine. Live — the starter quest is granted on the run's first boot." + }, + { + "id": "side", + "label": "Side Quests", + "accent": "#ffc94d", + "standby": true, + "_comment": "The socket for side quests — deferred by design. Pressing it toasts the standby note until side quests are authored (quests.json → quests, category 'side')." + } + ], + "sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET", + "starterToast": { + "_comment": "The one-shot note when the starter quest lands on a fresh ledger (GameScene.create).", + "glyph": "◆", + "color": "#ffc94d", + "text": "NEW QUEST — {title}" + }, + "readyToast": { + "glyph": "◆", + "color": "#ffc94d", + "text": "QUEST READY — {title} · CLAIM ITS REWARD" + }, + "claimToast": { + "glyph": "✓", + "color": "#67e863", + "text": "REWARD CLAIMED — +{n} MINERALS" + }, + "claimDenyToast": { + "glyph": "✗", + "color": "#ff5c77", + "text": "REQUIREMENTS INCOMPLETE — {missing}" + }, + "quests": [ + { + "id": "start_your_journey", + "category": "main", + "starter": true, + "icon": "diamond", + "title": "Start Your Journey", + "description": "The universe is cold, and it ain't gonna do you any favors kid. The sooner you discover that, the sooner you'll be able to start taking care of yourself. And what better way to discover the universe, than to discover the universe.", + "issuer": "A Friend", + "_comment": "The first quest, granted on the run's first boot (js/quests/QuestState.js → starters). Its checklist is the run's opening arc — research, mine, build, discover. The checks are LIVE: 'research' resolves against data/research/exploration.json (the Tether Level 2 node), 'mineralsMined' against the run's lifetime asteroid mining (the Mining sequence's onOre), 'tetherBuilt' against the home world's standing tether level, 'discoveredWorld' against the discovery ledger (any discovered world besides the home).", + "checks": [ + { "type": "research", "category": "exploration", "node": "tether_l2", "label": "Research Tether Level 2" }, + { "type": "mineralsMined", "amount": 200, "label": "Mine 200 Minerals from asteroids" }, + { "type": "tetherBuilt", "planet": "home", "level": 2, "label": "Build a Level 2 Tether on your Home world" }, + { "type": "discoveredWorld", "count": 1, "label": "Discover another planet or space station" } + ], + "reward": { + "minerals": 200, + "_comment": "The completion reward — 200 Minerals, claimed via the CLAIM REWARD action (fits the ship's hold: ship.stats.mineralStorage)." + } + } + ] +} diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs new file mode 100644 index 0000000..db1c146 --- /dev/null +++ b/dev/quests.test.mjs @@ -0,0 +1,200 @@ +/** + * Quests data-layer test (dev tool, run with Node — no browser): + * + * node dev/quests.test.mjs + * + * Pins the contract behind the QUESTS console (the deck's QUESTS button + * — right of SHIP, flight deck + surface deck → js/ui/QuestWindow.js): + * - quests.json is registered in data/manifest.json; + * - the window's knobs are present (title, the 2:3 video feed — the + * same dimensions as the research feed, sweep/glitch, the two + * category tabs with SIDE marked standby, the toasts); + * - the starter quest is well-formed: Main Story, one starter flag, + * a non-empty checklist of KNOWN requirement types, a reward that + * fits the ship's hold; + * - the 'research' check points at a REAL node of its category's tree + * (data/research/.json — the check would never complete + * otherwise); + * - 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); + * - both command decks carry the QUESTS slot, right of SHIP. + */ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const dataDir = join(root, 'data'); +const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); + +const manifest = read('manifest.json'); +const quests = read('quests.json'); +const actionbar = read('actionbar.json'); +const ship = read('ship.json'); + +// The real code (config singleton → the quest helpers + the ledger). +const { config } = await import(join(root, 'js/config/Config.js')); +config.init({ quests, ship }); +const { QuestState, questDefs, questDef, starterQuestIds } = await import( + join(root, 'js/quests/QuestState.js') +); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; +const hex = /^#[0-9a-fA-F]{6}$/; + +// ---------------------------------------------------------------------- +// 1. Manifest registration +// ---------------------------------------------------------------------- +check('manifest registers quests.json', manifest.files.includes('quests.json')); + +// ---------------------------------------------------------------------- +// 2. quests.json — the window's knobs +// ---------------------------------------------------------------------- +check('quests: master switch present', typeof quests.enabled === 'boolean'); +check('quests: enabled (the console is live)', quests.enabled === true); +check('quests: title present', typeof quests.title === 'string' && quests.title.length > 0); +check('quests: video file named', typeof quests.video?.file === 'string' && quests.video.file.length > 0); +check('quests: video aspect is 2:3 (the research feed\u2019s dimensions)', + JSON.stringify(quests.video?.aspect) === JSON.stringify([2, 3])); +check('quests: sweep configured (interval + duration ranges)', + Array.isArray(quests.sweep?.intervalMs) && quests.sweep.intervalMs.length === 2 && + Array.isArray(quests.sweep?.durationMs) && quests.sweep.durationMs.length === 2); +check('quests: glitch configured', typeof quests.glitch?.enabled === 'boolean' && + Array.isArray(quests.glitch?.intervalMs) && Array.isArray(quests.glitch?.slices)); + +// ---------------------------------------------------------------------- +// 3. The category tabs — Main Story live, Side Quests the standby socket +// ---------------------------------------------------------------------- +const tabs = quests.tabs ?? []; +check('quests: two category tabs', tabs.length === 2); +const mainTab = tabs.find((t) => t.id === 'main'); +const sideTab = tabs.find((t) => t.id === 'side'); +check('quests: the MAIN tab is live', !!mainTab && mainTab.standby !== true && typeof mainTab.label === 'string' && hex.test(mainTab.accent ?? '')); +check('quests: the SIDE tab is the standby socket', !!sideTab && sideTab.standby === true && typeof sideTab.label === 'string' && hex.test(sideTab.accent ?? '')); +check('quests: the standby note is present', typeof quests.sideStandbyNote === 'string' && quests.sideStandbyNote.length > 0); + +// ---------------------------------------------------------------------- +// 4. The toasts — starter / ready / claim / claim-denied +// ---------------------------------------------------------------------- +for (const key of ['starterToast', 'readyToast', 'claimToast', 'claimDenyToast']) { + const t = quests[key]; + check(`quests: ${key} configured (glyph + text)`, !!t && typeof t.glyph === 'string' && typeof t.text === 'string' && hex.test(t.color ?? '')); +} + +// ---------------------------------------------------------------------- +// 5. The starter quest — well-formed, one starter, real checks, fair reward +// ---------------------------------------------------------------------- +check('quests: at least one quest defined', Array.isArray(quests.quests) && quests.quests.length >= 1); +const starters = (quests.quests ?? []).filter((q) => q.starter === true); +check('quests: exactly ONE starter quest', starters.length === 1); +const starter = starters[0]; +check('starter: sits in the MAIN story', starter?.category === 'main'); +check('starter: has id/title/description/issuer', typeof starter?.id === 'string' && typeof starter?.title === 'string' && typeof starter?.description === 'string' && typeof starter?.issuer === 'string'); +check('starter: icon names a research icon', typeof starter?.icon === 'string' && starter.icon.length > 0); +check('starter: a non-empty checklist', Array.isArray(starter?.checks) && starter.checks.length >= 1); +check('starter: every check has a type + a player-facing label', + (starter?.checks ?? []).every((c) => typeof c.type === 'string' && typeof c.label === 'string' && c.label.length > 0)); +check('starter: reward is a positive integer (minerals)', + Number.isInteger(starter?.reward?.minerals) && starter.reward.minerals > 0); +check('starter: reward fits the ship\u2019s hold', + starter.reward.minerals <= (ship.stats?.mineralStorage ?? 0)); + +// The requirement types the scene evaluates (GameScene._questCheckEval) +// — a typo here would be a checklist row that never completes. +const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']); +check('starter: every check type is a known live check', + (starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type))); + +// Each check shape — the parameters _questCheckEval reads. +for (const c of starter?.checks ?? []) { + if (c.type === 'research') { + check(`starter check "${c.label}": names category + node`, typeof c.category === 'string' && typeof c.node === 'string'); + // The node must exist in its category\u2019s tree — otherwise the + // requirement could never read DONE (the research.json registry + // knows which categories are static file trees). + const tree = read(`research/${c.category}.json`); + check(`starter check "${c.label}": the node exists in data/research/${c.category}.json`, + !!tree?.nodes && typeof tree.nodes[c.node] === 'object'); + } else if (c.type === 'mineralsMined') { + check(`starter check "${c.label}": a positive integer amount`, Number.isInteger(c.amount) && c.amount > 0); + } else if (c.type === 'tetherBuilt') { + check(`starter check "${c.label}": planet + level set`, + (c.planet === 'home' || typeof c.planet === 'string') && Number.isInteger(c.level) && c.level >= 1); + } else if (c.type === 'discoveredWorld') { + check(`starter check "${c.label}": a positive integer count`, Number.isInteger(c.count) && c.count >= 1); + } +} + +// ---------------------------------------------------------------------- +// 6. The quest ledger — QuestState (give/claim/save-restore) +// ---------------------------------------------------------------------- +check('helpers: questDefs() sees the defined quests', questDefs().length === (quests.quests ?? []).length); +check('helpers: questDef() finds the starter by id', questDef(starter.id)?.title === starter.title); +check('helpers: questDef() is null for an unknown id', questDef('no_such_quest') === null); +check('helpers: starterQuestIds() is exactly [the starter]', + JSON.stringify(starterQuestIds()) === JSON.stringify([starter.id])); + +{ + const st = new QuestState(); + check('ledger: fresh — nothing granted, nothing claimed', !st.isGranted(starter.id) && !st.isClaimed(starter.id)); + check('ledger: give() grants a known quest', st.give(starter.id) === true); + check('ledger: give() is idempotent', st.give(starter.id) === false); + check('ledger: give() ignores an unknown id', st.give('no_such_quest') === false && !st.isGranted('no_such_quest')); + check('ledger: claim() pays out once', st.claim(starter.id) === true && st.isClaimed(starter.id)); + check('ledger: claim() is idempotent', st.claim(starter.id) === false); + const fresh = new QuestState(); + check('ledger: claim() refuses a quest not held', fresh.claim(starter.id) === false && !fresh.isClaimed(starter.id)); +} + +// The save round-trip (SaveData captures toJSON(), prepareLoad reads fromJSON). +{ + const st = new QuestState(); + st.give(starter.id); + st.claim(starter.id); + const restored = QuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON()))); + check('ledger: save round-trip keeps granted + claimed', + restored.isGranted(starter.id) && restored.isClaimed(starter.id)); +} +// A corrupted save degrades — never throws, never pays out a quest the +// player does not hold. +{ + check('ledger: fromJSON(malformed) → empty ledger', (() => { + try { + const st = QuestState.fromJSON({ granted: 'x', claimed: [starter.id] }); + return !st.isGranted(starter.id) && !st.isClaimed(starter.id); + } catch { return false; } + })()); + check('ledger: fromJSON drops ids the data no longer defines', (() => { + const st = QuestState.fromJSON({ granted: ['no_such_quest'], claimed: ['no_such_quest'] }); + return st.granted.size === 0 && st.claimed.size === 0; + })()); + check('ledger: fromJSON keeps claimed ⊆ granted', (() => { + const st = QuestState.fromJSON({ granted: [starter.id], claimed: [] }); + return st.isGranted(starter.id) && !st.isClaimed(starter.id); + })()); +} + +// ---------------------------------------------------------------------- +// 7. The command decks — the QUESTS slot, right of SHIP (both decks) +// ---------------------------------------------------------------------- +for (const [deck, slots] of [ + ['flight', actionbar.buttons ?? []], + ['surface', actionbar.surface?.buttons ?? []], +]) { + const iShip = slots.findIndex((s) => s.id === 'ship'); + check(`actionbar (${deck}): the QUESTS slot sits right of the SHIP slot`, + iShip >= 0 && slots[iShip + 1]?.id === 'quests'); + check(`actionbar (${deck}): the QUESTS slot carries a label + accent`, (() => { + const q = slots.find((s) => s.id === 'quests'); + return q && typeof q.label === 'string' && q.label.length > 0 && hex.test(q.accent ?? ''); + })()); +} + +console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`); +process.exit(failures === 0 ? 0 : 1); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index eb9fd69..1ee96ea 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -284,13 +284,17 @@ check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'bool check('builds._template.requires is an array', Array.isArray(bt.requires)); // ---------------------------------------------------------------------- -// 6. Command deck — six evenly spaced slots, right order +// 6. Command deck — seven evenly spaced slots, right order +// (Quests joined the flight deck to the right of Ship — the +// mission dossier; the surface deck carries the same slot.) // ---------------------------------------------------------------------- const slots = actionbar.buttons ?? []; -check('actionbar: exactly six slots', slots.length === 6); -check('actionbar: slot ids in order (Research, Scan, Ship, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu'])); -check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu'])); -check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })()); +check('actionbar: exactly seven slots', slots.length === 7); +check('actionbar: slot ids in order (Research, Scan, Ship, Quests, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'quests', 'map', null, 'menu'])); +check('actionbar: labels (Research / Scan / Ship / Quests / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Quests', 'Map', null, 'Menu'])); +check('actionbar: the QUESTS slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'quests'; })()); +check('actionbar: the QUESTS slot carries a label + accent', (() => { const q = slots.find((s) => s.id === 'quests'); return q && typeof q.label === 'string' && hex.test(q.accent ?? ''); })()); +check('actionbar: the MAP slot sits right of the QUESTS slot', (() => { const iQ = slots.findIndex((s) => s.id === 'quests'); return iQ >= 0 && slots[iQ + 1]?.id === 'map'; })()); check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null)); check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number'); @@ -299,8 +303,9 @@ check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height = // the surface deck (SurfaceScene) — Shop for Research, Build, Take Off const sSlots = actionbar.surface?.buttons ?? []; -check('actionbar: surface deck has six slots', sSlots.length === 6); -check('actionbar: surface slot ids in order (Shop, Build, Ship, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', null, 'takeoff', 'menu'])); +check('actionbar: surface deck has seven slots', sSlots.length === 7); +check('actionbar: surface slot ids in order (Shop, Build, Ship, Quests, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', 'quests', null, 'takeoff', 'menu'])); +check('actionbar: the QUESTS slot is on the surface deck (right of SHIP)', (() => { const iShip = sSlots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && sSlots[iShip + 1]?.id === 'quests'; })()); check('actionbar: the BUILD slot is on the surface deck', sSlots.some((s) => s.id === 'build')); check('actionbar: the BUILD slot carries a label + accent', (() => { const b = sSlots.find((s) => s.id === 'build'); return b && typeof b.label === 'string' && hex.test(b.accent ?? ''); })()); diff --git a/js/quests/QuestState.js b/js/quests/QuestState.js new file mode 100644 index 0000000..5dacc20 --- /dev/null +++ b/js/quests/QuestState.js @@ -0,0 +1,103 @@ +/** + * The quest ledger — the run's quest STATE (pure; the data is + * data/quests.json). + * + * A quest's REQUIREMENTS are not tracked here — they are computed LIVE + * from the run when the scene snapshots the dossier (GameScene. + * questSnapshot): research progress, lifetime minerals mined from + * 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 + * + * …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. + */ +import { config } from '../config/Config.js'; + +/** @returns {Array} every quest defined (data/quests.json → quests) */ +export function questDefs() { + const list = config.get('quests.quests', []); + return Array.isArray(list) ? list : []; +} + +/** @returns {object|null} the quest definition with this id (or null) */ +export function questDef(id) { + return questDefs().find((q) => q && q.id === id) ?? null; +} + +/** @returns {string[]} the ids granted on a FRESH run (quests.json → starter) */ +export function starterQuestIds() { + return questDefs() + .filter((q) => q && q.starter === true) + .map((q) => q.id); +} + +/** + * A held-quest ledger. The ids come from data/quests.json (validated + * against it on load — a save pointing at a removed quest just drops it). + */ +export class QuestState { + constructor() { + this.granted = new Set(); + this.claimed = new Set(); + } + + /** + * Grant a quest (idempotent — re-granting one already held is a no-op). + * Unknown ids are ignored. @returns {boolean} true when NEWLY granted + */ + give(id) { + if (typeof id !== 'string' || id === '') return false; + if (!questDef(id)) return false; + if (this.granted.has(id)) return false; + this.granted.add(id); + return true; + } + + isGranted(id) { + return this.granted.has(id); + } + + isClaimed(id) { + return this.claimed.has(id); + } + + /** + * Mark a quest's reward as paid out (idempotent). Only quests that are + * held + fully satisfied should be claimed — the satisfaction is the + * scene's call (GameScene.claimQuest checks the live snapshot). + * @returns {boolean} true when NEWLY claimed + */ + claim(id) { + if (!this.granted.has(id)) return false; + if (this.claimed.has(id)) return false; + this.claimed.add(id); + return true; + } + + toJSON() { + return { granted: [...this.granted], claimed: [...this.claimed] }; + } + + /** + * Restore a ledger from a save record (SaveData prepareLoad). A + * malformed record yields an empty ledger rather than a throw — quests + * are a progression nicety, not a crash vector. + */ + static fromJSON(data) { + const st = new QuestState(); + if (!data || typeof data !== 'object') return st; + const grant = (arr) => (Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : []); + for (const id of grant(data.granted)) { + if (questDef(id)) st.granted.add(id); + } + for (const id of grant(data.claimed)) { + if (st.granted.has(id)) st.claimed.add(id); // claimed ⊆ granted + } + return st; + } +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 1a3c651..51c030e 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -27,6 +27,8 @@ * // the bright lanes) * research: ResearchState.toJSON() | null, * builds: BuildState.toJSON() | null, + * quests: QuestState.toJSON() | null, // held + claimed quest ids + * totalMined: number, // lifetime asteroid mining * playTimeMs } * * captureState(scene) — GameScene → record (the Save panel calls it) @@ -46,6 +48,7 @@ import { config } from '../config/Config.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { Discovery } from '../galaxy/Discovery.js'; import { Reputation } from '../reputation/Reputation.js'; +import { QuestState } from '../quests/QuestState.js'; import { SAVE_FORMAT, SaveManager } from './SaveManager.js'; export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; @@ -110,6 +113,16 @@ export function captureState(scene, now) { builds: scene.buildState ? scene.buildState.toJSON(buildNow) : null, + // Quests — the run's mission ledger: which quests are HELD (granted) + // and which rewards have been CLAIMED. Progress itself is NOT saved — + // it is computed live from the run's state (GameScene.questSnapshot). + // A save predating quests has no field; the restore stages null and + // the starter quest is granted onto the fresh ledger (old saves load). + quests: scene.questState ? scene.questState.toJSON() : null, + // Lifetime minerals mined from asteroids (the 'Mine 200 Minerals' + // requirement's input — the hold is cargo, this is the record). + // A save predating it has no field; the restore stages 0. + totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)), // The SYSTEM research category's activation keys (which jump gates — // incl. other systems' return gates — the run has activated; the // per-run world state GameScene keeps in its registry-backed set). @@ -196,6 +209,14 @@ export function prepareLoad(registry, record) { ? { systemId: record.destination.systemId, objectId: record.destination.objectId ?? null } : null, ); + // The quest ledger (a save predating quests has no field → null — the + // fresh run's GameScene starts an empty ledger and grants the starter + // quest; old saves load). The validation inside fromJSON keeps a + // corrupted ledger from breaking the load (claimed ⊆ granted, ids must + // exist in data/quests.json). + registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null); + // Lifetime asteroid mining (a save predating it has no field → 0). + registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0))); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], @@ -229,6 +250,8 @@ export function resetRunState(registry) { registry.set('usedGates', null); registry.set('destination', null); registry.set('routeNotice', null); + registry.set('quests', null); + registry.set('totalMined', null); registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index b99595b..8610ce9 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -33,6 +33,8 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js'; 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 { QuestState, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js'; import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; import { planRoute } from '../galaxy/Route.js'; @@ -201,6 +203,20 @@ export class GameScene extends Phaser.Scene { } } + // The quests console's mission feed (data/quests.json → video): a muted + // 2:3 loop behind the QUESTS window — the same dimensions as the + // research feed. Same contract — a missing file just leaves the + // window's NO SIGNAL plate up. + if (config.get('quests.enabled', true)) { + const questVideo = String(config.get('quests.video.file') ?? ''); + if (questVideo) { + const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/') + ? questVideo + : `assets/videos/${questVideo}`; + this.load.video(QuestWindow.VIDEO_KEY, url); + } + } + // The build console's feed (data/builds.json → video): a muted 2:3 // loop behind the BUILD window on a planet surface (SurfaceScene's // BuildWindow reads the shared cache). A missing file just leaves the @@ -656,7 +672,14 @@ export class GameScene extends Phaser.Scene { this.mining = new Mining(this, { onPhase: (p) => this.onMiningPhase(p), onEvent: (name, data) => this.onMiningEvent(name, data), - onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks + onOre: (gained) => { + this.refreshMineralHud(); // live: the hold fills as the rock shrinks + // Lifetime asteroid mining — the quest ledger's input (the + // 'Mine 200 Minerals from asteroids' requirement counts this, + // not the hold — the hold is cargo, this is the record). + this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0); + this._questRefresh(); + }, }); this.miningPopup = new MiningPopup(this, { onAction: (id, rock) => this.miningAction(id, rock), @@ -742,6 +765,44 @@ export class GameScene extends Phaser.Scene { }, ), }); + // ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ---- + // The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80): + // left the 2:3 mission feed (assets/videos/quests.mp4 — the same + // dimensions as the research feed), right the MAIN STORY / SIDE QUESTS + // tabs (side is a STANDBY socket — side quests are deferred by design), + // a THIN list of the active quests, and the big detail plate below: + // name, issuer, description, the requirement checklist, and the reward + // with the CLAIM action. The surface deck's QUESTS slot opens the same + // window over the paused GameScene's state (SurfaceScene.buildDeck). + // A quest's progress is computed LIVE (questSnapshot below — research, + // lifetime asteroid mining, the home tether's standing, the discovery + // ledger); the ledger itself — which quests are held and which rewards + // have been claimed — is the QuestState in the shared registry (like + // discovery/research) and rides the saves (record.quests, plus the + // lifetime counter record.totalMined the mining requirement reads). + this.questState = this.registry.get('quests') ?? null; + if (!this.questState) { + this.questState = new QuestState(); + this.registry.set('quests', this.questState); + } + this._questAnnounced = new Set(); // the 'QUEST READY' toasts, once each + this.questWindow = new QuestWindow(this, { + getSnapshot: () => this.questSnapshot(), + onClaim: (id) => this.claimQuest(id), + onLocked: (tabId) => + this.consoleToast( + tabId === 'side' + ? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET') + : 'NO SIGNAL', + { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) }, + ), + }); + // Lifetime minerals mined from asteroids (the 'Mine 200 Minerals' + // requirement's input — the hold is cargo, this is the ledger). Registry + // -backed like discovery: a load stages the saved value (prepareLoad), + // a fresh run starts at zero, and the New Game reset clears it. + this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0)); + // deck progress bar — drawn over the RESEARCH button while a project runs this._researchDeckBar = { g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false), @@ -782,6 +843,34 @@ export class GameScene extends Phaser.Scene { this.applyRestore(this._pendingRestore); this._pendingRestore = null; } + // The starter quest — granted ONCE onto a fresh ledger (a loaded save's + // ledger already holds whatever was granted back then — and give() is + // idempotent, so a jump-cut restart that rebuilds this scene off the + // registry's ledger stays silent). It is the run's opening arc: the + // checklist (data/quests.json) is the first loop — research, mine, + // build, discover. + if (config.get('quests.enabled', true) !== false) { + for (const id of starterQuestIds()) { + if (this.questState.give(id)) { + const def = questDef(id); + this.playSfx('discovery'); // the 'something new is here' voice + // The toast lands AFTER the opening 'DISCOVERED — HOME WORLD' + // toast (one toast slot — the later call replaces the earlier): + // ~2.4s is that toast's life, so +0.3s keeps the order readable. + this.time.delayedCall(2700, () => { + this.consoleToast( + String(config.get('quests.starterToast.text', 'NEW QUEST — {title}')) + .replace('{title}', String(def?.title ?? id).toUpperCase()), + { + glyph: config.get('quests.starterToast.glyph', '◆'), + glyphColor: toCss(themeColor('amber', 0xffc94d)), + durationMs: 3200, + }, + ); + }); + } + } + } // The field's native set (home + the activated gates) is above; the // tethers the player INSTALLED on this system's other worlds are // world state (built records) — re-anchor them on their worlds, since @@ -878,6 +967,10 @@ export class GameScene extends Phaser.Scene { if (this.researchWindow && this.researchWindow.isOpen) return; // The map console (depth 80) — the same full-screen contract. if (this.mapWindow && this.mapWindow.isOpen) return; + // The quests console (depth 80) — the same full-screen contract. + if (this.questWindow && this.questWindow.isOpen) return; + // The quests console (depth 80) — the same full-screen contract. + if (this.questWindow && this.questWindow.isOpen) return; // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of // its buttons — the buttons fire on their own pointerdown). ANY // click OUTSIDE — world, deck, HUD, compass — folds it back down @@ -1374,6 +1467,7 @@ export class GameScene extends Phaser.Scene { } this.researchWindow?.update(_time); // the console's living details (open state) this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll + this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll this._deckResearchBar(_time); // the RESEARCH slot's progress bar // Builds: a build started on a surface keeps running in space if the // player took off mid-build (game-loop clock — see beginBuild); when @@ -2387,6 +2481,7 @@ export class GameScene extends Phaser.Scene { glyphColor: toCss(neon), durationMs: 2400, }); + this._questRefresh(); // a 'Discover another world' requirement may have just landed } showTargetMarker(x, y) { @@ -2435,6 +2530,18 @@ export class GameScene extends Phaser.Scene { this.researchWindow?.open(); return; } + // The quests console (full-screen, depth 80) — the deck's third console. + if (id === 'quests') { + if (this.questWindow && this.questWindow.isOpen) { + this.questWindow.close(); + 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(); + this.questWindow?.open(); + return; + } // The map console (full-screen, depth 80) — the deck's other console. if (id === 'map') { if (this.mapWindow && this.mapWindow.isOpen) { @@ -2458,6 +2565,225 @@ export class GameScene extends Phaser.Scene { console.info(`[orbit] command deck: ${id}`); } + // ------------------------------------------------------------ quests + + /** + * The live dossier snapshot (QuestWindow polls it while open): + * the categories (tabs) + every HELD quest with its checklist + * evaluated against the run RIGHT NOW — research unlocks, lifetime + * asteroid mining, the home tether's standing, the discovery ledger. + * The window is a pure view over this; the scene owns the effects + * (claimQuest) — the same split as the research/map consoles. + * @returns {{categories: Array, quests: Array}|null} + */ + questSnapshot() { + if (!this.questState) return null; + const tabs = config.get('quests.tabs', []); + const categories = (Array.isArray(tabs) ? tabs : []).map((c) => ({ + id: String(c?.id ?? ''), + label: String(c?.label ?? c?.id ?? ''), + standby: c?.standby === true, + accent: c?.accent ?? null, + })); + const quests = []; + for (const def of questDefs()) { + if (!def || !this.questState.isGranted(def.id)) continue; + const checks = (Array.isArray(def.checks) ? def.checks : []).map((c) => this._questCheckEval(c)); + const doneCount = checks.filter((c) => c.done).length; + const complete = checks.length > 0 && doneCount === checks.length; + quests.push({ + id: def.id, + category: def.category ?? 'main', + title: def.title ?? def.id, + description: def.description ?? '', + issuer: def.issuer ?? '', + icon: def.icon ?? 'diamond', + checks, + reward: this._questRewardLabel(def), + complete, + claimed: this.questState.isClaimed(def.id), + progress: `${doneCount}/${checks.length}`, + }); + } + return { categories, quests }; + } + + /** + * One checklist requirement, evaluated LIVE (data/quests.json → + * quests[].checks[].type). Pure reads — no effects here. + * research — the node is unlocked (data/research/.json) + * mineralsMined — lifetime asteroid mining >= amount (this.totalMined) + * tetherBuilt — the world's tether standing >= level ('home' = the home world) + * discoveredWorld — discovered planets/stations (not the home world) >= count + */ + _questCheckEval(check) { + const label = String(check?.label ?? ''); + const type = String(check?.type ?? ''); + if (type === 'research') { + const cat = String(check.category ?? ''); + const node = String(check.node ?? ''); + const done = !!this.researchState?.isUnlocked?.(cat, node); + return { label, done, detail: done ? 'RESEARCHED' : 'PENDING' }; + } + if (type === 'mineralsMined') { + const amount = Math.max(0, Math.floor(Number(check.amount) || 0)); + const got = Math.min(amount, Math.floor(this.totalMined ?? 0)); + const done = amount > 0 && got >= amount; + return { label, done, detail: `${got}/${amount}` }; + } + if (type === 'tetherBuilt') { + const level = Math.max(1, Math.floor(Number(check.level) || 1)); + const name = check.planet === 'home' ? this.homeWorldName : String(check.planet ?? ''); + const have = Math.max(0, this.tetherLevelFor(name) ?? 0); + const done = have >= level; + return { label, done, detail: `L${have}/L${level}` }; + } + if (type === 'discoveredWorld') { + const count = Math.max(1, Math.floor(Number(check.count) || 1)); + const have = Math.min(count, this.discoveredWorldCount()); + const done = have >= count; + return { label, done, detail: `${have}/${count}` }; + } + return { label, done: false, detail: '??' }; + } + + /** + * How many DISCOVERED worlds are planets or space stations (the + * 'Discover another planet or space station' requirement). The home + * world never counts (it is the origin, not a discovery), and neither + * do asteroid clusters / jump gates (the requirement is a WORLD or a + * STATION). The ledger is per-system (Discovery), so the count walks + * every system the player has found something in: the current system's + * objects are the scene's own entities; other systems resolve their + * ids against the generated content (galaxy.contentOf — deterministic, + * cached once generated). + */ + discoveredWorldCount() { + const disc = this.discovery; + if (!disc || !disc.bySystem) return 0; + const cur = this.systemRecord?.id ?? null; + let n = 0; + for (const [sysId, ids] of disc.bySystem) { + let planetIds = null; + let stationIds = null; + if (sysId === cur) { + planetIds = new Set((this.systemPlanets ?? []).map((p) => p.discoveryId)); + stationIds = new Set((this.systemStations ?? []).map((st) => st.discoveryId)); + } else if (this.galaxy && typeof this.galaxy.contentOf === 'function') { + let content = null; + try { + content = this.galaxy.contentOf(sysId); + } catch { + content = null; + } + planetIds = new Set(((content?.planets ?? []).map((r) => r?.name)).filter(Boolean)); + stationIds = new Set( + ((content?.settlements ?? []).filter((s) => s?.anchor?.type === 'space').map((s) => s?.id)).filter(Boolean), + ); + } + if (!planetIds && !stationIds) continue; + for (const id of ids) { + if (!id || id === 'home') continue; + if (planetIds.has(id) || stationIds.has(id)) n++; + } + } + return n; + } + + /** The reward line for a quest def (data/quests.json → reward object). */ + _questRewardLabel(def) { + const rw = def?.reward; + if (rw && typeof rw === 'object') { + const parts = []; + if (typeof rw.minerals === 'number') parts.push(`${rw.minerals} MINERALS`); + if (typeof rw.credits === 'number') parts.push(`${rw.credits} CREDITS`); + return parts.length ? parts.join(' · ') : ''; + } + return String(rw ?? ''); + } + + /** + * The CLAIM REWARD action (QuestWindow's button → here). The window + * only offers the button when the checklist reads complete; this is + * the belt-and-braces pass — re-evaluates the live checklist and pays + * the reward exactly once (the ledger's claim() is idempotent, so a + * double-press or a re-open can never pay twice). The scene owns the + * effect (the minerals land in the hold); the ledger owns the record. + */ + claimQuest(id) { + if (!this.questState || !this.questState.isGranted(id)) return; + if (this.questState.isClaimed(id)) { + this.questWindow?.refresh(); + return; + } + const def = questDef(id); + if (!def) return; + const snap = this.questSnapshot(); + const q = snap?.quests?.find((x) => x.id === id); + if (!q || !q.complete) { + // Requirements still open — a refusal, in the console's voice. + const missing = (q?.checks ?? []).filter((c) => !c.done).map((c) => c.label.toUpperCase()); + const t = config.get('quests.claimDenyToast', {}); + this.consoleToast( + String(t.text ?? 'REQUIREMENTS INCOMPLETE — {missing}').replace('{missing}', (missing.slice(0, 2).join(' · ') || 'REQUIREMENTS')), + { + glyph: t.glyph ?? '✗', + glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)), + }, + ); + this.questWindow?.refresh(); + return; + } + if (!this.questState.claim(id)) return; // someone beat us to it — idempotent + // The reward: minerals into the hold (clamped to the ship's storage — + // addMinerals hands back what fit, so a full hold just wastes the + // excess, the game's rule for mined ore). + const rw = def.reward ?? {}; + if (typeof rw.minerals === 'number' && this.ship) { + this.ship.addMinerals(rw.minerals); + this.refreshMineralHud(); + } + this.playSfx('construct'); // the power-up tick — the reward lands + const t = config.get('quests.claimToast', {}); + this.consoleToast( + String(t.text ?? 'REWARD CLAIMED — +{n} MINERALS').replace('{n}', String(rw.minerals ?? 0)), + { + glyph: t.glyph ?? '✓', + glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)), + }, + ); + this.questWindow?.refresh(); + } + + /** + * A quest-relevant event happened (research completed, ore mined, a + * build finished, a world discovered, a reward claimed). Announce each + * quest that just became claimable ONCE (the console's voice), and kick + * the open console's poll so it repaints without waiting the interval. + */ + _questRefresh() { + if (!this.questState) return; + const snap = this.questSnapshot(); + if (snap) { + for (const q of snap.quests) { + if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) { + this._questAnnounced.add(q.id); + const t = config.get('quests.readyToast', {}); + this.consoleToast( + String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD') + .replace('{title}', String(q.title).toUpperCase()), + { + glyph: t.glyph ?? '◆', + glyphColor: t.color ? toCss(t.color) : toCss(themeColor('amber', 0xffc94d)), + durationMs: 3200, + }, + ); + } + } + } + this.questWindow?.refresh(); + } + /** * The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled * the same 500 ms the system chart is — js/ui/MapWindow.js). Pure data @@ -3013,6 +3339,7 @@ export class GameScene extends Phaser.Scene { glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); this.researchWindow?.refresh(); + this._questRefresh(); // a 'Research …' requirement may have just landed } /** @@ -3121,6 +3448,7 @@ export class GameScene extends Phaser.Scene { completeBuild(planetName, buildId) { const def = buildDefs()[buildId]; if (def?.effects) this._applyBuildEffects(planetName, def.effects); + this._questRefresh(); // a 'Build a Level 2 Tether' requirement may have just landed } /** @@ -3463,6 +3791,11 @@ export class GameScene extends Phaser.Scene { this.mapWindow.close(); return; } + // The quests console (depth 80) — the same contract. + if (this.questWindow && this.questWindow.isOpen) { + this.questWindow.close(); + return; + } // The mining context menu is the topmost open thing after the save UI. if (this.miningPopup && this.miningPopup.isOpen) { this.miningPopup.close(); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index f60d7a3..9a56405 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -10,6 +10,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { BuildWindow } from '../ui/BuildWindow.js'; +import { QuestWindow } from '../ui/QuestWindow.js'; import { buildDefs } from '../research/ResearchModel.js'; /** @@ -543,6 +544,24 @@ export class SurfaceScene extends Phaser.Scene { }) : null; + // The QUESTS console (the deck's QUESTS slot — right of SHIP): the + // SAME window as the flight deck's (js/ui/QuestWindow.js), opened + // over the SLEEPING GameScene's dossier — the same split as the build + // console (state + effects on the GameScene, the view here). The + // snapshot/claim delegates to the game scene; the console note is + // ours (we are the scene the player sees). + this.questWindow = new QuestWindow(this, { + getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null, + onClaim: (id) => this.claimSurfaceQuest(id), + onLocked: (tabId) => + this.consoleNote( + tabId === 'side' + ? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET') + : 'NO SIGNAL', + { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) }, + ), + }); + // ESC — topmost open thing first (mirror of GameScene.escAction). // (The pointerdown binding lives in create() — it must also be // live on the landing stage, before the deck exists.) @@ -573,6 +592,14 @@ export class SurfaceScene extends Phaser.Scene { this.buildWindow?.open(); return; } + if (id === 'quests') { + if (this.questWindow?.isOpen) { + this.questWindow.close(); + } else { + this.questWindow?.open(); + } + return; + } if (id === 'shop') { this.toggleShop(); return; @@ -652,12 +679,46 @@ export class SurfaceScene extends Phaser.Scene { playSfxOn(this, name, o); } - /** ESC: build console → confirm dialog → save pop-up → sub-bar. */ + /** + * The CLAIM REWARD action from the surface console — the effect lands + * on the SLEEPING GameScene (the quest ledger + the ship's hold are + * its; the beginBuild precedent), and the console note is ours (we are + * the scene the player sees — a sleeping scene's toasts don't animate). + */ + claimSurfaceQuest(id) { + const g = this.gameScene; + if (!g?.claimQuest) return; + const before = g.questState?.isClaimed(id) ?? false; + g.claimQuest(id); + const now = g.questState?.isClaimed(id) ?? false; + if (!before && now) { + const t = config.get('quests.claimToast', {}); + this.consoleNote(String(t.text ?? 'REWARD CLAIMED'), { + glyph: t.glyph ?? '✓', + glyphColor: t.color ? toCss(t.color) : undefined, + }); + } else if (before !== now) { + // Still open — the refusal, in the console's voice (the game + // scene's own toast is behind the sleeping scene — this is the one + // the player reads). + const t = config.get('quests.claimDenyToast', {}); + this.consoleNote( + String(t.text ?? 'REQUIREMENTS INCOMPLETE').replace('{missing}', 'REQUIREMENTS'), + { glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined }, + ); + } + } + + /** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */ escAction() { if (this.buildWindow?.isOpen) { this.buildWindow.close(); return; } + if (this.questWindow?.isOpen) { + this.questWindow.close(); + return; + } if (this.savePanel) { if (this.savePanel.confirm.isOpen) { this.savePanel.confirm.cancel(); @@ -769,6 +830,7 @@ export class SurfaceScene extends Phaser.Scene { this.lastClickT = now; if (this.buildWindow?.isOpen) return; // the console owns the click + if (this.questWindow?.isOpen) return; // the quests console owns the click if (this.savePanel?.isOpen) return; // the modal scrim owns the click if (this.menuSubBar?.isOpen) { if (this.menuSubBar.contains(pointer.x, pointer.y)) return; @@ -905,6 +967,7 @@ export class SurfaceScene extends Phaser.Scene { } } this.buildWindow?.update(time); + this.questWindow?.update(time); } shutdown() { diff --git a/js/ui/QuestWindow.js b/js/ui/QuestWindow.js new file mode 100644 index 0000000..22085af --- /dev/null +++ b/js/ui/QuestWindow.js @@ -0,0 +1,1295 @@ +/** + * QuestWindow — the full-screen QUESTS console (the deck's QUESTS button, + * main deck + surface deck). Same console as RESEARCH and MAP + * (js/ui/ResearchWindow.js, js/ui/MapWindow.js): + * + * ┌────────────────────────────────────────────────────────────────────┐ + * │ OPERATIONS DOSSIER ▌ MISSION LOG // ACTIVE CONTRACTS [✕] │ + * ├───────────────────────┬────────────────────────────────────────────┤ + * │ ● MISSION FEED // LIVE│ [ MAIN STORY ] [ SIDE QUESTS ▢ ] │ + * │ ┌─────────────────┐ │ ┌────────────────────────────────────────┐│ + * │ │ 2:3 video feed │ │ │ ◆ START YOUR JOURNEY 1/4││ + * │ │ (muted, looping) │ │ │ reward 200 minerals · main story ││ + * │ │ + scanlines │ │ ├────────────────────────────────────────┤│ + * │ │ + sweep band │ │ │ [icon] START YOUR JOURNEY ││ + * │ └─────────────────┘ │ │ ISSUED BY — A FRIEND · MAIN STORY ││ + * │ 1 ACTIVE · 0 READY… │ │ "The universe is cold, and it ain't…" ││ + * │ │ │ ✓ Research Tether Level 2 ││ + * │ │ │ ▢ Mine 200 Minerals (64/200) ││ + * │ │ │ ▢ Build a Level 2 Tether on Home ││ + * │ │ │ ▢ Discover another world ││ + * │ │ │ REWARD — 200 MINERALS││ + * │ │ │ [ CLAIM REWARD ]││ + * │ │ └────────────────────────────────────────┘│ + * └───────────────────────┴────────────────────────────────────────────┘ + * + * Left: the mission feed — assets/videos/quests.mp4, a 2:3 portrait (the + * same dimensions as research.mp4) that LOOPS MUTED while the console is + * open; the NO SIGNAL plate stands in until the clip is authored. + * Right: the category tabs — MAIN STORY (live) and SIDE QUESTS (a + * STANDBY socket — side quests are deferred by design) — a THIN list of + * the active quests, and below it the big detail plate: the quest's name, + * who/where it was issued from, its description, the requirement + * checklist, and its reward, with the CLAIM action. + * + * The window is a VIEW: quest progress is computed live by the scene + * (GameScene.questSnapshot — research, lifetime asteroid mining, the home + * tether's standing, the discovery ledger) and polled here every few + * hundred ms; the reward is paid out by the scene too (GameScene. + * claimQuest — it verifies the checklist before handing out the minerals). + * The window's only state is the selected quest. + * + * Cyberpunk dressing in the Research/Map idiom: cut-corner plates + * (CyberShape), decode text (ScrambleDecode), a crawling sweep band, an + * occasional RGB-split glitch burst, a breathing REC dot. + */ +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 { ScrambleDecode } from '../utils/Decode.js'; +import { playSfxOn } from '../utils/Sfx.js'; +import { CyberShape } from './CyberShape.js'; + +const HEADER = fontStack('header'); +const BODY = fontStack('body'); +const rand = (a, b) => a + Math.random() * (b - a); +const clamp01 = (v) => Math.min(1, Math.max(0, v)); +const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2); + +/** Theme palette (data/theme.json), resolved once at build time. */ +const C = { + ink: themeColor('ink', 0xeaf6ff), + dim: themeColor('dim', 0x7d92c4), + faint: themeColor('faint', 0x3d4c74), + neon: themeColor('neon', 0x00e5ff), + neon2: themeColor('neon2', 0xff2d6f), + amber: themeColor('amber', 0xffc94d), + green: themeColor('green', 0x67e863), + red: themeColor('red', 0xff5c77), + panel: themeColor('panel', 0x0a1120), + bg: themeColor('bg', 0x04060d), +}; + +/** + * Draw a cut-corner plate with its top-left at (x, y) into an existing + * Graphics (CyberShape.points is centred on (0,0) — translate by the + * plate centre, not the top-left). + */ +function panel(g, x, y, w, h, o = {}) { + const cx = x + w / 2; + const cy = y + h / 2; + const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map( + (p) => ({ x: p.x + cx, y: p.y + cy }) + ); + if (o.fill !== undefined) { + g.fillStyle(o.fill, o.fillAlpha ?? 1); + g.fillPoints(pts, true); + } + if (o.stroke !== undefined) { + g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1); + g.strokePoints(pts, true); + } + if (o.glow !== undefined) { + g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22); + g.strokePoints(pts, true); + } +} + +/** Corner brackets framing a rect (x, y = top-left). */ +function brackets(g, x, y, w, h, o = {}) { + const color = o.color ?? C.neon; + const alpha = o.alpha ?? 0.7; + const len = o.length ?? 14; + g.lineStyle(o.lineWidth ?? 2, color, alpha); + const x2 = x + w; + const y2 = y + h; + g.lineBetween(x, y, x + len, y); + g.lineBetween(x, y, x, y + len); + g.lineBetween(x2, y, x2 - len, y); + g.lineBetween(x2, y, x2, y + len); + g.lineBetween(x, y2, x + len, y2); + g.lineBetween(x, y2, x, y2 - len); + g.lineBetween(x2, y2, x2 - len, y2); + g.lineBetween(x2, y2, x2, y2 - len); +} + +/** Word-wrap a string to fit ~maxWidth px of 11px body text (~6.5px/char). */ +function wrapText(str, maxWidth) { + const words = String(str ?? '').split(/\s+/); + const maxChars = Math.max(8, Math.floor(maxWidth / 6.5)); + const lines = []; + let line = ''; + for (const w of words) { + if ((line ? line + ' ' + w : w).length > maxChars) { + if (line) lines.push(line); + line = w; + } else { + line = line ? line + ' ' + w : w; + } + } + if (line) lines.push(line); + return lines.join('\n'); +} + +export class QuestWindow extends Phaser.GameObjects.Container { + static VIDEO_KEY = 'quest_feed'; + + /** + * @param {Phaser.Scene} scene the owning scene (GameScene or SurfaceScene) + * @param {object} o + * @param {() => object|null} o.getSnapshot — the live dossier snapshot + * (GameScene.questSnapshot; polled while open, repaint on change) + * @param {(questId: string) => void} [o.onClaim] — CLAIM REWARD pressed + * (the scene verifies + pays out) + * @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit + * (the scene toasts) + */ + constructor(scene, o = {}) { + super(scene, 0, 0); + // v4 quirk: a directly-constructed GameObject must register itself. + this.scene.add.existing(this); + this.setScrollFactor(0); // UI — pinned to the screen + this.setDepth(80); // above save panel (70), sub-bar (60), deck (50) + + this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null; + this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : 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._activeTab = 'main'; + this._tabs = []; + this._rows = []; // the list's painted rows + this._detailKids = []; // the detail's painted children + this._tabShakes = []; + this._glitch = { until: 0, next: 0 }; + this._sweep = null; + this._pollNext = 0; + this._fp = null; // the last painted fingerprint + this._snap = null; // the last snapshot (kept fresh between polls) + this._destroyed = false; + + this._build(); + this.setVisible(true); + this.setAlpha(0); // hidden until open() — built once, shown on demand + } + + sfx(name) { + playSfxOn(this.scene, name); + } + + hasVideo(key) { + const c = this.scene.cache?.video; + return !!(c && typeof c.has === 'function' && c.has(key)); + } + + /** Decode text into `txt` starting at `t0` (driven by update()). */ + decodeTo(txt, str, t0, dur = 520) { + this.decodes = this.decodes.filter((d) => d.txt !== txt); + if (txt.text === str) return; + txt.setText(''); + this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) }); + } + + // ------------------------------------------------------------ geometry + _measure() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + const m = 10; + const pad = 14; + const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m }; + const titleH = 46; + const bodyY = rect.y + titleH + pad; + const bodyH = rect.h - titleH - 2 * pad; + // Left: the mission feed — 2:3 (data/quests.json → video.aspect), the + // same frame as the research console's. + const a = config.get('quests.video.aspect', [2, 3]); + const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3; + const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8); + const videoW = Math.max(90, videoH * ar); + const leftX = rect.x + pad; + const leftW = videoW + 30; + // Right: tabs → the THIN active-quest list → the detail plate below. + const rightX = leftX + leftW + pad; + const rightW = rect.x + rect.w - pad - rightX; + const tabH = 38; + const tabGap = 12; + const rowH = 44; + const rowPad = 6; + // Thin by design (the detail plate owns the space below): a fixed + // three-row plate (the list shows at most three held quests). + const listH = 24 + rowH * 3 + rowPad; + const listY = bodyY + tabH + tabGap; + const detailY = listY + listH + tabGap; + const detailH = rect.y + rect.h - pad - detailY; + return { + rect, titleH, bodyY, bodyH, + leftX, leftW, videoW, videoH, + rightX, rightW, tabH, tabGap, rowH, rowPad, + listY, listH, detailY, detailH, + }; + } + + _build() { + const s = this.scene.add; + const geo = this._measure(); + this.geo = geo; + + // window background + const bg = s.graphics().setScrollFactor(0); + panel(bg, geo.rect.x, geo.rect.y, geo.rect.w, geo.rect.h, { + notch: 16, + fill: 0x04060d, + fillAlpha: 0.94, + stroke: 0x1b3a5a, + strokeAlpha: 0.65, + }); + this.add(bg); + + // title: QUESTS ▌ + meta (decoded on open) + const titleY = geo.rect.y + 12; + const title = String(config.get('quests.title', 'OPERATIONS DOSSIER')); + this.titleTxt = s + .text(geo.rect.x + 14, titleY, '', { + fontFamily: HEADER, + fontSize: '19px', + color: toCss(C.ink), + fontStyle: 'bold', + }) + .setScrollFactor(0); + this.add(this.titleTxt); + this.titleGhostA = s + .text(geo.rect.x + 14, titleY, '', { + fontFamily: HEADER, + fontSize: '19px', + color: toCss(C.neon), + fontStyle: 'bold', + }) + .setScrollFactor(0) + .setAlpha(0); + this.add(this.titleGhostA); + this.titleGhostB = s + .text(geo.rect.x + 14, titleY, '', { + fontFamily: HEADER, + fontSize: '19px', + color: toCss(C.neon2), + fontStyle: 'bold', + }) + .setScrollFactor(0) + .setAlpha(0); + this.add(this.titleGhostB); + const tw = Math.max(150, 8 + title.length * 12); + this.titleMeta = s + .text(geo.rect.x + 18 + tw, titleY + 6, String(config.get('quests.meta', 'MISSION LOG // ACTIVE CONTRACTS')), { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }) + .setScrollFactor(0); + this.add(this.titleMeta); + + // close button + const csize = 30; + const cx = geo.rect.x + geo.rect.w - csize - 14; + const cy = geo.rect.y + 11; + this.closeG = s.graphics().setScrollFactor(0); + this.add(this.closeG); + this._paintClose(false); + this.closeTxt = s + .text(cx + csize / 2, cy + csize / 2, '×', { + fontFamily: BODY, + fontSize: '14px', + color: toCss(C.ink), + }) + .setOrigin(0.5) + .setScrollFactor(0); + this.add(this.closeTxt); + // v4 input quirk: an object only becomes a hit-test candidate when it + // willRender() — and alpha 0 fails that, so a transparent hit-rect is + // NEVER clicked (verified in-browser). The working idiom (ActionBar + // slots, the Research/Map close buttons) carries the interactivity on + // the VISIBLE painted object with an explicit hitArea that repaints + // never disturb — this one too. Hover repaints the SAME graphics + // (swapping it out would orphan the handlers). + this.closeG.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(cx, cy, csize, csize), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + this.closeG.on('pointerover', () => { + if (!this.isOpen) return; + this.sfx('ui_hover'); + this._paintClose(true); + }); + this.closeG.on('pointerout', () => { + this._paintClose(false); + }); + this.closeG.on('pointerdown', () => { + if (this.isOpen) this.close(); + }); + + // the left feed + the right column + this.videoPanel = this._buildVideoPanel(); + this.add(this.videoPanel); + this._buildTabs(); + this.listCont = this._buildListPlate(); + this.add(this.listCont); + this.detailCont = this._buildDetailPlate(); + this.add(this.detailCont); + + // full-window scanlines (the ResearchWindow idiom) + const scanKey = 'quest_scanlines'; + if (!this.scene.textures.exists(scanKey)) { + const c = document.createElement('canvas'); + c.width = 4; + c.height = 8; + const ctx = c.getContext('2d'); + ctx.fillStyle = 'rgba(2,6,12,0.5)'; + ctx.fillRect(0, 0, 4, 3); + ctx.fillStyle = 'rgba(120,220,255,0.05)'; + ctx.fillRect(0, 4, 4, 1); + this.scene.textures.addCanvas(scanKey, c); + } + this.scan = s + .tileSprite(geo.rect.x + geo.rect.w / 2, geo.rect.y + geo.rect.h / 2, geo.rect.w, geo.rect.h, scanKey) + .setScrollFactor(0) + .setAlpha(0.5); + this.add(this.scan); + + // the glitch layer (RGB-split slices over the feed + the title split) + this.glitchG = s.graphics().setScrollFactor(0); + this.add(this.glitchG); + + this._paintAll(null); // the empty state until the first snapshot + } + + // ------------------------------------------------------------ video panel + /** Repaint the close plate (one persistent graphics object — its + * interactivity must never be swapped out under the handlers). */ + _paintClose(hot) { + const csize = 30; + const cx = this.geo.rect.x + this.geo.rect.w - csize - 14; + const cy = this.geo.rect.y + 11; + const g = this.closeG; + g.clear(); + g.lineStyle(1.5, C.neon, hot ? 0.9 : 0.55); + g.strokeRect(cx, cy, csize, csize); + g.lineStyle(2, hot ? C.ink : C.dim, 1); + g.lineBetween(cx + 9, cy + 9, cx + csize - 9, cy + csize - 9); + g.lineBetween(cx + csize - 9, cy + 9, cx + 9, cy + csize - 9); + } + + _buildVideoPanel() { + const { leftX, bodyY, leftW, videoW, videoH } = this.geo; + const s = this.scene.add; + const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY); + cont.setScrollFactor(0); + + // header: ● MISSION FEED // LIVE + this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0); + cont.add(this.recDot); + cont.add(s + .text(20, 6, 'MISSION FEED // LIVE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }) + .setScrollFactor(0)); + + // frame + the clip + const vx = (leftW - videoW) / 2; + const vy = 26; + const frameG = s.graphics().setScrollFactor(0); + frameG.clear(); + brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 }); + frameG.lineStyle(1, 0x14324f, 0.55); + frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2); + frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2); + cont.add(frameG); + + const vcx = vx + videoW / 2; + const vcy = vy + videoH / 2; + const vrect = { x: vx, y: vy, w: videoW, h: videoH }; + + // The NO SIGNAL plate is ALWAYS the base layer: the asset is planned + // but not authored yet, and even a file that 404s still registers its + // key in the video cache (cache.video.has() can't tell the two apart), + // so the plate is the only reliable fallback. The live clip simply + // covers it the moment it is decoded. + this._paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy); + this.video = null; + if (this.hasVideo(QuestWindow.VIDEO_KEY)) { + const v = s.video(0, 0, QuestWindow.VIDEO_KEY); + v.setOrigin(0.5); + v.setScrollFactor(0); + // The feed is ambience: muted + looping, as the console requires. + v.setVolume(0); + v.setLoop(true); + // v4's setVolume() only sets el.volume — the browser's autoplay + // policy looks at el.muted. Mute it properly. + if (v.video) v.video.muted = true; + v.setVisible(false); // the plate stands in until 'created' + const fit = (vv, iw = 0, ih = 0) => { + const el = vv.video; + const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544; + const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800; + const sc = Math.min(videoW / vw, videoH / vh); + vv.setPosition(vcx, vcy); + vv.setScale(sc); + }; + fit(v); + const ready = (vv, w, h) => { + if (vv !== v || v.destroyed) return; + fit(vv, w, h); + vv.setVisible(true); + }; + v.on('created', ready); + v.on('error', () => { + // The file 404'd or wouldn't decode — the plate stays up and the + // console still works (the game's rule for every feed window). + if (v.destroyed) return; + if (this.video === v) this.video = null; + v.destroy(); + }); + cont.add(v); + this.video = v; + } + + // scanlines over the feed + const scanKey = 'quest_feed_scanlines'; + if (!this.scene.textures.exists(scanKey)) { + const c = document.createElement('canvas'); + c.width = 4; + c.height = 8; + const ctx = c.getContext('2d'); + ctx.fillStyle = 'rgba(2,6,12,0.5)'; + ctx.fillRect(0, 0, 4, 3); + ctx.fillStyle = 'rgba(120,220,255,0.05)'; + ctx.fillRect(0, 4, 4, 1); + this.scene.textures.addCanvas(scanKey, c); + } + cont.add(s.tileSprite(vcx, vcy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5)); + + // the sweep band (crawls down the feed on a loop) + const swKey = 'quest_sweep'; + if (!this.scene.textures.exists(swKey)) { + const c = document.createElement('canvas'); + c.width = 8; + c.height = 64; + const ctx = c.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, 0, 64); + grad.addColorStop(0, 'rgba(127,223,255,0)'); + grad.addColorStop(0.5, 'rgba(127,223,255,0.5)'); + grad.addColorStop(1, 'rgba(127,223,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 8, 64); + this.scene.textures.addCanvas(swKey, c); + } + this.sweepBand = s + .image(vcx, vrect.y - 40, swKey) + .setDisplaySize(videoW, 48) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD); + cont.add(this.sweepBand); + this.sweepCfg = config.get('quests.sweep', {}); + + // bottom status strip — the live dossier counts + const sy = vy + videoH + 14; + const strip = s.graphics().setScrollFactor(0); + strip.clear(); + strip.lineStyle(1, 0x14324f, 0.8); + strip.lineBetween(2, sy, leftW - 2, sy); + strip.fillStyle(C.neon, 0.7); + strip.fillRect(2, sy - 1, 26, 2); + cont.add(strip); + this.statusTxt = s + .text(2, sy + 10, 'SIGNAL STANDBY', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setScrollFactor(0); + cont.add(this.statusTxt); + this.statusBar = s.graphics().setScrollFactor(0); + this.statusBarY = sy + 32; + this.statusBarW = Math.max(40, leftW - 60); + cont.add(this.statusBar); + + this.videoCx = vcx; + this.videoCy = vcy; + this.videoRect = vrect; + return cont; + } + + /** The 'NO SIGNAL' plate for the mission feed: drawn as the base layer + * of the feed frame (see _buildVideoPanel) so a missing/failed clip + * never leaves a dead hole. */ + _paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy) { + const ph = s.graphics().setScrollFactor(0); + ph.clear(); + panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 }); + for (let i = 0; i < 40; i++) { + ph.fillStyle(C.neon, rand(0.02, 0.08)); + ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1); + } + cont.add(ph); + cont.add(s.text(vcx, vcy - 8, 'NO SIGNAL', { + fontFamily: HEADER, + fontSize: '14px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 5, + align: 'center', + }).setScrollFactor(0)); + cont.add(s.text(vcx, vcy + 12, 'MISSION FEED OFFLINE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 3, + align: 'center', + }).setScrollFactor(0)); + } + + // ------------------------------------------------------------ tabs + _buildTabs() { + const s = this.scene.add; + const { rightX, bodyY, rightW, tabH } = this.geo; + const defs = config.get('quests.tabs', []); + const arr = Array.isArray(defs) && defs.length ? defs : [ + { id: 'main', label: 'Main Story' }, + { id: 'side', label: 'Side Quests', standby: true }, + ]; + const tabW = 170; + const gap = 10; + let x = rightX + Math.max(0, (rightW - (arr.length * tabW + (arr.length - 1) * gap)) / 2); + for (const t of arr) { + const id = String(t.id ?? ''); + const label = String(t.label ?? id).toUpperCase(); + const standby = t.standby === true; + const g = s.graphics().setScrollFactor(0); + const txt = s + .text(0, 0, label, { + fontFamily: BODY, + fontSize: '12px', + color: toCss(C.dim), + letterSpacing: 2, + }) + .setOrigin(0.5) + .setScrollFactor(0); + const tab = { id, label, standby, g, txt, x, w: tabW }; + // v4 input: the hit target must willRender() (alpha 0 fails) — the + // interactive lives on the visible plate, the ActionBar/Research idiom. + g.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(x, bodyY, tabW, tabH), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + if (!this.isOpen) return; + this.sfx('ui_hover'); + this._paintTab(tab, this._activeTab === tab.id ? 'active' : standby ? 'standby' : 'hot'); + }); + g.on('pointerout', () => { + this._paintTab(tab, this._activeTab === tab.id ? 'active' : standby ? 'standby' : 'idle'); + }); + g.on('pointerdown', () => { + if (!this.isOpen) return; + if (standby) { + this.sfx('ui_click'); + this._shakeTab(tab); + this.onLocked?.(tab.id); + return; + } + if (this._activeTab === tab.id) return; + this._activeTab = tab.id; + this.sfx('ui_click'); + this._paintTabs(); + this._selectFirstForTab(tab.id); + }); + this._paintTab(tab, standby ? 'standby' : id === 'main' ? 'active' : 'idle'); + this.add(g); + this.add(txt); + this._tabs.push(tab); + x += tabW + gap; + } + } + + /** One tab's visual state: 'active' | 'idle' | 'hot' | 'standby'. */ + _paintTab(tab, mode) { + const { g, txt } = tab; + const { x, w } = tab; + const y = this.geo.bodyY; + const h = this.geo.tabH; + g.clear(); + let stroke, strokeA, fillA, col; + if (mode === 'active') { + stroke = C.amber; strokeA = 0.95; fillA = 0.16; col = C.ink; + } else if (mode === 'hot') { + stroke = C.neon; strokeA = 0.7; fillA = 0.08; col = C.ink; + } else if (mode === 'standby') { + stroke = 0x14324f; strokeA = 0.8; fillA = 0.25; col = C.faint; + } else { + stroke = 0x14324f; strokeA = 0.9; fillA = 0.5; col = C.dim; + } + panel(g, x, y, w, h, { notch: 8, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA }); + if (mode === 'active') { + g.fillStyle(C.amber, 0.9); + g.fillRect(x + 10, y + h - 4, w - 20, 2); + } + if (tab.standby) { + // the standby square marker (the socket is not live) + g.lineStyle(1, C.faint, 0.9); + g.strokeRect(x + 12, y + 14, 8, 8); + } + txt.color = toCss(col); + txt.setText(tab.standby ? ' ' + tab.label : tab.label); + txt.x = x + w / 2 + (tab.standby ? 6 : 0); + txt.y = y + h / 2; + } + + _paintTabs() { + for (const t of this._tabs) { + this._paintTab(t, t.standby ? 'standby' : this._activeTab === t.id ? 'active' : 'idle'); + } + } + + /** The SIDE tab's rejected press — a shake (the map's STANDBY tabs do this). */ + _shakeTab(tab) { + const t0 = this.scene.time.now; + const x0 = tab.txt.x; + const y0 = tab.txt.y; + this._tabShakes.push({ tab, t0, x: x0, y: y0 }); + } + + // ------------------------------------------------------------ the quest list + _buildListPlate() { + const s = this.scene.add; + const { rightX, listY, rightW, listH } = this.geo; + const g = s.graphics().setScrollFactor(0); + g.clear(); + panel(g, rightX, listY, rightW, listH, { + notch: 8, + fill: 0x060b16, + fillAlpha: 0.85, + stroke: 0x14324f, + strokeAlpha: 0.9, + }); + const cont = new Phaser.GameObjects.Container(this.scene, 0, 0); + cont.add(g); + cont.add(s + .text(rightX + 12, listY + 7, 'ACTIVE QUESTS', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.faint), + letterSpacing: 2.5, + }) + .setScrollFactor(0)); + return cont; + } + + // ------------------------------------------------------------ the detail plate + _buildDetailPlate() { + const s = this.scene.add; + const { rightX, detailY, rightW, detailH } = this.geo; + const g = s.graphics().setScrollFactor(0); + g.clear(); + panel(g, rightX, detailY, rightW, detailH, { + notch: 10, + fill: 0x050a13, + fillAlpha: 0.92, + stroke: 0x14324f, + strokeAlpha: 0.9, + glow: C.neon, + glowAlpha: 0.08, + }); + const cont = new Phaser.GameObjects.Container(this.scene, 0, 0); + cont.add(g); + return cont; + } + + // ------------------------------------------------------------ painting + /** + * Paint everything from a snapshot (or null = the empty state). + * snapshot: { categories: [...], quests: [...] } (GameScene.questSnapshot) + */ + _paintAll(snap) { + this._snap = snap ?? null; + this._paintTabs(); + this._paintList(); + this._paintDetail(); + this._paintStatus(); + } + + /** The fingerprint of what is painted — poll repaints only on change. */ + _fpOf(snap) { + if (!snap) return 'null'; + const qs = (snap.quests ?? []).map((q) => [ + q.id, + q.claimed ? 1 : 0, + q.complete ? 1 : 0, + (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 }); + } + + _teardownRows() { + for (const r of this._rows) { + if (r.g) { try { r.g.destroy(); } catch { /* already gone */ } } + if (r.txt) { try { r.txt.destroy(); } catch { /* already gone */ } } + if (r.sub) { try { r.sub.destroy(); } catch { /* already gone */ } } + if (r.rgt) { try { r.rgt.destroy(); } catch { /* already gone */ } } + } + this._rows = []; + } + + _paintList() { + const s = this.scene.add; + const { rightX, listY, rightW, rowH } = this.geo; + this._teardownRows(); + const quests = (this._snap?.quests ?? []).filter((q) => q.category === this._activeTab); + if (quests.length === 0) { + const msg = this._activeTab === 'side' + ? 'NO CONTRACTS ON FILE — SIDE QUESTS OFFLINE' + : 'NO ACTIVE QUESTS — THE DOSSIER IS EMPTY'; + const txt = s + .text(rightX + 14, listY + 30, msg, { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setScrollFactor(0); + this.add(txt); + this._rows.push({ txt }); + return; + } + let y = listY + 24; + for (const q of quests.slice(0, 3)) { + const sel = this.selected === q.id; + const g = s.graphics().setScrollFactor(0); + g.clear(); + // row plate + panel(g, rightX + 8, y, rightW - 16, rowH - 6, { + notch: 6, + fill: sel ? 0x0a1626 : 0x070d1a, + fillAlpha: sel ? 0.95 : 0.7, + stroke: sel ? C.amber : 0x14324f, + strokeAlpha: sel ? 0.9 : 0.6, + }); + // the status glyph (claimed ● / ready ◆ / active ◌) + const gx = rightX + 26; + const gy = y + (rowH - 6) / 2; + if (q.claimed) { + g.fillStyle(C.faint, 0.9); + g.fillCircle(gx, gy, 4); + } else if (q.complete) { + g.fillStyle(C.amber, 1); + g.fillTriangle(gx, gy - 6, gx + 5.5, gy + 4.5, gx - 5.5, gy + 4.5); + } else { + g.lineStyle(1.5, C.neon, 0.8); + g.strokeCircle(gx, gy, 5); + } + const doneN = (q.checks ?? []).filter((c) => c.done).length; + const txt = s + .text(rightX + 44, y + 7, String(q.title ?? '').toUpperCase(), { + fontFamily: BODY, + fontSize: '12px', + color: toCss(sel ? C.ink : C.dim), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + const sub = s + .text(rightX + 44, y + 24, + q.claimed + ? 'REWARD CLAIMED' + : q.complete + ? 'READY — CLAIM ITS REWARD' + : `${doneN}/${(q.checks ?? []).length} REQUIREMENTS · REWARD ${String(q.reward ?? '')}`, { + fontFamily: BODY, + fontSize: '9px', + color: toCss(q.claimed ? C.faint : q.complete ? C.amber : C.faint), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + const rgt = s + .text(rightX + rightW - 22, y + (rowH - 6) / 2, + q.claimed ? '✓' : q.complete ? 'READY' : `${doneN}/${(q.checks ?? []).length}`, { + fontFamily: BODY, + fontSize: '12px', + color: toCss(q.claimed ? C.faint : q.complete ? C.amber : C.dim), + }) + .setOrigin(1, 0.5) + .setScrollFactor(0); + // v4 input: the row PLATE (visible) is the hit target — alpha-0 + // stand-in rects are never hit-tested in this build. + g.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(rightX + 8, y, rightW - 16, rowH - 6), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + if (this.isOpen) this.sfx('ui_hover'); + }); + g.on('pointerdown', () => { + if (!this.isOpen) return; + this.selected = q.id; + this.sfx('ui_click'); + this._paintList(); + this._paintDetail(); + }); + this.add(g); + this.add(txt); + this.add(sub); + this.add(rgt); + this._rows.push({ g, txt, sub, rgt }); + y += rowH; + } + } + + _paintDetail() { + const s = this.scene.add; + const { rightX, detailY, rightW, detailH } = this.geo; + // tear down the previous detail's children + for (const k of this._detailKids) { + try { k.destroy(); } catch { /* already gone */ } + } + this._detailKids = []; + const add = (o) => { + this._detailKids.push(o); + this.add(o); + return o; + }; + + const quests = this._snap?.quests ?? []; + const quest = quests.find((q) => q.id === this.selected) ?? quests[0] ?? null; + if (!quest) { + add(s.text(rightX + 20, detailY + 30, 'NO QUEST ON FILE — STAND BY', { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.faint), + letterSpacing: 2, + }).setScrollFactor(0)); + this._claimMode = 'off'; + return; + } + if (!this.selected) this.selected = quest.id; // default to the first held quest + + const pad = 16; + const x0 = rightX + pad; + const contentW = rightW - pad * 2; + let y = detailY + pad; + + // the icon plate (left) — the quest's sigil + const box = 56; + const bx = x0; + const by = y; + const boxG = add(s.graphics().setScrollFactor(0)); + boxG.clear(); + brackets(boxG, bx - 4, by - 4, box + 8, box + 8, { length: 10, color: C.neon, alpha: 0.6 }); + panel(boxG, bx, by, box, box, { notch: 6, fill: 0x040a14, fillAlpha: 0.9, stroke: 0x14324f, strokeAlpha: 0.6 }); + const ccx = bx + box / 2; + const ccy = by + box / 2; + boxG.fillStyle(C.amber, 0.95); + boxG.fillTriangle(ccx, ccy - 14, ccx + 10, ccy, ccx - 10, ccy); + boxG.fillTriangle(ccx, ccy + 14, ccx + 10, ccy, ccx - 10, ccy); + boxG.fillStyle(C.amber, 0.35); + boxG.fillCircle(ccx, ccy, 4); + + // title (right of the icon) + const tx = bx + box + 16; + add(s.text(tx, by, String(quest.title ?? '').toUpperCase(), { + fontFamily: HEADER, + fontSize: '17px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 1, + }).setScrollFactor(0)); + + // the issuer line — who / where the quest was issued from + const catLabel = (this._snap?.categories ?? []).find((c) => c.id === quest.category)?.label ?? ''; + add(s.text(tx, by + 26, + `ISSUED BY — ${String(quest.issuer ?? 'UNKNOWN').toUpperCase()} · ${String(catLabel).toUpperCase()}`, { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.dim), + letterSpacing: 2, + }).setScrollFactor(0)); + + // the reward chip (top-right of the detail) + add(s.text(rightX + rightW - pad, by + 4, `REWARD — ${String(quest.reward ?? '')}`, { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.amber), + letterSpacing: 1.5, + }).setOrigin(1, 0).setScrollFactor(0)); + + // the description (word-wrapped) + y = by + box + 16; + const desc = add(s.text(x0, y, wrapText(String(quest.description ?? ''), contentW), { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.dim), + lineSpacing: 5, + letterSpacing: 0.5, + }).setScrollFactor(0)); + const descLines = Math.max(1, desc.text ? desc.text.split('\n').length : 1); + y += descLines * 17 + 12; + + // the requirement checklist + for (const c of quest.checks ?? []) { + const cg = add(s.graphics().setScrollFactor(0)); + cg.clear(); + const cy = y + 7; + if (c.done) { + cg.fillStyle(C.green, 0.9); + cg.fillRect(x0, cy - 6, 12, 12); + cg.lineStyle(2, 0x04060d, 1); + cg.lineBetween(x0 + 2.5, cy + 0.5, x0 + 5, cy + 3.5); + cg.lineBetween(x0 + 5, cy + 3.5, x0 + 9.5, cy - 3.5); + } else { + cg.lineStyle(1.5, C.neon, 0.7); + cg.strokeRect(x0, cy - 6, 12, 12); + } + add(s.text(x0 + 22, y, String(c.label ?? '').toUpperCase(), { + fontFamily: BODY, + fontSize: '11px', + color: toCss(c.done ? C.ink : C.dim), + letterSpacing: 1, + }).setScrollFactor(0)); + if (c.detail) { + add(s.text(rightX + rightW - pad, y, String(c.detail), { + fontFamily: BODY, + fontSize: '10px', + color: toCss(c.done ? C.green : C.faint), + letterSpacing: 1, + }).setOrigin(1, 0).setScrollFactor(0)); + } + y += 24; + } + + // the CLAIM action (bottom-right of the detail) + const claimY = detailY + detailH - pad - 34; + const claimW = 230; + const claimX = rightX + rightW - pad - claimW; + const mode = quest.claimed ? 'claimed' : quest.complete ? 'claim' : 'progress'; + const cg2 = add(s.graphics().setScrollFactor(0)); + cg2.clear(); + let label, col, fillC, fillA, stroke, strokeA; + if (mode === 'claimed') { + label = '✓ REWARD CLAIMED'; col = C.faint; fillC = C.panel; fillA = 0.4; stroke = 0x14324f; strokeA = 0.5; + } else if (mode === 'claim') { + label = 'CLAIM REWARD'; col = C.ink; fillC = 0x2a1f08; fillA = 0.6; stroke = C.amber; strokeA = 1; + } else { + const doneN = (quest.checks ?? []).filter((c) => c.done).length; + label = `IN PROGRESS — ${doneN}/${(quest.checks ?? []).length}`; + col = C.dim; fillC = C.panel; fillA = 0.5; stroke = 0x1b3a5a; strokeA = 0.6; + } + panel(cg2, claimX, claimY, claimW, 34, { + notch: 8, + fill: fillC, + fillAlpha: fillA, + stroke, + strokeAlpha: strokeA, + glow: mode === 'claim' ? C.amber : undefined, + glowAlpha: mode === 'claim' ? 0.25 : undefined, + }); + add(s.text(claimX + claimW / 2, claimY + 17, label, { + fontFamily: BODY, + fontSize: '12px', + color: toCss(col), + letterSpacing: 2, + }).setOrigin(0.5).setScrollFactor(0)); + if (mode === 'claim') { + // v4 input: the claim PLATE itself is the hit target (see above). + cg2.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(claimX, claimY, claimW, 34), + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + cg2.on('pointerover', () => { + if (this.isOpen) this.sfx('ui_hover'); + }); + cg2.on('pointerdown', () => { + if (!this.isOpen) return; + this.sfx('ui_click'); + this.onClaim?.(quest.id); + }); + } + this._claimMode = mode; + } + + _paintStatus() { + const qs = this._snap?.quests ?? []; + if (this.statusTxt) { + const held = qs.length; + const done = qs.filter((q) => q.claimed).length; + const ready = qs.filter((q) => q.complete && !q.claimed).length; + this.statusTxt.setText(this._snap ? `${held} ACTIVE · ${ready} READY · ${done} CLAIMED` : 'SIGNAL STANDBY'); + } + const bar = this.statusBar; + if (bar) { + bar.clear(); + bar.fillStyle(0x14324f, 0.8); + bar.fillRect(0, this.statusBarY, this.statusBarW, 4); + const held = qs.length; + const done = qs.filter((q) => q.claimed).length; + if (held > 0) { + bar.fillStyle(C.neon, 0.8); + bar.fillRect(0, this.statusBarY, (this.statusBarW * done) / held, 4); + } + } + } + + // ------------------------------------------------------------ selection + /** Pick the first quest in the active tab (an unclaimed one first). */ + _selectFirstForTab(tabId) { + const qs = (this._snap?.quests ?? []).filter((q) => q.category === tabId); + const pick = qs.find((q) => !q.claimed && !q.complete) + ?? qs.find((q) => !q.claimed) + ?? qs[0] ?? null; + this.selected = pick ? pick.id : null; + this._paintList(); + this._paintDetail(); + } + + // ------------------------------------------------------------ open/close + open() { + if (this.openState === 'open' || this.openState === 'opening') return; + this.openState = 'opening'; + this.setAlpha(0); + this.video?.play?.(); + this.sfx('ui_window'); + this._startReveal(this.scene.time.now); + } + + close() { + if (this.openState === 'closed' || this.openState === 'closing') return; + this.openState = 'closing'; + this.video?.pause?.(); + this.sfx('ui_close'); + this.scene.tweens.add({ + targets: this, + alpha: 0, + duration: 150, + ease: 'Power2', + onComplete: () => { + this.openState = 'closed'; + this.glitchG.clear(); + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + }, + }); + } + + // 'closing' counts as open: the close button flips openState in the SAME + // input pass, before the scene's pointerdown guard runs — while the + // window is still on screen (fading out) it keeps owning the click. + get isOpen() { + return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing'; + } + + // ------------------------------------------------------------ boot reveal + _startReveal(now) { + const list = []; + const push = (o, d, dur) => list.push({ o, d, dur, t0: now }); + this.decodeTo(this.titleTxt, String(config.get('quests.title', 'OPERATIONS DOSSIER')).toUpperCase(), now, 520); + push(this, 0, 240); + push(this.titleMeta, 140, 300); + push(this.closeG, 200, 200); + push(this.closeTxt, 200, 200); + push(this.videoPanel, 260, 380); + for (const t of this._tabs) { + push(t.g, 300, 260); + push(t.txt, 300, 260); + } + 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 glitch clock + const g = config.get('quests.glitch', {}); + const [ia, ib] = g.intervalMs ?? [4200, 9200]; + this._glitch.next = now + rand(ia, ib); + this._glitch.until = 0; + // the sweep clock + this._sweep = { next: now + 1500, active: null }; + // the first poll now + this._pollNext = 0; + this._fp = null; + this.openState = 'open'; + } + + // ------------------------------------------------------------ glitch bursts + _glitchBurst() { + const g = config.get('quests.glitch', {}); + if (g.enabled === false) return; + const [da, db] = g.durationMs ?? [110, 260]; + const now = this.scene.time.now; + this._glitch.until = now + rand(da, db); + const [sa, sb] = g.slices ?? [1, 4]; + const maxOff = g.maxOffset ?? 9; + const vr = this.videoRect ?? { x: 0, y: 0, w: 100, h: 100 }; + const gg = this.glitchG; + gg.clear(); + const n = Math.max(1, Math.round(rand(sa, sb))); + for (let i = 0; i < n; i++) { + const y = vr.y + rand(0, Math.max(1, vr.h - 6)); + const hgt = rand(2, 7); + const off = rand(-maxOff, maxOff); + const color = Math.random() < 0.5 ? C.neon : C.neon2; + gg.fillStyle(color, rand(0.06, 0.2)); + gg.fillRect(vr.x + off, y, vr.w, hgt); + } + // title RGB split + const t = this.titleTxt; + if (t.text) { + const off = rand(2, 5); + this.titleGhostA.setText(t.text); + this.titleGhostB.setText(t.text); + this.titleGhostA.setPosition(t.x - off, t.y); + this.titleGhostB.setPosition(t.x + off, t.y); + this.titleGhostA.setAlpha(0.55); + this.titleGhostB.setAlpha(0.55); + } + } + + // ------------------------------------------------------------ per-frame + update(time) { + if (!this.isOpen) return; + + // reveal timeline + if (this.reveal.length) { + let done = true; + for (const r of this.reveal) { + const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur)); + const e = easeIO(u); + if (u < 1) done = false; + r.o.setAlpha(e); + } + if (done) this.reveal = []; + } + + // decodes + if (this.decodes.length) { + for (const d of this.decodes) { + if (!d.dec.started(time)) continue; + d.txt.setText(d.dec.display(time)); + } + this.decodes = this.decodes.filter((d) => !d.dec.finished(time)); + } + + // tab shakes (the standby tab's rejection) + if (this._tabShakes.length) { + const keep = []; + for (const sh of this._tabShakes) { + const u = (time - sh.t0) / 260; + if (u >= 1) { + sh.tab.txt.x = sh.x; + continue; + } + sh.tab.txt.x = sh.x + Math.sin(u * 28) * 3 * (1 - u); + keep.push(sh); + } + this._tabShakes = keep; + } + + // glitch + const g = this._glitch; + if (time >= g.next && time > g.until) { + this._glitchBurst(); + const [ia, ib] = config.get('quests.glitch.intervalMs', [4200, 9200]); + g.next = time + rand(ia, ib); + } + if (time > g.until && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0)) { + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + } + + // the sweep band + if (this._sweep) { + const sw = this.sweepCfg; + if (sw.enabled !== false) { + if (this._sweep.active) { + const a = this._sweep.active; + const u = (time - a.t0) / a.dur; + if (u >= 1) { + this.sweepBand.setAlpha(0); + this._sweep.active = null; + } else { + const yTop = this.videoRect.y + u * (this.videoRect.h + 80) - 40; + this.sweepBand.setPosition(this.videoCx, yTop); + this.sweepBand.setAlpha(0.5 * Math.sin(Math.PI * u)); + } + } else if (time >= this._sweep.next) { + const [ia, ib] = sw.intervalMs ?? [5200, 9800]; + const [da, db] = sw.durationMs ?? [1400, 2200]; + this._sweep.next = time + rand(ia, ib); + this._sweep.active = { t0: time, dur: rand(da, db) }; + } + } + } + + // the REC dot breathes + this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004))); + + // the CLAIM button's live pulse (a gentle glow breathing on its plate) + if (this._claimMode === 'claim') { + // (kept subtle — the window's own alpha is its console state) + } + + // poll the scene's data — repaint on any real change (research + // completing, ore mined, a tether built, a world discovered) + if (time >= this._pollNext) { + this._pollNext = time + 400; + let snap = null; + try { + snap = this.getSnapshot ? this.getSnapshot() : null; + } catch { + snap = null; + } + if (snap) { + const fp = this._fpOf(snap); + if (fp !== this._fp) { + this._fp = fp; + this._paintAll(snap); + } else { + this._snap = snap; // keep live data fresh between repaints + } + } + } + } + + /** The scene calls this after a quest-relevant event (research + * completing, ore mined, a build, a discovery, a claim) so the + * console repaints on its next poll instead of waiting the interval. */ + refresh() { + this._pollNext = 0; + this._fp = null; + } + + destroy() { + this._destroyed = true; + this._teardownRows(); + for (const k of this._detailKids) { + try { k.destroy(); } catch { /* already gone */ } + } + this._detailKids = []; + for (const t of this._tabs) { + if (t.g) { try { t.g.destroy(); } catch { /* already gone */ } } + if (t.txt) { try { t.txt.destroy(); } catch { /* already gone */ } } + } + if (this.closeG) { try { this.closeG.destroy(); } catch { /* already gone */ } } + // v4 quirk: there is no removeChildren() — super.destroy(true) + // recurses through the display list (the ResearchWindow idiom). + super.destroy(true); + } +}