orbit/js/ui/JobBoardWindow.js

789 lines
30 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 │ │ │ NO CONTRACTS ON FILE — the postings │ │
* │ │ (muted, 2:3) │ │ │ list lands here (later) │ │
* │ │ + scanlines │ │ └────────────────────────────────────────┘ │
* │ │ + sweep band │ │ JOB DETAILS │
* │ └─────────────────┘ │ ┌────────────────────────────────────────┐ │
* │ BOARD STANDBY — … │ │ SELECT A JOB — the contract readout │ │
* │ │ │ lands here (later) │ │
* │ │ └────────────────────────────────────────┘ │
* └───────────────────────┴──────────────────────────────────────────────┘
*
* 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 postings — flexing from the body's top
* edge) and the JOB DETAILS section (the selected job's contract) —
* both PLACEHOLDERS for now; the postings + the selection land later
* (refresh() is the repaint seam).
*
* 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),
panel: themeColor('panel', 0x0a1120),
bg: themeColor('bg', 0x04060d),
};
/**
* Draw a cut-corner plate with its top-left at (x, y) into an existing
* Graphics (CyberShape.points is centred on (0,0) — translate by the
* plate centre). 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;
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 = 150; // the details panel (the build's detail height)
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 placeholder sections ────────────────────────────
/**
* AVAILABLE JOBS (the board's postings) over JOB DETAILS (the selected
* job's contract) — PLACEHOLDERS for now: framed, labelled, and
* standing by. The postings list + the selection land later; refresh()
* is the repaint seam (the build console's rows/detail shape is the
* model to copy when they arrive).
*/
_buildSections() {
const s = this.scene.add;
const { rightX, rightW } = this.geo;
const mk = (headY, panelY, panelH, head, main, sub) => {
// NOTE: added to THE CONTAINER (this.add), not left 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 headTxt = s
.text(rightX + 6, headY, head, {
fontFamily: HEADER,
fontSize: '10px',
color: toCss(C.dim),
fontStyle: 'bold',
letterSpacing: 3,
})
.setScrollFactor(0);
this.add(headTxt);
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);
const mainTxt = s
.text(rightX + rightW / 2, panelY + panelH / 2 - 8, main, {
fontFamily: HEADER,
fontSize: '12px',
color: toCss(C.faint),
fontStyle: 'bold',
letterSpacing: 3,
align: 'center',
})
.setOrigin(0.5, 0.5)
.setScrollFactor(0);
this.add(mainTxt);
const subTxt = s
.text(rightX + rightW / 2, panelY + panelH / 2 + 12, sub, {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2,
align: 'center',
})
.setOrigin(0.5, 0.5)
.setScrollFactor(0);
this.add(subTxt);
return { headTxt, bg, mainTxt, subTxt };
};
const g = this.geo;
this.jobsSection = mk(
g.availHeadY, g.availTop, g.availH,
'AVAILABLE JOBS',
'NO CONTRACTS ON FILE',
'THE BOARD’S POSTINGS LAND HERE',
);
this.detailSection = mk(
g.detailHeadY, g.detailPanelY, g.detailH,
'JOB DETAILS',
'SELECT A JOB',
'PICK A LISTING TO READ ITS CONTRACT',
);
}
// ── 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);
}
/** Repaint the placeholder sections (the seam — the postings + the
* selected job's contract repaint here when they land). */
refresh() {
// The sections stand as built (NO CONTRACTS ON FILE / SELECT A JOB).
}
// ── open / close ─────────────────────────────────────────────────────────
open() {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the feed would stay frozen after close → reopen.
// resume() handles both a first open (delegates to play()) and a
// re-open of a paused clip.
this.video?.resume?.();
this.sfx('ui_window');
this._startReveal();
}
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);
push(this.jobsSection.mainTxt, 480, 240, 'fade');
push(this.jobsSection.subTxt, 520, 240, 'fade');
push(this.detailSection.headTxt, 560, 240, 'fade');
push(this.detailSection.bg, 580, 260, 'rise', 0);
push(this.detailSection.mainTxt, 620, 240, 'fade');
push(this.detailSection.subTxt, 660, 240, '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);
}
}