1354 lines
53 KiB
JavaScript
1354 lines
53 KiB
JavaScript
/**
|
||
* 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;
|