/** * JobBoardWindow — the full-screen JOB BOARD console (the Explore * drawer's Job Board slot, on every world EXCEPT the player's home * system — safe space). Structurally the build console's (the deck's * BUILD button — js/ui/BuildWindow.js): same cut-corner frame + title * rail + ✕, the same boot reveal / decode / glitch language, and the * same left feed + right content split. * * ┌──────────────────────────────────────────────────────────────────────┐ * │ J O B B O A R D · BOARD [✕] │ * ├───────────────────────┬──────────────────────────────────────────────┤ * │ ● BOARD FEED // LIVE │ AVAILABLE JOBS │ * │ ┌─────────────────┐ │ ┌────────────────────────────────────────┐ │ * │ │ looping video │ │ │ · the live postings — one row per job │ │ * │ │ (muted, 2:3) │ │ │ (the standby plate while no type │ │ * │ │ + scanlines │ │ │ is live — the data's category gate) │ │ * │ │ + sweep band │ │ └────────────────────────────────────────┘ │ * │ └─────────────────┘ │ JOB DETAILS │ * │ BOARD STANDBY — … │ ┌────────────────────────────────────────┐ │ * │ │ │ the selected job's contract — the │ │ * │ │ │ copy, the payout, the ACCEPT door │ │ * │ │ └────────────────────────────────────────┘ │ * └───────────────────────┴──────────────────────────────────────────────┘ * * Title: the JOB BOARD name in the top bar's upper left — the build * console's titleTxt spot (the meta line + ✕ sit at the right). Left: * the board's feed — assets/videos/jobboard.mp4, a 2:3 portrait that * LOOPS MUTED while the console is up (the build feed's twin; * data/actionbar.json → surface.explore.video). Right: the AVAILABLE * JOBS section (the board's LIVE postings — the scene's seam: the * current WORLD's contracts) over the JOB DETAILS section (the * selected job's contract + the ACCEPT door). The window is a pure * VIEW over js/quests/SideQuests.js: refresh() repaints both off the * seams (postings / isAccepted / isCompleted / onAccept); the ledger * and the accept effects live on the scene. * * Depth 80 — the build screen's: nearly full-screen, ABOVE the deck * (50) + drawer (60) + the upper-left surface HUD (60), so it covers * the planet text as well (the build console's shape). The top bar's * text sits at the right (the build's titleMeta corner). * * The scene owns the toggle: it calls open()/close() and stands the * deck + drawer down (SurfaceScene.setServiceLocked — the slot's own * press stays live as the second door); the ✕ calls back through the * onClose seam so the unlock can't be skipped. */ 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), panel: themeColor('panel', 0x0a1120), bg: themeColor('bg', 0x04060d), }; /** '#rrggbb' → a Phaser hex number (a category accent from the data). */ function hexNum(str, fallback = 0xffffff) { const m = String(str ?? '').trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : fallback; } /** * A data line's `{level}` filled (the lock badge + its sub carry the gate). * The posting carries its own `minLevel` (sideQuestPostingsFor stamps it off * categories..minLevel); a posting without one falls to the bounty * category's gate, then the design's 2. */ function fillLevel(text, p) { let level = Number(p?.minLevel); if (!Number.isFinite(level)) level = Number(config.get('sidequests.categories.bounty.minLevel', 2)); if (!Number.isFinite(level)) level = 2; return String(text ?? '').replace(/\{level\}/g, String(Math.floor(level))); } /** * A copy's line fit (deterministic — no Phaser measure, so the headless * stub and the game agree): cut at a word boundary + '…' when the text * would run past maxLines at ~charsPerLine. */ function fitText(str, charsPerLine, maxLines) { let s = String(str ?? ''); const cap = Math.max(20, Math.floor(charsPerLine)) * Math.max(1, Math.floor(maxLines)); if (s.length <= cap) return s; s = s.slice(0, cap - 1); const sp = s.lastIndexOf(' '); if (sp > cap * 0.6) s = s.slice(0, sp); return s.trimEnd() + '…'; } /** * 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). Mirrors BuildWindow.panel() (the build console's * recipe, including the wide, low-alpha `glow` stroke pass). */ 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); } export class JobBoardWindow extends Phaser.GameObjects.Container { static VIDEO_KEY = 'jobboard'; /** * @param {Phaser.Scene} scene the owning scene (SurfaceScene) * @param {object} o seams: * { planetName: string, // the world's proper name (top-bar meta) * onClose: () => void } // the ✕ door (the scene's full leave: relabel + unlock) */ 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 // The build screen's depth — nearly full-screen, ABOVE the deck (50) // + drawer (60) + the upper-left surface HUD (60): the board covers // the planet text, and the ✕ (or the JOB BOARD slot's press) is the // way-out (see the module note above). this.setDepth(80); this.planetName = String(o.planetName ?? '').trim(); this.onClose = typeof o.onClose === 'function' ? o.onClose : null; // The board's data + state seams (the scene's — the QuestWindow // precedent: state + effects there, the view here): the current // WORLD's live postings (GameScene.sideQuestPostings), the ledger's // accepted/completed marks (GameScene.sideQuestState), and the ACCEPT // door (GameScene.acceptSideQuest via the surface's note). Missing // seams stand the board on its STANDBY plate (a test scene, or a // pre-feature save's load) — the console still works. this.postings = typeof o.postings === 'function' ? o.postings : () => []; this.isAccepted = typeof o.isAccepted === 'function' ? o.isAccepted : () => false; this.isCompleted = typeof o.isCompleted === 'function' ? o.isCompleted : () => false; this.isActive = typeof o.isActive === 'function' ? o.isActive : () => false; this.isClaimed = typeof o.isClaimed === 'function' ? o.isClaimed : () => false; this.routeProgress = typeof o.routeProgress === 'function' ? o.routeProgress : () => null; this.onAccept = typeof o.onAccept === 'function' ? o.onAccept : null; this.onActivate = typeof o.onActivate === 'function' ? o.onActivate : null; this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null; this.selectedId = null; // the list's selection (the detail's contract) this.openState = 'closed'; // closed | opening | open | closing this.booted = false; this.reveal = []; // { o, d, dur, mode, baseY, t0 } this.decodes = []; // { txt, dec } — polled ScrambleDecodes this.glitch = { until: 0, next: 0, level: 0.8 }; this.sweep = { t0: 0, next: 0 }; this._build(); this.setVisible(true); this.setAlpha(0); } // ------------------------------------------------------------ helpers hasVideo(key) { const c = this.scene.cache?.video; return !!(c && typeof c.has === 'function' && c.has(key)); } destroyVideo(v) { if (!v) return; try { v.off(); v.stop(false); v.destroy(); } catch { /* already gone */ } } /** 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) }); } sfx(name) { playSfxOn(this.scene, name); } // ------------------------------------------------------------ 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; // Nearly full-screen — the build screen's shape (its body runs to // the window's bottom padding; the deck + drawer + HUD are covered). const bodyY = rect.y + titleH + pad; const bodyBottom = rect.y + rect.h - pad; const bodyH = bodyBottom - bodyY; const a = config.get('actionbar.surface.explore.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; const rightX = leftX + leftW + pad; const rightW = Math.max(120, rect.x + rect.w - pad - rightX); // right column: the two placeholder sections (the build console's // list-over-detail shape) — the title now lives in the top bar // (the build's titleTxt spot), so the column starts at the body's // top edge and the postings panel gets the whole flex room: // AVAILABLE JOBS (the postings — flex) over JOB DETAILS (fixed). const headH = 12; // a section's header line (10 px font) const headGap = 10; // header → panel const secGap = 16; // available panel → details header const detailH = 210; // the details panel — title + issuer + the contract's lines (destination / route) + the copy + the payout + the action bar const availHeadY = bodyY + 8; // the AVAILABLE JOBS header (just under the top bar) const availTop = availHeadY + headH + headGap; const detailPanelY = bodyBottom - detailH; const detailHeadY = detailPanelY - headGap - headH; // the JOB DETAILS header baseline const availH = Math.max( 80, detailHeadY - headGap - headH - secGap - availTop, ); return { W, H, rect, titleH, pad, bodyY, bodyBottom, bodyH, videoH, videoW, leftX, leftW, rightX, rightW, availHeadY, availTop, availH, detailHeadY, detailPanelY, detailH, }; } // ------------------------------------------------------------ build _build() { const s = this.scene.add; const G = (d = 0) => { const g = s.graphics().setScrollFactor(0).setDepth(d); this.add(g); return g; }; const T = (x, y, str, style, d = 0) => { const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d); this.add(t); return t; }; this.geo = this._measure(); const { rect, titleH } = this.geo; // ── window body ───────────────────────────────────────────────── const bg = G(0); bg.clear(); panel(bg, rect.x, rect.y, rect.w, rect.h, { notch: 20, fill: C.bg, fillAlpha: 1, // fully opaque — the upper-left HUD sits UNDER this window (depth 80 > 60) and must not ghost through stroke: 0x0e5f86, strokeAlpha: 0.95, }); bg.fillStyle(C.neon, 0.16); bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5); this.bgG = bg; // ── top bar — the meta line + ✕ at the RIGHT (the build console's // titleMeta corner; the window now covers the upper-left HUD), the rail const meta = String(this.planetName || 'SURFACE').toUpperCase(); this.topMeta = T(0, 0, `${meta} · SURFACE BOARD`, { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2.5, }, 1); this.topMeta.setOrigin(1, 0); this.topMeta.setPosition(rect.x + rect.w - 66, rect.y + 15); this.titleRailG = G(1); this.titleRailG.clear(); this.titleRailG.lineStyle(1, 0x14324f, 0.8); this.titleRailG.lineBetween(rect.x + 16, rect.y + titleH - 1, rect.x + rect.w - 16, rect.y + titleH - 1); this.titleRailG.fillStyle(C.neon, 0.9); this.titleRailG.fillRect(rect.x + 16, rect.y + titleH - 2, 90, 2); // close button (top right) const cw = 30; const cx = rect.x + rect.w - 18 - cw / 2; const cy = rect.y + titleH / 2 - 2; this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false }; this.closeG = G(2); this.closeTxt = T(cx, cy + 1, '✕', { fontFamily: BODY, fontSize: '15px', color: toCss(C.neon2), align: 'center', }, 2); // Text's default origin is (0,0) — without this the ✕ anchors its // top-left at the plate centre and sits right-of-centre. this.closeTxt.setOrigin(0.5, 0.5); const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw); this.closeG.setInteractive({ useHandCursor: true, hitArea: closeRect, hitAreaCallback: (area, px, py) => area.contains(px, py), }); this.closeG.on('pointerover', () => { this.closeBtn.hover = true; this._paintClose(); }); this.closeG.on('pointerout', () => { this.closeBtn.hover = false; this._paintClose(); }); this.closeG.on('pointerdown', () => { this.sfx('ui_click'); // The scene's full leave (relabel the drawer's slot back to JOB // BOARD + stand the deck back up) — close() alone would skip it. if (this.onClose) this.onClose(); else this.close(); }); this._paintClose(); // glitch layers (top of the window) this.glitchG = G(10); this.titleGhostA = this._ghost(); this.titleGhostB = this._ghost(); // ── left: the board feed (video) ───────────────────────────────── this.videoPanel = this._buildVideoPanel(); this.add(this.videoPanel); this.videoPanel.setDepth(1); // ── top bar: the JOB BOARD title in the build's titleTxt spot // (upper left; the meta line + ✕ sit at the right) ──────────────── this.rightTitle = T(rect.x + 18, rect.y + 13, '', { fontFamily: HEADER, fontSize: '17px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 7, }, 1); this.rightTitleCursor = G(1); this._buildSections(); } _ghost() { const t = this.scene.add .text(0, 0, '', { fontFamily: HEADER, fontSize: '20px', color: toCss(C.neon), fontStyle: 'bold', letterSpacing: 6, }) .setScrollFactor(0) .setAlpha(0) .setBlendMode(Phaser.BlendModes.ADD); t.setDepth(2); this.add(t); return t; } // ── right: the two live sections ───────────────────────────────── /** * AVAILABLE JOBS (the board's postings — the current WORLD's live * contracts) over JOB DETAILS (the selected job's readout + the * ACCEPT door). The frames + headers are STATIC geometry; the content * inside each is DYNAMIC — refresh() repaints both off the seams * (postings / isAccepted / isCompleted / onAccept — the scene's, the * QuestWindow precedent). While no category is live (the board's gate * — data/sidequests.json → categories' `enabled` flags), the list * stands on its standby plate (the console's face until the first * type flips on) — same copy as the original placeholder. */ _buildSections() { const s = this.scene.add; const { rightX, rightW } = this.geo; const headTxt = (headY, text) => { const t = s .text(rightX + 6, headY, text, { fontFamily: HEADER, fontSize: '10px', color: toCss(C.dim), fontStyle: 'bold', letterSpacing: 3, }) .setScrollFactor(0); this.add(t); return t; }; const frame = (panelY, panelH) => { // NOTE: added to THE CONTAINER (this.add), not scene-level — // the container carries the window's depth (10), and a scene-level // object at depth 0 hides behind the window's own opaque backdrop. const bg = s.graphics().setScrollFactor(0); this.add(bg); bg.clear(); panel(bg, rightX, panelY, rightW, panelH, { notch: 10, fill: 0x081120, fillAlpha: 0.85, stroke: 0x1b3a5a, strokeAlpha: 0.7, }); bg.fillStyle(C.neon, 0.3); bg.fillRect(rightX + 12, panelY + 1, rightW - 24, 1.5); return bg; }; const g = this.geo; this.jobsSection = { headTxt: headTxt(g.availHeadY, 'AVAILABLE JOBS'), bg: frame(g.availTop, g.availH), content: [], }; this.detailSection = { headTxt: headTxt(g.detailHeadY, 'JOB DETAILS'), bg: frame(g.detailPanelY, g.detailH), content: [], }; this._rows = []; // the list's interactive rows ({ post, hover, paint }) this.refresh(); // the initial paint (the standby plate, or the live postings) } // ── the list + the detail readout (the dynamic halves) ────────────── /** The live postings off the seam (a seam fault stands an empty board, never a crash). */ _livePostings() { let posts = null; try { posts = this.postings(); } catch { posts = null; } return Array.isArray(posts) ? posts.filter((p) => p && typeof p.id === 'string') : []; } /** Tear a section's rendered content down (it is rebuilt on the repaint). */ _destroyContent(list) { for (const o of list) { try { o?.destroy?.(); } catch { /* already gone */ } } list.length = 0; } /** The selection's row repaint (all rows re-read the selected state). */ _repaintList() { for (const row of this._rows) { row.selected = this.selectedId === row.post.id; row.paint(); } } /** The row/door's LEDGER state off the seams (the scene's truth, the * board's contract): open → accepted → active → complete → claimed. * (A category without an active stage — the stubs — simply never reads * 'active'; the door's shape is the same either way.) */ _rowState(p) { if (this.isClaimed(p.id)) return 'claimed'; if (this.isCompleted(p.id)) return 'complete'; if (this.isActive(p.id)) return 'active'; if (this.isAccepted(p.id)) return 'accepted'; if (p.locked === true) return 'locked'; // the level gate's greyed row (BOUNTY HUNT under its minLevel) return 'open'; } /** The patrol's compass color as a hex number (the active state's accent). */ _activeColor() { return hexNum(config.get('sidequests.patrol.compass.color', '#ffd60a'), 0xffd60a); } /** * Repaint the AVAILABLE JOBS list: the postings' rows (chip + title + * issuer line, the payout — or the row's LEDGER state once the contract * is taken / active / done / paid), selectable, or the standby plate * while no category is live. */ _renderList() { const s = this.scene.add; const { rightX, rightW, availTop, availH } = this.geo; this._destroyContent(this.jobsSection.content); this.jobsSection.content.length = 0; this._rows = []; const posts = this._livePostings(); const ui = config.get('sidequests.ui', {}); if (posts.length === 0) { // STANDBY — the board's face while no category is live (data/ // sidequests.json → categories); the same copy the placeholder wore. const main = s .text(rightX + rightW / 2, availTop + availH / 2 - 8, String(ui.standbyMain ?? 'NO CONTRACTS ON FILE'), { fontFamily: HEADER, fontSize: '12px', color: toCss(C.faint), fontStyle: 'bold', letterSpacing: 3, align: 'center', }) .setOrigin(0.5, 0.5) .setScrollFactor(0); const sub = s .text(rightX + rightW / 2, availTop + availH / 2 + 12, String(ui.standbySub ?? 'THE BOARD’S POSTINGS LAND HERE'), { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2, align: 'center', }) .setOrigin(0.5, 0.5) .setScrollFactor(0); this.add(main); this.add(sub); this.jobsSection.content.push(main, sub); return; } const pad = 10; const gap = 8; const n = posts.length; const rowH = Math.max(34, Math.min(58, (availH - 2 * pad - (n - 1) * gap) / n)); posts.forEach((p, i) => { const y = availTop + pad + i * (rowH + gap); const g = s.graphics().setScrollFactor(0); this.add(g); const row = { post: p, hover: false, selected: false, hit: g }; const chipColor = hexNum(p.accent, C.neon); const title = s .text(rightX + 32, y + 9, String(p.title ?? '').toUpperCase(), { fontFamily: HEADER, fontSize: '11px', color: toCss(p.locked === true ? C.faint : C.ink), // a locked contract's title stands greyed with the rest of the row fontStyle: 'bold', letterSpacing: 1.5, }) .setScrollFactor(0); this.add(title); const sub = s .text( rightX + 32, y + rowH - 16, `${String(p.label ?? '').toUpperCase()} — ${String(p.issuer ?? '').toUpperCase()}`, { fontFamily: BODY, fontSize: '9px', color: toCss(C.faint), letterSpacing: 2 }, ) .setScrollFactor(0); this.add(sub); // The row's right end: the payout while open, the ledger's mark once // taken (the state is read off the seams at repaint — the ledger is // the truth, the scene's refresh() lands it after each action). const state = this._rowState(p); const locked = state === 'locked'; // the row stands, greyed, under its level gate const unit = String(ui.rewardUnit ?? 'CR'); let rightSize = '10px'; let rightTxt; let rightColor; if (locked) { // The gate reads on the row itself: LOCKED — REQUIRES LEVEL 2 (data → // ui.bountyLockBadge), greyed in place of the payout. The row still // SELECTS (the dossier is readable before the level lands); the // scene's accept is the door that refuses. rightTxt = fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), p); rightColor = C.faint; rightSize = '9px'; } else if (state === 'claimed') { rightTxt = String(ui.claimed ?? 'REWARD CLAIMED'); rightColor = C.faint; } else if (state === 'complete') { rightTxt = String(ui.complete ?? 'CONTRACT COMPLETE'); rightColor = C.neon; } else if (state === 'active') { // A hunt flying reads as a HUNT, an escort as an ESCORT, not a patrol // (data → ui.bountyActive / ui.escortActive); every other contract // keeps the standing copy. rightTxt = p.category === 'bounty' ? String(ui.bountyActive ?? 'HUNT ACTIVE') : p.category === 'escort' ? String(ui.escortActive ?? 'ESCORT ACTIVE') : String(ui.active ?? 'PATROL ACTIVE'); rightColor = this._activeColor(); } else if (state === 'accepted') { rightTxt = String(ui.accepted ?? 'CONTRACT ACCEPTED'); rightColor = C.green; } else { rightTxt = `${p.reward?.credits ?? 0} ${unit}`; rightColor = C.amber; } const right = s .text(rightX + rightW - 16, y + 10, rightTxt, { fontFamily: BODY, fontSize: rightSize, color: toCss(rightColor), fontStyle: 'bold', letterSpacing: 2, }) .setOrigin(1, 0) .setScrollFactor(0); this.add(right); const paint = () => { g.clear(); panel(g, rightX + 2, y, rightW - 4, rowH, { notch: 8, fill: row.selected ? 0x0d1c30 : 0x081120, fillAlpha: row.selected ? 0.95 : 0.5, stroke: row.selected ? chipColor : 0x1b3a5a, strokeAlpha: row.selected ? 1 : row.hover ? 0.95 : 0.4, }); if (row.selected) { g.fillStyle(chipColor, 1); g.fillRect(rightX + 3, y + 7, 3, rowH - 14); } g.fillStyle(chipColor, locked ? 0.35 : 0.95); // the locked row's chip reads greyed too g.fillRect(rightX + 15, y + rowH / 2 - 6, 11, 11); }; const hit = new Phaser.Geom.Rectangle(rightX + 2, y, rightW - 4, rowH); g.setInteractive({ useHandCursor: true, hitArea: hit, hitAreaCallback: (area, px, py) => area.contains(px, py) }); g.on('pointerover', () => { row.hover = true; paint(); this.sfx('ui_hover'); }); g.on('pointerout', () => { row.hover = false; paint(); }); g.on('pointerdown', () => { this.sfx('ui_click'); this.selectedId = p.id; this._repaintList(); this._renderDetail(); }); row.paint = paint; this._rows.push(row); this.jobsSection.content.push(g, title, sub, right); }); this._repaintList(); } /** * Repaint the JOB DETAILS readout: the selection's contract (title, * issuer, the copy, the payout) + the ACCEPT door — the door's state * off the ledger's seams (open → accepted → complete; the accept * lands on the scene, which repaints us — the refresh() seam). */ _renderDetail() { const s = this.scene.add; const { rightX, rightW, detailPanelY: py, detailH: ph } = this.geo; this._destroyContent(this.detailSection.content); this.detailSection.content.length = 0; const posts = this._livePostings(); const ui = config.get('sidequests.ui', {}); const centered = (main, sub) => { const m = s .text(rightX + rightW / 2, py + ph / 2 - 8, main, { fontFamily: HEADER, fontSize: '12px', color: toCss(C.faint), fontStyle: 'bold', letterSpacing: 3, align: 'center', }) .setOrigin(0.5, 0.5) .setScrollFactor(0); const t = s .text(rightX + rightW / 2, py + ph / 2 + 12, sub, { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2, align: 'center', }) .setOrigin(0.5, 0.5) .setScrollFactor(0); this.add(m); this.add(t); this.detailSection.content.push(m, t); }; if (posts.length === 0) { centered(String(ui.standbyMain ?? 'NO CONTRACTS ON FILE'), String(ui.standbySub ?? 'THE BOARD’S POSTINGS LAND HERE')); return; } const sel = posts.find((p) => p.id === this.selectedId) ?? null; if (!sel) { centered(String(ui.selectMain ?? 'SELECT A JOB'), String(ui.selectSub ?? 'PICK A LISTING TO READ ITS CONTRACT')); return; } const accent = hexNum(sel.accent, C.neon); const wrapW = Math.max(80, rightW - 36); const title = s .text(rightX + 14, py + 12, String(sel.title ?? '').toUpperCase(), { fontFamily: HEADER, fontSize: '13px', color: toCss(accent), fontStyle: 'bold', letterSpacing: 2, }) .setScrollFactor(0); this.add(title); const issuer = s .text(rightX + 14, py + 34, `ISSUER — ${String(sel.issuer ?? '').toUpperCase()}`, { fontFamily: BODY, fontSize: '9px', color: toCss(C.faint), letterSpacing: 2, }) .setScrollFactor(0); this.add(issuer); // The contract's LINES (the patrol's shape — the other categories', // when they land, add their own): the DESTINATION (a patrol whose // route stands in ANOTHER system — the posting names the target), // the ROUTE progress (the accepted patrol's checkpoints — the // ledger's truth off the scene's seam), and the CONTACTS (the // route's encounter waypoints — the combat the patrol owes, cleared // by the wave runs — old saves' routes carry none and the line stays // down). let lineY = py + 52; // The BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]): the // wanted leader, their epithet, the charges, and the CRAFT the story says // they fly. It rides the posting, so it reads BEFORE the contract is taken // — and it stays silent about WHERE: the chain of legs rolls at the accept // and never touches the board (ui.dossier* carry the shapes). const dossier = sel.category === 'bounty' && sel.dossier && typeof sel.dossier === 'object' ? sel.dossier : null; if (dossier) { const dline = (tpl, vars, o = {}) => { let text = String(tpl ?? ''); for (const [k, v] of Object.entries(vars)) text = text.split(`{${k}}`).join(String(v ?? '')); if (text.replace(/\{[a-zA-Z]+\}/g, '').trim() === '') return null; // an unfilled line stands down const t = s .text(rightX + 14, lineY, text, { fontFamily: BODY, fontSize: String(o.size ?? '9.5px'), color: toCss(o.color ?? C.dim), fontStyle: o.style ?? 'bold', letterSpacing: 2, }) .setScrollFactor(0); this.add(t); this.detailSection.content.push(t); lineY += 15; }; dline(ui.dossierWanted ?? 'WANTED — {name}', { name: String(dossier.name ?? '').toUpperCase() }, { color: accent }); if (dossier.epithet) dline(ui.dossierEpithet ?? '{epithet}', { epithet: dossier.epithet }, { color: C.faint, style: 'italic' }); if (dossier.crime) dline(ui.dossierCrime ?? 'CHARGES — {crime}', { crime: dossier.crime }, { color: C.dim }); if (dossier.craftLabel) dline(ui.dossierCraft ?? 'CRAFT — {craft}', { craft: String(dossier.craftLabel).toUpperCase() }, { color: C.faint }); } // The level gate's own line under the dossier (data → ui.bountyLockSub): // what the record needs, not what the contract hides. if (sel.locked === true && !this.isAccepted(sel.id)) { const lock = s .text(rightX + 14, lineY, fillLevel(String(ui.bountyLockSub ?? 'BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU'), sel), { fontFamily: BODY, fontSize: '9px', color: toCss(0xff5c77), letterSpacing: 2, }) .setScrollFactor(0); this.add(lock); this.detailSection.content.push(lock); lineY += 15; } if (sel.targetSystemName && sel.targetSystemName !== sel.systemName) { const dest = s .text( rightX + 14, lineY, String(ui.destination ?? 'DESTINATION — {name}').replace('{name}', String(sel.targetSystemName).toUpperCase()), { fontFamily: BODY, fontSize: '9.5px', color: toCss(C.amber), letterSpacing: 2, fontStyle: 'bold' }, ) .setScrollFactor(0); this.add(dest); this.detailSection.content.push(dest); lineY += 16; } let prog = null; try { prog = this.routeProgress(sel.id); } catch { prog = null; // a seam fault stands the line out, never a crash } if (prog && Number.isFinite(prog.visited) && Number.isFinite(prog.total)) { // A HUNT reads its own line (data → ui.bountyRouteProgress — WAYPOINT n // OF m LOGGED): the legs flown, never what stands on them. Its color is // the category's accent, not the patrol's yellow. const hunt = sel.category === 'bounty'; const route = s .text( rightX + 14, lineY, String(hunt ? ui.bountyRouteProgress ?? 'TRAVEL LANE — WAYPOINT {visited} OF {total} LOGGED' : ui.routeProgress ?? 'ROUTE — {visited}/{total} WAYPOINTS LOGGED') .replace('{visited}', String(prog.visited)) .replace('{total}', String(prog.total)), { fontFamily: BODY, fontSize: '9.5px', color: toCss(hunt ? accent : this._activeColor()), letterSpacing: 2, fontStyle: 'bold' }, ) .setScrollFactor(0); this.add(route); this.detailSection.content.push(route); lineY += 16; } // The CONTACTS line is the patrol's own (a hunt's route carries no // encounters spec — its fights are the hidden chain), so a bounty never // paints a contact count or a red cleared/total line here. const isHunt = sel.category === 'bounty'; if (!isHunt && !dossier && prog && prog.contacts && Number.isFinite(prog.contacts.cleared) && prog.contacts.total > 0) { const contacts = s .text( rightX + 14, lineY, String(ui.contactProgress ?? 'CONTACTS — {cleared}/{total} CLEARED') .replace('{cleared}', String(prog.contacts.cleared)) .replace('{total}', String(prog.contacts.total)), { fontFamily: BODY, fontSize: '9.5px', color: toCss(prog.contacts.cleared >= prog.contacts.total ? this._activeColor() : 0xff5c77), letterSpacing: 2, fontStyle: 'bold', }, ) .setScrollFactor(0); this.add(contacts); this.detailSection.content.push(contacts); lineY += 16; } // The contract's OWN words (the pool's body — a hunt's written dossier). // The panel is a fixed height, so the copy takes whatever room the lines // above it left (the dossier's WANTED block + the gate line spend some of // it) and fitText carries an ellipsis when the copy would run past. const bodyLines = Math.max(2, Math.min(5, Math.floor((py + ph - 50 - lineY) / 13))); const body = s .text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), bodyLines), { fontFamily: BODY, fontSize: '10.5px', color: toCss(C.dim), lineSpacing: 3, wordWrap: { width: wrapW, useAdvancedWrap: true }, // Phaser 4: the wrap width is the object's `width` (a bare number is a Phaser 3 key — silently dropped, the copy runs off the panel) }) .setScrollFactor(0); this.add(body); // The payout's XP (the claim's second reward — the experience worth // of the patrol's defeated contacts, fixed on the route at the // accept): it stands once the contract is TAKEN (the routeProgress // seam carries the route's xp — a posting not yet accepted has no // route, so the line holds credits-only until the sign). const credits = sel.reward?.credits ?? 0; const routeXp = prog && Number.isFinite(prog.xp) ? Math.max(0, prog.xp) : 0; const payoutText = routeXp > 0 ? String(ui.payoutXp ?? 'PAYOUT — {reward} CR · +{xp} XP').replace('{reward}', String(credits)).replace('{xp}', String(routeXp)) : `PAYOUT — ${credits} ${String(ui.rewardUnit ?? 'CR')}`; const payout = s .text(rightX + 14, py + ph - 46, payoutText, { fontFamily: BODY, fontSize: '10px', color: toCss(C.amber), fontStyle: 'bold', letterSpacing: 2, }) .setScrollFactor(0); this.add(payout); // the ACTION door (full-width bar — the console's action shape; its // state off the ledger's seams: open → accepted → active → complete // → claimed — each state's door onto the scene's own seam; the // active/claimed states stand as plates — nothing to press) const state = this._rowState(sel); let label; let color; let action = null; // 'accept' | 'activate' | 'claim' | null (a plate) if (state === 'locked') { // The door stays LIVE under the level gate and reads its own badge — // pressing it is how the player learns the rule (the scene's accept // refuses with data/sidequests.json → ui.bountyAcceptDenyToast, which // the surface console speaks out). No contract is taken, ever. label = fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), sel); color = 0xff5c77; action = 'accept'; } else if (state === 'open') { label = String(ui.accept ?? 'ACCEPT CONTRACT'); color = accent; action = 'accept'; } else if (state === 'accepted') { label = sel.category === 'bounty' ? String(ui.bountyActivate ?? 'SET HUNT ACTIVE') : sel.category === 'escort' ? String(ui.escortActivate ?? 'SET ESCORT ACTIVE') : String(ui.activate ?? 'SET PATROL ACTIVE'); color = C.green; action = 'activate'; } else if (state === 'active') { label = sel.category === 'bounty' ? String(ui.bountyActive ?? 'HUNT ACTIVE') : sel.category === 'escort' ? String(ui.escortActive ?? 'ESCORT ACTIVE') : String(ui.active ?? 'PATROL ACTIVE'); color = this._activeColor(); } else if (state === 'complete') { label = routeXp > 0 ? String(ui.claimXp ?? 'CLAIM REWARD — {reward} CR · +{xp} XP').replace('{reward}', String(credits)).replace('{xp}', String(routeXp)) : String(ui.claim ?? 'CLAIM REWARD — {reward} CR').replace('{reward}', String(credits)); color = C.green; action = 'claim'; } else { label = String(ui.claimed ?? 'REWARD CLAIMED'); color = C.faint; } const bh = 26; const by = py + ph - 34; const bx = rightX + 14; const bw = rightW - 28; const btn = { hover: false }; const g = s.graphics().setScrollFactor(0); this.add(g); const txt = s .text(bx + bw / 2, by + bh / 2 + 1, label, { fontFamily: BODY, fontSize: '11px', color: toCss(color), fontStyle: 'bold', letterSpacing: 3, align: 'center', }) .setOrigin(0.5, 0.5) .setScrollFactor(0); const paint = () => { g.clear(); panel(g, bx, by, bw, bh, { notch: 7, fill: C.panel, fillAlpha: action ? (btn.hover ? 0.9 : 0.5) : 0.35, stroke: color, strokeAlpha: action ? (btn.hover ? 1 : 0.8) : 0.45, }); txt.setAlpha(action ? (btn.hover ? 1 : 0.9) : 0.7); }; paint(); if (action) { const hit = new Phaser.Geom.Rectangle(bx, by, bw, bh); g.setInteractive({ useHandCursor: true, hitArea: hit, hitAreaCallback: (area, px, py2) => area.contains(px, py2) }); g.on('pointerover', () => { btn.hover = true; paint(); this.sfx('ui_hover'); }); g.on('pointerout', () => { btn.hover = false; paint(); }); g.on('pointerdown', () => { this.sfx('ui_click'); if (action === 'accept' && this.onAccept) this.onAccept(sel.id); else if (action === 'activate' && this.onActivate) this.onActivate(sel.id); else if (action === 'claim' && this.onClaim) this.onClaim(sel.id); }); } this.add(txt); this.detailSection.content.push(title, issuer, body, payout, g, txt); } /** The feed's status strip (the board's face — the standby, or the live count). */ _paintStatus() { const ui = config.get('sidequests.ui', {}); const n = this._livePostings().length; this.statusTxt.setText( n > 0 ? String(ui.statusLive ?? '{n} CONTRACTS ON FILE').replace('{n}', String(n)) : String(ui.statusStandby ?? 'BOARD STANDBY — NO CONTRACTS ON FILE'), ); } // ── left panel: the board feed ────────────────────────────────────────── _buildVideoPanel() { const { leftX, bodyY, leftW, videoW, videoH } = this.geo; const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY); const s = this.scene.add; // header: ● BOARD FEED // LIVE this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0); cont.add(this.recDot); const head = s .text(20, 6, 'BOARD FEED // LIVE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2.5, }) .setScrollFactor(0); cont.add(head); // 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); this.videoCx = vx + videoW / 2; this.videoCy = vy + videoH / 2; this.videoRect = { x: vx, y: vy, w: videoW, h: videoH }; this.video = null; if (this.hasVideo(JobBoardWindow.VIDEO_KEY)) { const v = s.video(0, 0, JobBoardWindow.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); if (v.video) v.video.muted = true; 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(this.videoCx, this.videoCy); vv.setScale(sc); }; fit(v); const ready = (vv, w, h) => { if (vv !== v) return; fit(vv, w, h); vv.setVisible(true); }; v.on('created', ready); if (v.video && v.video.readyState >= 1) ready(v, 0, 0); cont.add(v); this.video = v; } else { // NO SIGNAL plate (asset missing) — the console still works. 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); const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', { fontFamily: HEADER, fontSize: '14px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 5, align: 'center', }).setScrollFactor(0); const nsub = s.text(this.videoCx, this.videoCy + 12, 'BOARD FEED OFFLINE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 3, align: 'center', }).setScrollFactor(0); cont.add(ns); cont.add(nsub); } // scanlines over the feed (the menu's CRT recipe — the build feed's // twin, its own texture key) const scanKey = 'jobboard_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(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5); cont.add(this.scan); // sweep band (crawls down the feed on a loop) const swKey = 'jobboard_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(this.videoCx, this.videoRect.y - 40, swKey) .setDisplaySize(videoW, 48) .setScrollFactor(0) .setAlpha(0) .setBlendMode(Phaser.BlendModes.ADD); cont.add(this.sweepBand); this.sweepCfg = config.get('builds.fx', null) ?? {}; // bottom status strip (the build feed's shape) 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, 'BOARD STANDBY — NO CONTRACTS ON FILE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2, }) .setScrollFactor(0); cont.add(this.statusTxt); return cont; } // ── painting ───────────────────────────────────────────────────────────── _paintClose() { const b = this.closeBtn; const g = this.closeG; g.clear(); panel(g, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, { notch: 7, fill: C.panel, fillAlpha: b.hover ? 0.85 : 0.4, stroke: C.neon2, strokeAlpha: b.hover ? 1 : 0.55, }); this.closeTxt.setAlpha(b.hover ? 1 : 0.8); } /** * The repaint seam — the list + the detail readout + the feed's status * strip, all off the scene's seams (postings / isAccepted / * isCompleted). Called on open (the seams are re-read — the board is * for the scene's CURRENT system) and after the scene's accept (the * row's ACCEPTED state lands off the fresh ledger). */ refresh() { this._renderList(); this._renderDetail(); this._paintStatus(); } // ── 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.refresh(); // the seams' fresh read (the board is for the CURRENT system) this._startReveal(); } 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 door flips openState in the // SAME input pass, before the scene's pointerdown guard runs — while // the window is still on screen (fading out) it must keep owning the // click, or the close tap leaks through as a world click. get isOpen() { return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing'; } // ── boot reveal ──────────────────────────────────────────────────────────── _startReveal() { const now = this.scene.time.now; const list = []; const push = (o, d, dur, mode, baseY) => list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now }); this.decodeTo(this.rightTitle, 'JOB BOARD', now, 520); if (!this.booted) { push(this, 0, 240, 'fade'); push(this.topMeta, 120, 300, 'fade'); push(this.titleRailG, 160, 220, 'fade'); push(this.closeG, 200, 200, 'fade'); push(this.closeTxt, 200, 200, 'fade'); push(this.videoPanel, 260, 380, 'fade'); push(this.rightTitle, 300, 300, 'rise', this.rightTitle.y); push(this.jobsSection.headTxt, 420, 240, 'fade'); push(this.jobsSection.bg, 440, 260, 'rise', 0); this.jobsSection.content.forEach((o, i) => push(o, 470 + i * 45, 220, 'fade')); push(this.detailSection.headTxt, 600, 240, 'fade'); push(this.detailSection.bg, 620, 260, 'rise', 0); this.detailSection.content.forEach((o, i) => push(o, 660 + i * 45, 220, 'fade')); this.booted = true; this.glitch.next = now + rand(6000, 13000); } else { push(this, 0, 160, 'fade'); } this.reveal = list; this.openState = 'open'; } _glitchBurst(level, at) { this.glitch.until = at + rand(220, 420); this.glitch.level = level; } // ── per-frame (called by the scene's update) ────────────────────────────── 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 (r.mode === 'fade') r.o.setAlpha(e); else if (r.mode === 'rise') { r.o.setAlpha(e); r.o.setY(r.baseY + (1 - e) * 14); } if (u < 1) done = false; } if (done) { for (const r of this.reveal) { r.o.setAlpha(1); if (r.mode === 'rise') r.o.setY(r.baseY); } this.reveal = []; } } // decodes (ScrambleDecode polling) if (this.decodes.length) { for (const d of [...this.decodes]) { if (!d.dec.started(time)) continue; d.txt.setText(d.dec.display(time)); if (d.dec.finished(time)) { d.txt.setText(d.dec.value); const i = this.decodes.indexOf(d); if (i >= 0) this.decodes.splice(i, 1); } } } // ambient glitch bursts (the build console's recipe) if (time >= this.glitch.next) { this._glitchBurst(rand(0.5, 1), time); this.glitch.next = time + rand(6000, 13000); } if (time < this.glitch.until) { const lvl = this.glitch.level ?? 0.8; const { rect } = this.geo; const g = this.glitchG; g.clear(); for (let i = 0; i < 4; i++) { g.fillStyle(i % 2 ? C.neon : C.neon2, rand(0.03, 0.1) * lvl); g.fillRect(rect.x + rand(-8, 8), rand(rect.y, rect.y + rect.h), rect.w, rand(2, 18)); } const dx = 2 + lvl * 2; const t = this.rightTitle; this.titleGhostA.setText(t.text).setPosition(t.x + dx, t.y).setAlpha(0.5 * lvl); this.titleGhostB.setText(t.text).setPosition(t.x - dx, t.y).setAlpha(0.5 * lvl); this.titleGhostB.setColor(toCss(C.neon2)); } else if (this.titleGhostA?.alpha > 0) { this.titleGhostA.setAlpha(0); this.titleGhostB.setAlpha(0); } // title cursor blink (the build console's title-cursor recipe) const blink = Math.floor(time / 480) % 2 === 0 ? 1 : 0.15; this.rightTitleCursor.clear(); this.rightTitleCursor.fillStyle(C.neon, blink); this.rightTitleCursor.fillRect( this.rightTitle.x + this.rightTitle.width + 10, this.rightTitle.y - 16, 9, 19, ); // sweep band over the feed const cfg = this.sweepCfg; if (cfg.enabled !== false && this.sweepBand) { const [lo, hi] = cfg.everyMs ?? [4200, 8600]; if (time >= this.sweep.next) { this.sweep.t0 = time; this.sweep.next = time + rand(lo, hi); } const dur = cfg.durationMs ?? 1500; const u = (time - this.sweep.t0) / dur; if (u >= 0 && u <= 1) { const { y, h } = this.videoRect; this.sweepBand.setY(y - 48 + u * (h + 96)); this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5); } else { this.sweepBand.setAlpha(0); } } // REC dot pulse this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006)); } // ── teardown ─────────────────────────────────────────────────────────────── destroy() { this.destroyVideo(this.video); this.video = null; super.destroy(true); } }