orbit/js/ui/QuestWindow.js

1301 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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