1284 lines
48 KiB
JavaScript
1284 lines
48 KiB
JavaScript
/**
|
||
* 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 system'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 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
|
||
// system'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 system'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';
|
||
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(C.ink),
|
||
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 unit = String(ui.rewardUnit ?? 'CR');
|
||
let rightTxt;
|
||
let rightColor;
|
||
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') {
|
||
rightTxt = 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: '10px',
|
||
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, 0.95);
|
||
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;
|
||
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)) {
|
||
const route = s
|
||
.text(
|
||
rightX + 14,
|
||
lineY,
|
||
String(ui.routeProgress ?? 'ROUTE — {visited}/{total} WAYPOINTS LOGGED')
|
||
.replace('{visited}', String(prog.visited))
|
||
.replace('{total}', String(prog.total)),
|
||
{ fontFamily: BODY, fontSize: '9.5px', color: toCss(this._activeColor()), letterSpacing: 2, fontStyle: 'bold' },
|
||
)
|
||
.setScrollFactor(0);
|
||
this.add(route);
|
||
this.detailSection.content.push(route);
|
||
lineY += 16;
|
||
}
|
||
if (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;
|
||
}
|
||
const body = s
|
||
.text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
|
||
fontFamily: BODY,
|
||
fontSize: '10.5px',
|
||
color: toCss(C.dim),
|
||
lineSpacing: 3,
|
||
wordWrap: wrapW,
|
||
})
|
||
.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 === 'open') {
|
||
label = String(ui.accept ?? 'ACCEPT CONTRACT');
|
||
color = accent;
|
||
action = 'accept';
|
||
} else if (state === 'accepted') {
|
||
label = String(ui.activate ?? 'SET PATROL ACTIVE');
|
||
color = C.green;
|
||
action = 'activate';
|
||
} else if (state === 'active') {
|
||
label = 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);
|
||
}
|
||
}
|