orbit/js/ui/JobBoardWindow.js

1402 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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 <WORLD> · 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';
import { gateLockCopy } from '../quests/SideQuests.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.<cat>.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: the LEVEL gate reads LOCKED —
// REQUIRES LEVEL 2 (data → ui.bountyLockBadge); the GATE ACCESS lock
// (the faction boards' hunt / rescue behind a hostile neighbor) names
// the system the player needs access to FIRST (data →
// ui.{bounty,escort}GateLockBadge). The row still SELECTS (the
// dossier is readable before the level lands); the scene's accept
// is the door that refuses.
rightTxt = p.gateLocked === true
? gateLockCopy(p, 'badge')?.text ?? 'LOCKED'
: 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 — or, under the
// GATE ACCESS lock, the GATE's own words naming the system the player
// needs access to first (data → ui.{bounty,escort}GateLockSub).
if (sel.locked === true && !this.isAccepted(sel.id)) {
const gateLine = sel.gateLocked === true ? gateLockCopy(sel, 'sub') : null;
const lock = s
.text(
rightX + 14,
lineY,
gateLine?.text ?? 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.
// Under the GATE ACCESS lock the door reads the gate's own badge,
// the system named (data → ui.{bounty,escort}GateLockBadge).
label =
sel.gateLocked === true
? gateLockCopy(sel, 'badge')?.text ?? 'LOCKED'
: 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);
}
}