/** * 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); // resume() not play(): v4's play() is a no-op after pause() (its // _playCalled flag stays true, so the native play() is never // re-issued) — the feed would stay frozen after close → reopen. // resume() handles both a first open (delegates to play()) and a // re-open of a paused clip. this.video?.resume?.(); 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); } }