orbit/js/ui/ClassWindow.js

1354 lines
53 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.

/**
* 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;