/** * ClassWindow — the full-screen SELECT YOUR CLASS console (the level-5 door). * Same cabinet as SHIP / MAP / QUESTS / RESEARCH: a 2:3 feed on the LEFT, * a tab row over ONE plate on the RIGHT — but this one is not a passive read * of live state. It is the screen that DECIDES something: Trestle's briefing, * the two classes, and a PICK that locks one in (js/ui/ConfirmOverlay.js asks * first — js/player/ClassState.js is the only thing that writes the ledger). * * ┌────────────────────────────────────────────────────────────────────┐ * │ CLASS SELECT ▌ COMBAT LEVEL 5 // PICK YOUR PATH [✕] │ * ├───────────────────────┬────────────────────────────────────────────┤ * │ ● RECRUITING OFFICE │ [ ◈ VANGUARD ] [ ◈ WRAITH ] │ * │ ┌─────────────────┐ │ ┌──────────────────────────────────────┐ │ * │ │ classes.mp4 │ │ │ ◆ TRESTLE ▌ OUT-OF-SYSTEM MENTOR │ │ * │ │ 2:3, muted, │ │ │ │ │ * │ │ looping │ │ │ his briefing copy, │ │ * │ │ + scanlines │ │ │ read from classes.json │ │ * │ └─────────────────┘ │ │ (types out at the clip's own pace) │ │ * │ ● his voice plays │ ├──────────────────────────────────────┤ │ * │ once, on open │ │ THE TAB: crest · doctrine · bonus │ │ * └───────────────────────┴──┴──────────────────────────────────────┴──┘ * * THE INTRO is the state BEFORE any tab is picked (this._tab === null): * assets/speech/trestle/classes.mp3 plays once on the first open of the * session while his copy types onto the plate at the clip's own pace — the * video stays MUTED (the comms' rule: the clip is always the voice, never a * second one), and while he talks music + ambience duck (restored exactly * once, whether the clip ends on its own or the console closes under it). * * A CLASS TAB shows that class's crest (its frame off * assets/images/classes.png — 128×128, frame 0 = Vanguard, frame 1 = Wraith), * its doctrine, its INITIAL BONUS and the PICK action. Each class THEMES its * own tab + plate (data/classes.json → classes[].accent — Vanguard reads red, * Wraith reads purple). * * PICK → the confirm dialog ("you can change your class later for a price") → * CONFIRM calls this.onPick(id); the scene writes the ledger and returns true, * and only then does the console close. A class already on file makes every * PICK inert (the swap is a later layer with a price on it). * * The window is still a VIEW: every label, color, frame index, paragraph and * number is data (data/classes.json through js/player/ClassModel.js); the run's * class is read through getClassState() and never guessed here. Depth 85 — * above the other consoles (80), because while this is up it owns the screen. */ import Phaser from '../vendor/phaser.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 { ConfirmOverlay } from './ConfirmOverlay.js'; import { CLASS_ICON_KEY, classAt, classDef, classList, confirmSpec, fillTemplate, iconSheet, introSpec, windowSpec, } from '../player/ClassModel.js'; const HEADER = fontStack('header'); const BODY = fontStack('body'); const rand = (a, b) => a + Math.random() * (b - a); /** 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), }; /** Cut-corner panel (the console idiom's own painter — ShipWindow's). */ 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); } /** A small filled diamond (the plate bullets). */ function diamond(g, cx, cy, r, color, alpha = 1) { g.fillStyle(color, alpha); g.fillPoints( [ { x: cx, y: cy - r }, { x: cx + r, y: cy }, { x: cx, y: cy + r }, { x: cx - r, y: cy }, ], true ); } /** Is the class-insignia sheet on the GPU? (the scene loads it on demand) */ export function classIconsReady(scene) { return !!scene?.textures?.exists(CLASS_ICON_KEY); } /** * Load the class layer's media on demand — js/vignettes/VignetteHub.js's * deferred asset pass, same rule: no boot bytes for a screen most runs see * once (the feed alone is ~2 MB). The insignia sheet, the looping muted feed * and Trestle's brief are each optional; whatever lands is what the console * paints (a missing clip leaves the NO SIGNAL plate up, a missing voice lets * the copy type un-narrated, a missing sheet falls back to themed crests). * * @param {Phaser.Scene} scene * @param {() => void} [onReady] fired when the pass ends (complete OR stalled — * the console is never lost to a slow file) * @returns {boolean} true when a pass actually started (false = already ready) */ export function loadClassMedia(scene, onReady) { const sheet = iconSheet(); const spec = windowSpec(); const intro = introSpec(); const missing = []; // The insignia sheet is a STRIP of 128×128 frames (frame 0 = Vanguard, // frame 1 = Wraith) — loaded as a spritesheet, because `load.image` would // hand back one __BASE frame and every tab would wear the same crest. if (!scene.textures.exists(CLASS_ICON_KEY)) missing.push(['spritesheet', CLASS_ICON_KEY, sheet.texture]); if (!scene.cache?.video?.has?.(ClassWindow.VIDEO_KEY)) missing.push(['video', ClassWindow.VIDEO_KEY, spec.video.file]); if (intro.audio && !scene.cache?.audio?.has?.(ClassWindow.SPEECH_KEY)) missing.push(['audio', ClassWindow.SPEECH_KEY, intro.audio]); if (!missing.length) { try { onReady?.(); } catch { /* the callback is never load-bearing */ } return false; } for (const [kind, key, url] of missing) { if (kind === 'video') scene.load.video(key, url); else if (kind === 'audio') scene.load.audio(key, url); else if (kind === 'spritesheet') scene.load.spritesheet(key, url, { frameWidth: sheet.frameWidth, frameHeight: sheet.frameHeight }); else scene.load.image(key, url); } let done = false; let valve = null; const finish = () => { if (done) return; done = true; scene.load.off('complete', finish); // the other finish path — keep it clean if (valve) { try { valve.remove(false); } catch { /* already fired */ } } try { onReady?.(); } catch { /* whatever landed, the console plays */ } }; scene.load.once('complete', finish); scene.load.start(); valve = scene.time.delayedCall(12000, finish); // the stall valve return true; } export class ClassWindow extends Phaser.GameObjects.Container { /** The left feed's cache key (assets/videos/classes.mp4, loaded by the scene). */ static VIDEO_KEY = 'class_feed'; /** Trestle's briefing cache key (assets/speech/trestle/classes.mp3). */ static SPEECH_KEY = 'class_brief'; /** * @param {Phaser.Scene} scene the owning scene (GameScene) * @param {object} [o] * @param {() => import('../player/ClassState.js').ClassState|null} [o.getClassState] * the run's class ledger (read-only here: it decides what PICK means) * @param {(id: string) => boolean|{ok?: boolean}} [o.onPick] — the CONFIRMed * lock-in; return truthy to let the console close (the scene writes * js/player/ClassState.js and toasts the grant) * @param {() => void} [o.onClose] — the console was dismissed (a defer when * no class is on file; the ship console's door re-opens it) * @param {() => void} [o.onFirstOpen] — first open of the session (the scene * marks the auto-open trigger spent) * @param {(msg: string, o?: object) => void} [o.onToast] — a word on the * screen (the scene's consoleToast; a window never owns the HUD) */ constructor(scene, o = {}) { super(scene, 0, 0); // v4 quirk: a directly-constructed GameObject must register itself. this.scene.add.existing(this); this.setScrollFactor(0); // UI — pinned to the screen this.setDepth(85); // above the other consoles (80) this.getClassState = typeof o.getClassState === 'function' ? o.getClassState : null; this.onPick = typeof o.onPick === 'function' ? o.onPick : null; this.onClose = typeof o.onClose === 'function' ? o.onClose : null; this.onFirstOpen = typeof o.onFirstOpen === 'function' ? o.onFirstOpen : null; this.onToast = typeof o.onToast === 'function' ? o.onToast : null; this.openState = 'closed'; // closed | opening | open | closing this._tab = null; // null = the INTRO (no class picked yet) this._tabs = []; this._kids = []; // every child of the current body repaint this._hits = []; this._decode = []; // { txt, dec } — polled ScrambleDecodes this._glitch = { until: 0, next: 0 }; this._sweep = 0; this._destroyed = false; // the brief's plumbing (exactly-once on both sides) this._briefPlayed = false; // played this session? this._voiceKey = null; // the brief's sound key, while it talks this._voiceTimer = null; // the isPlaying poll (v4 has no 'complete') this._ducked = false; // the beds are ducked right now? this._duckedSounds = null; // [{ s, vol }] — restored exactly this._typing = null; // { full, dur, t0, shown, done } while the copy types this._escBound = null; // the ESC handler (bound on open, unbound on close) this._lastFingerprint = null; // refresh()'s "did the ledger change?" this._build(); this.setVisible(true); this.setAlpha(0); // hidden until open() — built once, shown on demand } sfx(name) { playSfxOn(this.scene, name); } /** The run's ledger (a missing seam reads as "no class on file"). */ get classState() { return this.getClassState?.() ?? null; } /** Is a class already locked in? */ get hasClass() { return !!this.classState?.hasClass; } get isOpen() { return this.openState === 'opening' || this.openState === 'open'; } /** A text child of the current body repaint (or the frame, when host=g). */ txt(x, y, str, o = {}) { const t = this.scene.add .text(x, y, String(str ?? ''), { fontFamily: o.head ? HEADER : BODY, fontSize: `${o.size ?? 11}px`, color: toCss(o.color ?? C.dim), letterSpacing: o.ls ?? 0, align: o.align ?? 'left', lineSpacing: o.leading ?? 0, wordWrap: o.wrap ? { width: o.wrap, breakWord: true } : undefined, ...(o.bold ? { fontStyle: 'bold' } : {}), }) .setScrollFactor(0); if (Array.isArray(o.origin)) t.setOrigin(o.origin[0], o.origin[1]); else if (o.origin !== undefined) t.setOrigin(o.origin); (o.container ?? this.bodyCont).add(t); this._kids.push(t); return t; } /** A graphics child of the current body repaint. */ gfx(container = this.bodyCont) { const g = this.scene.add.graphics().setScrollFactor(0); container.add(g); this._kids.push(g); return g; } // ------------------------------------------------------------ geometry _measure() { const W = this.scene.scale.width; const H = this.scene.scale.height; const m = 10; const pad = 14; const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m }; const titleH = 46; const bodyY = rect.y + titleH + pad; const bodyH = rect.h - titleH - 2 * pad; // Left: the recruiting-office feed — 2:3 (data/classes.json → video.aspect), // the same frame as the ship / research / map / quest feeds. const a = windowSpec().video.aspect; const ar = Number(a[0]) > 0 && Number(a[1]) > 0 ? Number(a[0]) / Number(a[1]) : 2 / 3; const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8); const videoW = Math.max(90, videoH * ar); const leftX = rect.x + pad; const leftW = videoW + 30; // Right: the class tab row over ONE plate (the intro / the doctrine). const rightX = leftX + leftW + pad; const rightW = rect.x + rect.w - pad - rightX; const tabH = windowSpec().tabs.height; const tabGap = 12; const plateY = bodyY + tabH + tabGap; const plateH = Math.max(160, rect.y + rect.h - pad - plateY); return { W, H, rect, titleH, pad, bodyY, bodyH, leftX, leftW, videoW, videoH, rightX, rightW, tabH, tabGap, plateX: rightX, plateY, plateW: rightW, plateH, }; } _build() { const s = this.scene.add; const geo = (this.geo = this._measure()); const spec = windowSpec(); // window background const bg = s.graphics().setScrollFactor(0); panel(bg, geo.rect.x, geo.rect.y, geo.rect.w, geo.rect.h, { notch: 16, fill: 0x04060d, fillAlpha: 0.955, stroke: 0x1b3a5a, strokeAlpha: 0.65, }); this.add(bg); // title + meta (decoded on open) const titleY = geo.rect.y + 12; const titleStyle = { fontFamily: HEADER, fontSize: '19px', fontStyle: 'bold' }; this.titleTxt = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.ink) }).setScrollFactor(0); this.add(this.titleTxt); // the glitch's RGB-split partners (the Map/Quest voice) this.titleGhostA = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon) }).setScrollFactor(0).setAlpha(0); this.add(this.titleGhostA); this.titleGhostB = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon2) }).setScrollFactor(0).setAlpha(0); this.add(this.titleGhostB); const tw = Math.max(150, 8 + spec.title.length * 12); this.titleMeta = s .text(geo.rect.x + 18 + tw, titleY + 6, '', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2.5 }) .setScrollFactor(0); this.add(this.titleMeta); // close button — the interactivity rides the VISIBLE plate (v4 needs // willRender() for a hit candidate; a transparent rect is never clicked) const csize = 30; const cx = geo.rect.x + geo.rect.w - csize - 14; const cy = geo.rect.y + 11; this.closeG = s.graphics().setScrollFactor(0); this.add(this.closeG); this._paintClose(false); this.closeG.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(cx, cy, csize, csize), hitAreaCallback: (area, px, py) => area.contains(px, py), }); this.closeG.on('pointerover', () => { if (!this.isOpen) return; this.sfx('ui_hover'); this._paintClose(true); }); this.closeG.on('pointerout', () => this._paintClose(false)); this.closeG.on('pointerdown', () => { if (this.isOpen && !this.dialog.isOpen) this.dismiss(); }); // the left feed + the right column this.videoPanel = this._buildVideoPanel(); this.add(this.videoPanel); this._buildTabs(); this.plateG = s.graphics().setScrollFactor(0); this.add(this.plateG); this.bodyCont = new Phaser.GameObjects.Container(this.scene, 0, 0).setScrollFactor(0); this.add(this.bodyCont); // the glitch layer (RGB-split slices over the feed + title) this.glitchG = s.graphics().setScrollFactor(0); this.add(this.glitchG); // The PICK confirm (js/ui/ConfirmOverlay.js — hosted by this window so it // always paints above it; its scrim covers the whole console, which is // what makes every tab inert while the dialog is up). const pc = windowSpec().pick; // Centred on the WHOLE console (not the right-hand plate): the dialog sizes // itself to its widest line, and a class plate's centre sits far enough // right that a widened panel used to run off the screen. this.dialog = new ConfirmOverlay( this.scene, geo.W / 2, geo.H / 2, Math.max(300, Math.min(Number(pc.width) || 520, geo.W - 120)), Number(pc.height) || 196, { areaW: geo.W, areaH: geo.H } ); this.dialog.setDepth(30); this.add(this.dialog); this._paintPlate(); this._buildBody(); } /** Repaint the close plate (one persistent graphics object — swapping it * out would orphan its handlers). */ _paintClose(hot) { const csize = 30; const cx = this.geo.rect.x + this.geo.rect.w - csize - 14; const cy = this.geo.rect.y + 11; const g = this.closeG; g.clear(); g.lineStyle(1.5, C.neon, hot ? 0.9 : 0.55); g.strokeRect(cx, cy, csize, csize); g.lineStyle(2, hot ? C.ink : C.dim, 1); g.lineBetween(cx + 9, cy + 9, cx + csize - 9, cy + csize - 9); g.lineBetween(cx + csize - 9, cy + 9, cx + 9, cy + csize - 9); } // ------------------------------------------------------------ video panel _buildVideoPanel() { const { leftX, bodyY, leftW, videoW, videoH } = this.geo; const s = this.scene.add; const spec = windowSpec(); const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY).setScrollFactor(0); // header: ● RECRUITING OFFICE // OPEN CHANNEL (data → video.tag) this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0); cont.add(this.recDot); cont.add( s.text(20, 6, spec.video.tag, { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2.5 }).setScrollFactor(0) ); // frame + the clip const vx = (leftW - videoW) / 2; const vy = 26; const frameG = s.graphics().setScrollFactor(0); brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 }); frameG.lineStyle(1, 0x14324f, 0.55); frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2); frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2); cont.add(frameG); const vcx = vx + videoW / 2; const vcy = vy + videoH / 2; const vrect = { x: vx, y: vy, w: videoW, h: videoH }; // The NO SIGNAL plate is ALWAYS the base layer (a clip that 404s still // registers its cache key — cache.video.has() cannot tell the two apart). this._paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy); this.video = null; if (this.scene.cache?.video?.has?.(ClassWindow.VIDEO_KEY)) { const v = s.video(0, 0, ClassWindow.VIDEO_KEY); v.setOrigin(0.5).setScrollFactor(0); // ambience: muted + looping (v4's setVolume only sets el.volume — the // autoplay policy looks at el.muted, so mute it properly) v.setVolume(0); v.setLoop(true); if (v.video) v.video.muted = true; v.setVisible(false); // the plate stands in until 'created' const fit = (vv, iw = 0, ih = 0) => { const el = vv.video; const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544; const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800; vv.setPosition(vcx, vcy); vv.setScale(Math.min(videoW / vw, videoH / vh)); }; fit(v); v.on('created', (vv, w, h) => { if (vv !== v || v.destroyed) return; fit(vv, w, h); vv.setVisible(true); }); v.on('error', () => { if (v.destroyed) return; if (this.video === v) this.video = null; v.destroy(); }); cont.add(v); this.video = v; } // scanlines over the feed const scanKey = 'classfeed_scanlines'; if (!this.scene.textures.exists(scanKey)) { const c = document.createElement('canvas'); c.width = 4; c.height = 8; const ctx = c.getContext('2d'); ctx.fillStyle = 'rgba(2,6,12,0.5)'; ctx.fillRect(0, 0, 4, 3); ctx.fillStyle = 'rgba(120,220,255,0.05)'; ctx.fillRect(0, 4, 4, 1); this.scene.textures.addCanvas(scanKey, c); } cont.add(s.tileSprite(vcx, vcy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5)); // the sweep band (crawls down the feed on a loop) const swKey = 'classscreen_sweep'; if (!this.scene.textures.exists(swKey)) { const c = document.createElement('canvas'); c.width = 8; c.height = 64; const ctx = c.getContext('2d'); const grad = ctx.createLinearGradient(0, 0, 0, 64); grad.addColorStop(0, 'rgba(127,223,255,0)'); grad.addColorStop(0.5, 'rgba(127,223,255,0.5)'); grad.addColorStop(1, 'rgba(127,223,255,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 8, 64); this.scene.textures.addCanvas(swKey, c); } this.sweepBand = s.image(vcx, vrect.y - 40, swKey).setDisplaySize(videoW, 48).setScrollFactor(0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD); cont.add(this.sweepBand); // the second line under the feed: what this channel is const sy = vy + videoH + 14; const strip = s.graphics().setScrollFactor(0); 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.feedLine = s .text(2, sy + 10, 'CLASSES // LV {n}', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2 }) .setScrollFactor(0); cont.add(this.feedLine); return cont; } /** The 'NO SIGNAL' plate — a procedural static frame (no stand-in clip), * the two class crests' silhouettes drawn from the plate's geometry. */ _paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy) { const spec = windowSpec(); const ph = s.graphics().setScrollFactor(0); 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); } const r = Math.min(videoW, videoH) * 0.13; // the two frames waiting to be claimed: a red slab, a purple shroud ph.lineStyle(1.5, 0xff3a4d, 0.32); ph.strokeRect(vcx - r * 1.15, vcy - r * 0.4, r * 1.0, r * 1.5); ph.lineStyle(1.5, 0xa764ff, 0.32); ph.strokeRect(vcx + r * 0.15, vcy - r * 0.4, r * 1.0, r * 1.5); cont.add(ph); cont.add( s.text(vcx, vcy - r * 1.9, 'NO SIGNAL', { fontFamily: HEADER, fontSize: '14px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 5, align: 'center' }).setScrollFactor(0) ); cont.add( s.text(vcx, vcy - r * 1.9 + 22, spec.video.noSignalTag, { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 3, align: 'center' }).setScrollFactor(0) ); } // ------------------------------------------------------------ class tabs _buildTabs() { const s = this.scene.add; const { rightX, bodyY, rightW, tabH } = this.geo; const defs = classList(); const gap = 10; const icon = windowSpec().tabs.iconSize; // The tabs sit LEFT-aligned in the right column: the class row is short // (two classes today), and a wide centred socket would read like the // ship console's. Each tab is wide enough for its crest + label. const labelW = (d) => 26 + icon + 10 + String(d.label).length * 9; const total = defs.reduce((a, d) => a + labelW(d), 0) + gap * Math.max(0, defs.length - 1); const tabW = Math.max(150, Math.min(280, total / Math.max(1, defs.length), rightW * 0.46)); let x = rightX; for (const d of defs) { const g = s.graphics().setScrollFactor(0); const chip = classIconsReady(this.scene) ? s.image(x + 12 + icon / 2, bodyY + tabH / 2, CLASS_ICON_KEY, d.frame).setDisplaySize(icon, icon).setScrollFactor(0) : null; let placeholder = null; if (!chip) { // no sheet — a themed diamond keeps the tab honest about its class placeholder = s.graphics().setScrollFactor(0); diamond(placeholder, x + 12 + icon / 2, bodyY + tabH / 2, icon / 2 - 2, d.accent, 0.85); } const txt = s .text(x + 14 + icon + (chip ? 6 : 0), bodyY + tabH / 2, d.label, { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 2 }) .setOrigin(0, 0.5) .setScrollFactor(0); const on = { x, y: bodyY, w: tabW, h: tabH }; g.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(on.x, on.y, on.w, on.h), hitAreaCallback: (area, px, py) => area.contains(px, py), }); const mode = () => (this._tab === d.id ? 'active' : this.classState?.id === d.id ? 'filed' : 'idle'); const cell = { g, txt, chip, placeholder, ...on }; g.on('pointerover', () => { if (!this.isOpen || this.dialog.isOpen) return; this.sfx('ui_hover'); this._paintTab(cell, 'hot', d); }); g.on('pointerout', () => this._paintTab(cell, mode(), d)); g.on('pointerdown', () => { if (!this.isOpen || this.dialog.isOpen) return; this.sfx('ui_click'); this._setTab(d.id); }); this._paintTab(cell, mode(), d); this.add(g); if (chip) this.add(chip); else if (placeholder) this.add(placeholder); this.add(txt); this._tabs.push({ id: d.id, def: d, g, txt, chip, placeholder, x, y: bodyY, w: tabW, h: tabH }); x += tabW + gap; } } /** One class tab's visual state: 'active' | 'hot' | 'idle' | 'filed'. */ _paintTab(tab, mode, def) { const accent = def.accent; const g = tab.g; g.clear(); let strokeA = 0.45; let fillA = 0.05; let ink = C.dim; if (mode === 'active') { strokeA = 0.95; fillA = 0.18; ink = C.ink; } else if (mode === 'hot') { strokeA = 0.75; fillA = 0.11; ink = C.ink; } else if (mode === 'filed') { strokeA = 0.8; fillA = 0.1; ink = accent; } panel(g, tab.x, tab.y, tab.w, tab.h, { notch: 9, fill: accent, fillAlpha: fillA, stroke: accent, strokeAlpha: strokeA, glow: mode === 'active' ? accent : undefined, }); if (mode === 'filed') { // the class you already wear: a filled pip in the corner diamond(g, tab.x + tab.w - 12, tab.y + 11, 4, accent, 0.95); } if (tab.chip) tab.chip.setTint(mode === 'idle' ? 0x8fa6c8 : 0xffffff); if (tab.placeholder) tab.placeholder.setAlpha(mode === 'idle' ? 0.55 : 1); tab.txt.setColor(toCss(ink)); } /** Switch the plate to a class tab (null returns to Trestle's briefing). */ _setTab(id) { const def = id == null ? null : classDef(id); const next = def ? def.id : null; this._tab = next; this._repaintTabs(); this._buildBody(); } _repaintTabs() { for (const t of this._tabs) this._paintTab(t, this._tab === t.id ? 'active' : this.classState?.id === t.id ? 'filed' : 'idle', t.def); } backToIntro() { this._setTab(null); } // ------------------------------------------------------------ the plate _paintPlate() { const { plateX, plateY, plateW, plateH } = this.geo; const g = this.plateG; g.clear(); panel(g, plateX, plateY, plateW, plateH, { notch: 10, fill: 0x060b16, fillAlpha: 0.85, stroke: 0x14324f, strokeAlpha: 0.9 }); brackets(g, plateX - 4, plateY - 4, plateW + 8, plateH + 8, { length: 12, color: C.neon, alpha: 0.5 }); } /** Drop the body and rebuild it (the repaint idiom). */ _buildBody() { for (const k of this._kids) { try { k.destroy(); } catch { /* already gone */ } } this._kids = []; this._hits = []; if (this.bodyCont.list) this.bodyCont.list.length = 0; this._typing = null; if (this._tab) this._buildClassBody(this._tab); else this._buildIntroBody(); } /** A plate headline: ◆ LABEL ─────────── meta */ head(x, y, w, label, meta, accent) { const g = this.gfx(); diamond(g, x + 5, y + 8, 4, accent ?? C.neon, 0.9); g.lineStyle(1, 0x14324f, 0.9); g.lineBetween(x + 16, y + 8, x + w, y + 8); this.txt(x + 16, y - 2, label, { color: C.ink, size: 11, ls: 2.4, bold: true }); if (meta) this.txt(x + w, y, meta, { color: C.faint, size: 9, ls: 1.6, align: 'right', origin: [1, 0] }); } /** A button-ish plate with a label; the callback fires on pointerdown. */ button(x, y, w, h, label, accent, onPress, { inert = false, tag = '' } = {}) { const g = this.gfx(); const inkColor = inert ? C.faint : C.ink; const paint = (hot) => { g.clear(); panel(g, x, y, w, h, { notch: 8, fill: accent, fillAlpha: inert ? 0.06 : hot ? 0.28 : 0.14, stroke: accent, strokeAlpha: inert ? 0.3 : hot ? 1 : 0.7, glow: !inert && hot ? accent : undefined, }); }; paint(false); const t = this.txt(x + w / 2, y + h / 2, label, { color: inkColor, size: 13, ls: 2.6, align: 'center', origin: 0.5, bold: true }); if (tag) this.txt(x + w / 2, y + h - 9, tag, { color: C.faint, size: 8, ls: 1.4, align: 'center', origin: [0.5, 1] }); g.setInteractive({ useHandCursor: !inert, hitArea: new Phaser.Geom.Rectangle(x, y, w, h), hitAreaCallback: (area, px, py) => area.contains(px, py), }); g.on('pointerover', () => { if (!this.isOpen) return; this.sfx('ui_hover'); paint(true); }); g.on('pointerout', () => paint(false)); g.on('pointerdown', () => { if (!this.isOpen || this.dialog.isOpen) return; this.sfx('ui_click'); onPress?.(); }); this._hits.push(g); return { g, t, paint }; } // ---------------------------------------------------- body: the briefing /** The state BEFORE any tab is picked — Trestle's copy on the plate. The * text types itself at the clip's own pace (see _startTyping). */ _buildIntroBody() { const intro = introSpec(); const spec = windowSpec(); const { plateX, plateY, plateW } = this.geo; const x = plateX + 18; const w = plateW - 36; // the speaker plate: a diamond chip + TRESTLE ▌ OUT-OF-SYSTEM MENTOR this.head(x, plateY + 16, w, intro.speaker, fillTemplate(spec.meta, { classAt: classAt() }), C.amber); if (intro.role) this.txt(x + 16, plateY + 32, intro.role, { color: C.amber, size: 9, ls: 2 }); // the copy itself (typed; the caret rides along while it runs) const bodyY = plateY + 58; this._introText = this.txt(x, bodyY, '', { color: C.ink, size: 13, leading: 7, wrap: w }); if (!this._typing) this._introText.setText(intro.text); else this._syncTyped(); // the hint + the pointer at the tabs (below the copy once it is up) const hintY = Math.min(this.geo.plateY + this.geo.plateH - 46, bodyY + Math.max(180, this._introText.height + 26)); const g = this.gfx(); g.lineStyle(1, 0x14324f, 0.8); g.lineBetween(x, hintY - 12, x + w, hintY - 12); const shown = !this._typing || this._typing.done; this.txt(x, hintY, intro.hint, { color: shown ? C.neon : C.faint, size: 11, ls: 2.2 }); const ar = this.gfx(); ar.fillStyle(shown ? C.neon : C.faint, 0.9); ar.fillPoints( [ { x: x + w - 2, y: hintY + 7 }, { x: x + w - 14, y: hintY + 1 }, { x: x + w - 14, y: hintY + 13 }, ], true ); } /** Start the typewriter for the brief. `ms` is the clip's own length when * the sound loaded (the copy finishes as he stops talking); a missing/ * unloaded clip paces it at a readable 28 chars/s, clamped to [6s, 34s]. */ _startTyping(ms) { const intro = introSpec(); const len = intro.text.length; const want = Number.isFinite(ms) && ms > 500 ? ms : (len / 28) * 1000; this._typing = { full: intro.text, caret: intro.caret ? '▌' : '', t0: this.scene.time.now, dur: Math.max(6000, Math.min(34000, want)), shown: 0, done: false, }; this._syncTyped(); } /** Paint the typed slice (update() drives it while the brief runs). */ _syncTyped() { const ty = this._typing; const txt = this._introText; if (!ty || !txt || txt.destroyed) return; const u = Math.min(1, (this.scene.time.now - ty.t0) / ty.dur); const n = Math.floor(ty.full.length * u); if (n !== ty.shown || u === 1) { ty.shown = n; txt.setText(ty.full.slice(0, n) + (u < 1 ? ty.caret : '')); } if (u >= 1 && !ty.done) { ty.done = true; txt.setText(ty.full); // the finished copy, no caret if (this._tab === null) this._buildBody(); // the hint brightens with him } } // ------------------------------------------------------- body: one class _buildClassBody(id) { const def = classDef(id); if (!def) return this._buildIntroBody(); const spec = windowSpec(); const plate = spec.plate; const state = this.classState; const filed = state?.id === def.id; const other = !!state?.hasClass && !filed; const { plateX, plateY, plateW, plateH } = this.geo; const x = plateX + 18; const w = plateW - 36; const accent = def.accent; // ---- THE CREST (the class's frame off assets/images/classes.png) ------ const crest = Math.max(64, Math.min(Number(plate.crestSize) || 104, plateH * 0.24)); const cx = x + crest / 2; const cy = plateY + 20 + crest / 2; const frame = this.gfx(); panel(frame, x - 3, plateY + 17, crest + 6, crest + 6, { notch: 8, fill: accent, fillAlpha: 0.1, stroke: accent, strokeAlpha: 0.85, glow: accent }); if (classIconsReady(this.scene)) { const img = this.scene.add.image(cx, cy, CLASS_ICON_KEY, def.frame).setDisplaySize(crest - 6, crest - 6).setScrollFactor(0); this.bodyCont.add(img); this._kids.push(img); } else { // no sheet yet — the class's initial inside its own frame this.txt(cx, cy, def.label.slice(0, 1), { color: accent, size: Math.round(crest * 0.42), align: 'center', origin: 0.5, bold: true, head: true }); } // ---- THE NAME + ROLE + TAGLINE --------------------------------------- const nameX = x + crest + 22; this.txt(nameX, plateY + 20, def.label, { color: accent, size: 26, head: true, bold: true, ls: 3 }); let ry = plateY + 54; if (def.role) { this.txt(nameX, ry, def.role, { color: C.dim, size: 10, ls: 2.2 }); ry += 16; } if (def.tagline) { this.txt(nameX, ry, def.tagline, { color: C.ink, size: 11, ls: 2 }); ry += 18; } if (def.summary) { this.txt(nameX, ry, def.summary, { color: C.dim, size: 11, wrap: Math.max(120, plateX + plateW - 18 - nameX), leading: 4 }); } // the ON FILE chip (a class is already locked in — this one or another) if (filed || other) { const tag = filed ? String(plate.chosenTag ?? 'LOCKED IN') : `ON FILE · ${state.label}`; const tg = this.gfx(); const tw = tag.length * 7 + 18; panel(tg, plateX + plateW - 18 - tw, plateY + 12, tw, 20, { notch: 5, fill: filed ? accent : C.faint, fillAlpha: 0.14, stroke: filed ? accent : C.faint, strokeAlpha: 0.7 }); this.txt(plateX + plateW - 18 - tw / 2, plateY + 22, tag, { color: filed ? accent : C.dim, size: 9, ls: 1.6, align: 'center', origin: 0.5, bold: true }); } // ---- DOCTRINE --------------------------------------------------------- let y = plateY + crest + 40; this.head(x, y, w, plate.overviewTitle ?? 'DOCTRINE', '', accent); y += 22; for (const para of def.overview) { const t = this.txt(x, y, para, { color: C.dim, size: 11.5, wrap: w, leading: 5 }); y += t.height + 10; } // ---- INITIAL BONUS ---------------------------------------------------- y = Math.max(y, plateY + crest + 120); this.head(x, y, w, plate.bonusTitle ?? 'INITIAL BONUS', '', C.amber); y += 22; for (const perk of def.perks) { const g = this.gfx(); diamond(g, x + 5, y + 7, 4, accent, 0.95); this.txt(x + 16, y, perk.label, { color: C.ink, size: 12, ls: 1.6, bold: true }); if (perk.note) { const n = this.txt(x + 16, y + 17, perk.note, { color: C.dim, size: 10.5, wrap: w - 16, leading: 3 }); y += 19 + n.height; } else y += 20; } if (def.skillPoints > 0) { const g = this.gfx(); diamond(g, x + 5, y + 7, 4, C.amber, 0.95); this.txt(x + 16, y, String(plate.skillPointsLine ?? '+{n} SKILL POINTS').replace('{n}', def.skillPoints), { color: C.amber, size: 12, ls: 1.6, bold: true }); if (plate.skillPointsNote) this.txt(x + 16, y + 17, plate.skillPointsNote, { color: C.faint, size: 9.5, ls: 1.2 }); y += 34; } // ---- THE PICK --------------------------------------------------------- const pickH = 46; const pickW = Math.min(w, 340); const by = Math.max(plateY + 40, plateY + plateH - pickH - 18); if (filed) { this.button(x, by, pickW, pickH, `${def.label} LOCKED IN`, accent, null, { inert: true }); } else if (other) { // a class is already on file — the PICK stays visible but inert this.button(x, by, pickW, pickH, 'PICK THIS CLASS', accent, () => this.toast(windowSpec().toasts.alreadyChosen), { inert: true }); } else { this.button(x, by, pickW, pickH, String(spec.pick.label ?? 'PICK THIS CLASS').toUpperCase(), accent, () => this._askPick(def.id)); } // back to the briefing (the copy stays readable without reloading) const back = String(introSpec().backLabel || '◂ BRIEFING'); const bt = this.txt(plateX + plateW - 18, by + pickH / 2, back, { color: C.dim, size: 10, ls: 1.6, align: 'right', origin: [1, 0.5] }); const bg = this.gfx(); const bw = back.length * 7 + 8; bg.fillRect(plateX + plateW - 18 - bw, by + 6, bw, pickH - 12).setAlpha(0); // the visible hit target bg.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(plateX + plateW - 18 - bw, by, bw, pickH), hitAreaCallback: (area, px, py) => area.contains(px, py), }); bg.on('pointerover', () => { if (!this.isOpen) return; this.sfx('ui_hover'); bt.setColor(toCss(C.neon)); }); bg.on('pointerout', () => bt.setColor(toCss(C.dim))); bg.on('pointerdown', () => { if (!this.isOpen || this.dialog.isOpen) return; this.sfx('ui_click'); this.backToIntro(); }); } // ------------------------------------------------------------ the PICK /** The lock-in question (data/classes.json → window.pick). CONFIRM writes * the ledger through onPick and only closes when the scene says yes. */ _askPick(id) { const spec = confirmSpec(id); this.dialog.show({ title: spec.title, // the dialog takes its body as LINES (ConfirmOverlay stacks + fits them) body: [spec.line1, spec.line2].filter(Boolean), accent: spec.accent, confirmLabel: spec.confirmLabel, cancelLabel: spec.cancelLabel, width: spec.width, height: spec.height, time: this.scene.time.now, onConfirm: () => { const res = this.onPick?.(id); const accepted = res === true || res?.ok === true; // CONFIRM writes the ledger and the console closes on the way out // (the ship manifest's SET DESTINATION contract); a refusal — a class // already on file — says why and stays open. if (accepted) this.close({ picked: id }); else this.toast(windowSpec().toasts.alreadyChosen, { glyph: '✕', glyphColor: toCss(spec.accent) }); }, }); } /** A message on the scene's console toast (the seam; never load-bearing). */ toast(msg, o = {}) { if (!msg) return; try { (this.onToast ?? ((m, oo) => this.scene.consoleToast?.(m, oo)))?.(String(msg), o); } catch { /* a toast never breaks the console */ } } // ------------------------------------------------------------ open/close /** Bring the console up: the reveal, the muted looping feed, and (first * time only) Trestle's brief over the intro copy. */ open(o = {}) { if (this.isOpen) return; this.openState = 'opening'; const first = !this._briefPlayed; if (first) { this._briefPlayed = true; try { this.onFirstOpen?.(); } catch { /* the scene's bookkeeping never breaks the window */ } } // an opened console always starts on the briefing when nothing is filed, // and on the class you wear when something is (the door reads its state) this._tab = o.tab ?? (this.hasClass ? this.classState.id : null); this.setPosition(0, 12); this.setAlpha(0); this.setVisible(true); this._repaintTabs(); this._buildBody(); this.feedLine?.setText(fillTemplate(windowSpec().feedLine, { classAt: classAt() })); this.sfx(windowSpec().sfx.lock); // the header decodes (the console's own voice) const now = this.scene.time.now; this.decodeTo(this.titleTxt, windowSpec().title, now, 420); this.decodeTo(this.titleMeta, fillTemplate(windowSpec().meta, { classAt: classAt() }), now + 160, 520); // the feed starts with the window (muted, looping; the plate stands in) if (this.video?.video) { try { this.video.play(true); } catch { /* a video that will not play leaves the NO SIGNAL plate up */ } } // Trestle: ONCE per session, on the first open, over the intro copy. if (first && this._tab === null) this._playBrief(); this.scene.tweens.add({ targets: this, alpha: 1, y: 0, duration: 260, ease: 'Cubic.Out', onComplete: () => (this.openState = 'open'), }); this._bindKeys(); } /** Dismiss without picking (the ✕ / ESC) — a DEFER when nothing is filed. */ dismiss() { this.close({ deferred: true }); } close(o = {}) { if (!this.isOpen) return; // A tab left open still closes on the NEXT repaint, not now (nothing to // save — the ledger only changes through onPick). this._typing = null; if (this.dialog.isOpen) { // a dialog still up when the console closes just collapses (its own // answer was already taken by the ✕ / ESC — never fire a stale callback) this.dialog.onConfirm = null; this.dialog.onCancel = null; this.dialog.startClose(); } if (o.stopSpeech !== false) this._stopBrief(); this.openState = 'closing'; this._unbindKeys(); this.scene.tweens.add({ targets: this, alpha: 0, y: 12, duration: 220, ease: 'Cubic.In', onComplete: () => { this.setVisible(false); this.openState = 'closed'; try { this.video?.pause(); } catch { /* the clip stops on its own when it can't be paused */ } }, }); this.sfx('ui_close'); try { this.onClose?.(o); } catch { /* the scene's seam never breaks the close */ } } /** * Play Trestle's brief (the console's first open) at its own volume while * everything else ducks — the comms' recipe (CommsHub._duck / * js/vignettes/VignetteHub.js): each OTHER playing sound is turned down by a * fraction of its OWN volume and restored exactly when the voice stops, and * the manager's master is never touched (v4: `sound.setVolume` IS the master, * so ducking it would quiet the voice too). No clip / a refused play leaves * the copy typing over the muted feed with nothing left ducked. */ _playBrief() { const spec = introSpec(); const snd = this.scene.sound; if (!spec.audio || !snd || typeof snd.play !== 'function') return; if (!this.scene.cache?.audio?.has?.(ClassWindow.SPEECH_KEY)) return; // not loaded (yet) this._stopVoice(); // never two briefs at once this._voiceKey = ClassWindow.SPEECH_KEY; this._duck(true); try { snd.play(ClassWindow.SPEECH_KEY, { volume: 1 }); } catch { this._voiceKey = null; this._duck(false); return; } // the clip's own length paces the typing (the loader knows it once decoded) let ms = 0; const known = snd.get?.(ClassWindow.SPEECH_KEY); const dur = Number(known?.duration ?? known?.totalDuration ?? 0); if (Number.isFinite(dur) && dur > 0.4) ms = dur * 1000; this._startTyping(ms); // v4 has no reliable 'complete' event — poll isPlaying (the comms' recipe) this._voiceTimer = this.scene.time.addEvent({ delay: 120, loop: true, callback: () => { if (this._destroyed || this._voiceKey !== ClassWindow.SPEECH_KEY) return; if (snd.isPlaying(ClassWindow.SPEECH_KEY) !== true) this._stopVoice(); }, }); } /** Stop the voice + restore what it ducked (exactly once, both ways). */ _stopBrief() { this._stopVoice(); } _stopVoice() { if (this._voiceTimer) { try { this._voiceTimer.remove(false); } catch { /* already gone */ } this._voiceTimer = null; } if (this._voiceKey && this.scene.sound?.stopByKey) { try { this.scene.sound.stopByKey(this._voiceKey); } catch { /* the engine may not know it */ } } this._voiceKey = null; this._duck(false); } /** Duck / restore the other playing sounds (the feed's bed, the music) — * per-sound, exactly once each way. The voice itself is exempt. */ _duck(on) { const snd = this.scene?.sound; if (!snd) return; const ratio = Number(introSpec().musicDuck); if (on && !this._ducked) { this._ducked = true; this._duckedSounds = []; const playing = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : []; for (const s of playing) { if (!s || s.key === this._voiceKey) continue; // the voice is exempt if (typeof s.setVolume !== 'function') continue; // The LOGICAL volume (currentConfig) is the source of truth — the // gain node's read can lag (a suspended context, headless). const vol = s.currentConfig && typeof s.currentConfig.volume === 'number' ? s.currentConfig.volume : s.volume; if (typeof vol !== 'number') continue; this._duckedSounds.push({ s, vol }); try { s.setVolume(vol * ratio); } catch { /* one bed refusing to duck is not worth a stack trace */ } } } else if (!on && this._ducked) { this._ducked = false; for (const { s, vol } of this._duckedSounds ?? []) { try { s.setVolume(vol); } catch { /* it ended mid-duck (a track rolled on) — fine */ } } this._duckedSounds = null; } } /** Catch anything that STARTED after the duck went on (the music rolling to * its next track mid-brief): duck it, remember its true volume, and let the * restore pick it up with the rest. Rate-limited — this runs every frame. */ _duckReassert(time) { if (!this._ducked) return; if (time - (this._duckChecked ?? 0) < 200) return; this._duckChecked = time; const snd = this.scene?.sound; if (!snd || typeof snd.getAllPlaying !== 'function') return; const ratio = Number(introSpec().musicDuck); for (const s of snd.getAllPlaying()) { if (!s || s.key === this._voiceKey) continue; if (typeof s.setVolume !== 'function') continue; if ((this._duckedSounds ?? []).some((d) => d.s === s)) continue; const vol = s.currentConfig && typeof s.currentConfig.volume === 'number' ? s.currentConfig.volume : s.volume; if (typeof vol !== 'number') continue; this._duckedSounds.push({ s, vol }); try { s.setVolume(vol * ratio); } catch { /* one bed refusing to duck is not worth a stack trace */ } } } // ------------------------------------------------------------ keyboard _bindKeys() { if (this._escBound || !this.scene.input?.keyboard) return; this._escBound = (e) => { if (!this.isOpen) return; if (this.dialog.isOpen) return; // the dialog's own ESC answers it this.dismiss(); }; this.scene.input.keyboard.on('keydown-ESC', this._escBound); } _unbindKeys() { if (!this._escBound) return; try { this.scene.input.keyboard.off('keydown-ESC', this._escBound); } catch { /* the scene may already be gone */ } this._escBound = null; } // ------------------------------------------------------------ dressing decodeTo(txt, str, t0, dur = 520) { if (!txt) return; this._decode = this._decode.filter((d) => d.txt !== txt); if (txt.text === str) return; txt.setText(''); this._decode.push({ txt, dec: new ScrambleDecode(str, t0, dur) }); } update(time) { if (this._destroyed || !this.visible) return; if (this.dialog) this.dialog.update(time); // the PICK dialog animates here // The polled ScrambleDecodes (js/utils/Decode.js — started/display/ // finished/value, the idiom every console window drives). for (const d of [...this._decode]) { if (d.txt.destroyed) { const i = this._decode.indexOf(d); if (i >= 0) this._decode.splice(i, 1); continue; } 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._decode.indexOf(d); if (i >= 0) this._decode.splice(i, 1); } } // A late-starting bed (a music track rolling over while Trestle talks) is // ducked too — the voice keeps the screen quiet for its whole length. if (this._voiceKey) this._duckReassert(time); if (!this.isOpen) return; // the brief's typewriter if (this._typing && !this._typing.done) this._syncTyped(); // the REC dot breathes (the channel is hot) const b = 0.5 + 0.45 * Math.sin(time / 380); this.recDot?.setAlpha(0.25 + 0.65 * Math.abs(b)); // the sweep band crawls down the feed if (this.sweepBand) { const v = this.videoRectForSweep(); this._sweep = (this._sweep + 0.0016) % 1; this.sweepBand.y = v.y + this._sweep * (v.h + 80) - 40; this.sweepBand.setAlpha(0.5 * Math.sin(Math.PI * this._sweep)); } // an occasional RGB-split glitch burst over the feed + title if (time > this._glitch.next) { this._glitch.until = time + rand(90, 180); this._glitch.next = time + rand(3500, 9000); } const gOn = time < this._glitch.until; if (!gOn && this.glitchG.renderTexture) this.glitchG.clear(); this.titleGhostA.setAlpha(gOn ? 0.7 : 0); this.titleGhostB.setAlpha(gOn ? 0.6 : 0); if (gOn) { const d = rand(1, 3); this.titleGhostA.setX(this.titleTxt.x - d); this.titleGhostB.setX(this.titleTxt.x + d); const g = this.glitchG; g.clear(); const vp = this.videoPanel; const v = this.videoRectForSweep(); for (let i = 0; i < 3; i++) { const yy = v.y + rand(0, v.h); g.fillStyle(Math.random() < 0.5 ? C.neon : C.neon2, rand(0.05, 0.14)); g.fillRect(vp.x + v.x, vp.y + yy, v.w, rand(1, 3)); } } else { this.titleGhostA.setX(this.titleTxt.x); this.titleGhostB.setX(this.titleTxt.x); } } /** The feed's rect in the video panel's own space (built once). */ videoRectForSweep() { if (!this._vrect) { const { leftW, bodyY, videoW, videoH } = this.geo; this._vrect = { x: (leftW - videoW) / 2, y: 26, w: videoW, h: videoH }; } return this._vrect; } /** Re-read the ledger and repaint if the class on file changed. */ refresh() { const id = this.classState?.id ?? null; if (id !== this._lastFingerprint) { this._lastFingerprint = id; this._repaintTabs(); this._buildBody(); } } destroy() { if (this._destroyed) return; this._destroyed = true; this._unbindKeys(); this._stopBrief(); for (const k of this._kids) { try { k.destroy(); } catch { /* already gone */ } } this._kids = []; super.destroy(); } } export default ClassWindow;