1409 lines
50 KiB
JavaScript
1409 lines
50 KiB
JavaScript
/**
|
||
* 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 {(questId: string) => void} [o.onSetPriority] — SET PRIORITY
|
||
* pressed (the tracker HUD's featured quest; the scene sets it on the
|
||
* quest ledger + toasts — the tracker updates live)
|
||
* @param {() => (string|null)} [o.getPriority] — the current priority
|
||
* quest id (the detail plate's PRIORITY badge; a set/clear repaints)
|
||
* @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
|
||
* (the scene toasts)
|
||
*/
|
||
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.onSetPriority = typeof o.onSetPriority === 'function' ? o.onSetPriority : null;
|
||
this.getPriority = typeof o.getPriority === 'function' ? o.getPriority : null;
|
||
this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
|
||
|
||
this.openState = 'closed'; // closed | opening | open | closing
|
||
this.reveal = []; // { o, d, dur, mode, t0 }
|
||
this.decodes = []; // { txt, dec } — polled ScrambleDecodes
|
||
this.selected = null; // the selected quest id (the window's only state)
|
||
this._pendingSelect = null; // openAt's quest — the reveal lands on it
|
||
this._activeTab = 'main';
|
||
this._tabs = [];
|
||
this._rows = []; // the list's painted rows
|
||
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),
|
||
]);
|
||
let prio = null;
|
||
try {
|
||
prio = this.getPriority ? this.getPriority() : null;
|
||
} catch {
|
||
prio = null;
|
||
}
|
||
return JSON.stringify({ t: this._activeTab, s: this.selected, p: prio, qs });
|
||
}
|
||
|
||
_teardownRows() {
|
||
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;
|
||
|
||
// --- SET PRIORITY — the tracker HUD's featured quest (the green
|
||
// accent). This quest IS the priority → a static "◆ PRIORITY" badge
|
||
// (the player switches it by selecting ANOTHER quest's button);
|
||
// otherwise the SET PRIORITY button. A CLAIMED quest gets no
|
||
// priority control — it isn't active (the ledger's rule).
|
||
let priorityId = null;
|
||
try {
|
||
priorityId = this.getPriority ? this.getPriority() : null;
|
||
} catch {
|
||
priorityId = null;
|
||
}
|
||
if (!quest.claimed) {
|
||
const isPrio = priorityId === quest.id;
|
||
const pw = 200;
|
||
const px0 = claimX - 12 - pw;
|
||
const py0 = claimY;
|
||
const pg = add(s.graphics().setScrollFactor(0));
|
||
pg.clear();
|
||
panel(pg, px0, py0, pw, 34, {
|
||
notch: 8,
|
||
fill: isPrio ? 0x082010 : C.panel,
|
||
fillAlpha: isPrio ? 0.7 : 0.5,
|
||
stroke: C.green,
|
||
strokeAlpha: isPrio ? 1 : 0.7,
|
||
lineWidth: isPrio ? 2 : 1.5,
|
||
glow: C.green,
|
||
glowAlpha: isPrio ? 0.3 : 0.12,
|
||
});
|
||
add(
|
||
s
|
||
.text(px0 + pw / 2, py0 + 17, isPrio ? '◆ PRIORITY' : 'SET PRIORITY', {
|
||
fontFamily: BODY,
|
||
fontSize: '12px',
|
||
color: toCss(isPrio ? C.green : C.ink),
|
||
letterSpacing: 2,
|
||
})
|
||
.setOrigin(0.5)
|
||
.setScrollFactor(0),
|
||
);
|
||
if (!isPrio) {
|
||
pg.setInteractive({
|
||
useHandCursor: true,
|
||
hitArea: new Phaser.Geom.Rectangle(px0, py0, pw, 34),
|
||
hitAreaCallback: (area, px, py) => area.contains(px, py),
|
||
});
|
||
pg.on('pointerover', () => {
|
||
if (this.isOpen) this.sfx('ui_hover');
|
||
});
|
||
pg.on('pointerdown', () => {
|
||
if (!this.isOpen) return;
|
||
this.sfx('ui_click');
|
||
this.onSetPriority?.(quest.id);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
_paintStatus() {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Open on a SPECIFIC quest (the tracker HUD's SHOW MORE / VIEW QUEST):
|
||
* its tab becomes active and it becomes the selection — the reveal
|
||
* (or, when the console is already open, a repaint) lands on it.
|
||
*/
|
||
openAt(questId) {
|
||
if (typeof questId !== 'string' || questId === '') return;
|
||
let snap = this._snap;
|
||
if (!snap && typeof this.getSnapshot === 'function') {
|
||
try {
|
||
snap = this.getSnapshot();
|
||
} catch {
|
||
snap = null;
|
||
}
|
||
}
|
||
const q = ((snap?.quests ?? [])).find((x) => x && x.id === questId) ?? null;
|
||
if (!q) return;
|
||
if (q.category) this._activeTab = q.category;
|
||
if (this.openState === 'open') {
|
||
this.selected = q.id;
|
||
this._snap = snap ?? this._snap;
|
||
this._paintAll(this._snap); // tab + list + plate follow the selection
|
||
} else {
|
||
this._pendingSelect = q.id; // the reveal (_startReveal) picks it up
|
||
}
|
||
}
|
||
|
||
close() {
|
||
if (this.openState === 'closed' || this.openState === 'closing') return;
|
||
this.openState = 'closing';
|
||
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;
|
||
// the selection — a PENDING one (openAt — the tracker's SHOW MORE /
|
||
// VIEW QUEST lands here) beats the tab's first quest (fresh open).
|
||
if (typeof this._pendingSelect === 'string' && this._pendingSelect !== '') {
|
||
const id = this._pendingSelect;
|
||
this._pendingSelect = null;
|
||
const q = ((this._snap?.quests ?? [])).find((x) => x && x.id === id) ?? null;
|
||
if (q) {
|
||
if (q.category) this._activeTab = q.category;
|
||
this.selected = q.id;
|
||
}
|
||
} else {
|
||
this._selectFirstForTab(this._activeTab);
|
||
}
|
||
// the glitch clock
|
||
const g = config.get('quests.glitch', {});
|
||
const [ia, ib] = g.intervalMs ?? [4200, 9200];
|
||
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);
|
||
}
|
||
}
|