orbit/js/ui/VignetteWindow.js

762 lines
29 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.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack } from '../utils/Theme.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
/**
* VIGNETTE WINDOW — the centered dialogue window a vignette plays in
* (js/vignettes/VignetteHub.js owns its life): the speaker's PLANET clip
* in a bracketed feed panel on the LEFT, the line's text decoding in on
* the RIGHT, and — on every line — the NEXT button below the text
* (amber, the ACK pulse; the LAST line's button reads DONE and the hub
* closes the window). A scrim dims the world behind it so the window
* owns the moment — the location's looping clip keeps moving beneath
* (that is the set).
*
* ┌──────────────────────────────────────────────────────┐
* │ ● TRESTLE // OUT-OF-SYSTEM MENTOR │
* │ ┌──────┐ Hey kid. You made it to the capital — │
* │ │ carol│ Carol's at the bar, she's been waiting… │
* │ │ clip │ [NEXT] │
* │ └──────┘ │
* └──────────────────────────────────────────────────────┘
*
* The window is a PASSIVE view (the project rule): the VignetteHub
* builds it, drives `open()` / `setLine(spec)` / `update(time)` /
* `setVoice(on)` / `close()`, and tears it down. It never plays the
* voice, never touches the scene's action; it only shows the line and
* reports the button press (`onNext` — the hub is the ear, the scene is
* the voice).
*
* The clip is ALWAYS muted (the comms' video rule — the voice is the
* line's speech clip, and the video is the speaker's face): it loops
* silently while the line holds. When the NEXT line's speaker is a
* different character, `setLine` swaps the face in place (the clip is
* cached game-wide, so the swap is a fresh element off the cache).
*
* The window SIZES TO THE LINE IN PLAY (textHeightFor → _rebuildLayout):
* a later line taller than the first grows the window in place — the
* backplate re-draws, the NEXT/DONE button seats below the NEW text (not
* the first line's), the pulse follows, and the window re-centers — so
* the words never run into the button or the window's edge.
*
* v4 quirks honored (the CommsBox/SurfaceScene video idiom):
* - add.video(x, y, key) — the key is LAST;
* - the element is muted via el.muted (setVolume(0) alone isn't the
* browser's autoplay check);
* - the bookkeeping size is a placeholder until the first presented
* frame — fit now with the best known dimensions, refit on
* 'created', keep the clip hidden until ready (no black flash);
* - resume() not play() after a pause.
*
* Config: data/vignettes.json → window.
*/
export class VignetteWindow extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene the SurfaceScene (screen-fixed — it pins
* itself to the screen, scrollFactor 0)
* @param {{
* label: string, the speaker's display name (the header)
* role: string, the small line beside it
* line: {text: string},the first line's words (decode in)
* video: {key:string}|null, the speaker's cached planet clip (null → NO SIGNAL plate)
* last: boolean, the last line of the scene (the button reads DONE)
* onNext: () => void, the button press (the hub advances / completes)
* }} spec
*/
constructor(scene, spec) {
super(scene, 0, 0);
this.scene.add.existing(this);
this.setScrollFactor(0); // UI — pinned to the screen, not the world
this.setDepth(110); // above the shop view (100) — the window owns the moment
const winCfg = config.section('vignettes.window', {});
const colors = winCfg.colors ?? {};
const textCfg = winCfg.text ?? {};
const buttonCfg = winCfg.button ?? {};
const anim = winCfg.animation ?? {};
const scrimCfg = winCfg.scrim ?? {};
this.W = Math.max(320, winCfg.width ?? 560);
this.pad = winCfg.pad ?? 14;
this.videoSize = Math.max(80, winCfg.videoSize ?? 150);
this.videoGap = winCfg.videoGap ?? 14;
this.headerH = 20;
// Palette (data/vignettes.json → window.colors, theme underneath).
this.panelInt = toColor(colors.panel, 0x0a1120);
this.panelAlpha = Math.max(0, Math.min(1, colors.panelAlpha ?? 0.94));
this.borderInt = toColor(colors.border, 0x22405f);
this.borderAlpha = colors.borderAlpha ?? 0.9;
this.notch = winCfg.notch ?? Math.min(14, 20);
this.neonInt = toColor(colors.neon, 0x00e5ff);
this.inkCss = toCss(toColor(colors.ink, 0xeaf6ff));
this.dimCss = toCss(toColor(colors.dim, 0x7d92c4));
this.faintCss = toCss(toColor(colors.faint, 0x3d4c74));
this.amberInt = toColor(colors.amber, 0xffc94d);
this.nextLabel = String(buttonCfg.label ?? 'NEXT').toUpperCase();
this.doneLabel = String(buttonCfg.doneLabel ?? 'DONE').toUpperCase();
this.inMs = Math.max(0, anim.inMs ?? 260);
this.outMs = Math.max(0, anim.outMs ?? 420);
this.onNext = typeof spec.onNext === 'function' ? spec.onNext : null;
this.lastLine = spec.last === true;
this.voiceOn = false;
this._textValue = String(spec.line?.text ?? spec.text ?? '');
this._videoKey = undefined; // the face in the feed (setLine's swap key — undefined until the first _setVideo, so a first line with no clip still gets its NO SIGNAL plate)
// The button pulse (window.pulse config) — the one live control on
// the surface wears the same attention cue as the comms' ACKNOWLEDGE.
const pulseCfg = config.section('vignettes.window.pulse', {});
this._pulseOn = pulseCfg.enabled !== false;
this._pulseColor = toColor(pulseCfg.color, 0xffd84d);
this._pulsePeriod = Math.max(600, pulseCfg.periodMs ?? 1300);
this._halo = null;
this._ringA = null;
this._ringB = null;
const fam = fontStack('body');
// ---- measure the text (its height sizes the window) ---------------
const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap);
this.textMaxW = textMaxW; // the column (text wrap + the button's right seat)
this.text = scene.add
.text(0, 0, this._textValue, {
fontFamily: fam,
fontSize: `${textCfg.fontSize ?? 13}px`,
lineHeight: { value: textCfg.lineHeight ?? 18 },
color: this.inkCss,
letterSpacing: textCfg.letterSpacing ?? 0.4,
wordWrap: { width: textMaxW, useAdvancedWrap: true },
})
.setOrigin(0, 0)
.setScrollFactor(0);
// The wrapped height (the PROBE — the same measurement setLine re-runs
// per line, so the window and its tests share one source of truth).
const textH = this.textHeightFor(this._textValue);
// ---- the button (every line) — measured too (it adds a row) --------
const buttonLabel = this.lastLine ? this.doneLabel : this.nextLabel;
const probe = new Phaser.GameObjects.Text(scene, 0, 0, buttonLabel, {
fontFamily: fam,
fontSize: `${buttonCfg.fontSize ?? 11}px`,
});
const buttonH = probe.height + (buttonCfg.paddingY ?? 7) * 2;
probe.destroy();
this.buttonH = buttonH; // the layout math (constructor + setLine) reads it
const contentTop = this.pad + this.headerH + (winCfg.headerGap ?? 8);
this.H = contentTop + Math.max(this.videoSize, textH + (buttonCfg.gap ?? 10) + buttonH) + this.pad;
// ---- placement: dead center — it slides in from 16px high ---------
const cx = (scene.scale?.width ?? 1280) / 2;
const cy = (scene.scale?.height ?? 720) / 2;
this.finalX = cx - this.W / 2;
this.finalY = cy - this.H / 2;
this.setPosition(this.finalX, this.finalY - 16);
this.setAlpha(0);
this._buildScrim(scrimCfg);
this._buildBackplate();
this._buildHeader(spec.label, spec.role);
this.text.setPosition(this.pad + this.videoSize + this.videoGap, contentTop);
this.add(this.text); // v4: a directly-built Text is off the display list until added
// The button wears the window's AMBER — the primary action reads
// against the cyan UI (the pulse below makes it unmistakable).
const amberCss = toCss(this.amberInt);
this.button = new MenuButton(
scene,
0, 0, // repositioned below (right-aligned in the text column)
buttonLabel,
() => this._onNextPress(),
{
fontSize: buttonCfg.fontSize ?? 11,
paddingX: buttonCfg.paddingX ?? 16,
paddingY: buttonCfg.paddingY ?? 7,
letterSpacing: buttonCfg.letterSpacing ?? 2,
upper: true,
screenFixed: true, // the window lives in a scrolling-cam scene
bgColor: '#181104', // warm dark panel (the window's amber family)
hoverColor: '#2f2409', // hover warms it
borderColor: amberCss, // the window's amber — the primary-action edge
textColor: '#ffe9b8', // warm ink on the amber
},
);
// Right-aligned in the text column: the action anchors the window's
// lower-right (the menu's button idiom, the console's balance).
const textX = this.pad + this.videoSize + this.videoGap;
this.button.setPosition(
textX + Math.min(this.button.width, textMaxW) - this.button.width / 2,
contentTop + textH + (buttonCfg.gap ?? 10) + buttonH / 2,
);
if (this._pulseOn) this._buildPulse(); // the halo + rings go BEHIND the button
this.add(this.button);
this._setVideo(spec.video);
// The decode-in (the console's type-out): the line's words pull
// themselves out of static over the canonical window.
this._dec = null;
this._decT0 = null;
this._decFinished = false;
}
// ------------------------------------------------------------------
// the chrome
// ------------------------------------------------------------------
/**
* The SCRIM — the world behind the window goes dim (the ConfirmOverlay
* idiom), but unlike the confirm it is a pure backdrop: NOT interactive
* (a click through it lands on the surface, and the scene's
* pointerdown guard stands it down while the scene holds — NEXT/DONE is
* the only live control).
*/
_buildScrim(scrimCfg) {
const W = this.scene.scale?.width ?? 1280;
const H = this.scene.scale?.height ?? 720;
const color = toColor(scrimCfg.color ?? '#02040a', 0x02040a);
this.scrim = this.scene.add
.rectangle(W / 2, H / 2, W, H, color, Math.max(0, Math.min(1, scrimCfg.alpha ?? 0.4)))
.setScrollFactor(0);
this.add(this.scrim);
}
_buildBackplate() {
const g = this.scene.add.graphics().setScrollFactor(0);
this.backplate = g;
this.add(g);
this._drawBackplate();
}
/** (Re)draw the backplate at the window's current W × H — a TALLER line
* re-sizes the window (setLine → _rebuildLayout) and the plate follows. */
_drawBackplate() {
const g = this.backplate;
g.clear();
CyberShape.draw(g, this.W, this.H, {
notch: this.notch,
fill: this.panelInt,
fillAlpha: this.panelAlpha,
stroke: this.borderInt,
strokeAlpha: this.borderAlpha,
lineWidth: 1.5,
glow: this.neonInt,
glowAlpha: 0.14,
});
g.setPosition(this.W / 2, this.H / 2);
}
_buildHeader(label, role) {
const fam = fontStack('body');
// The voice indicator — amber while the line's voice is talking
// (setVoice drives its pulse), faint when the voice is done.
this.dot = this.scene.add.circle(0, 0, 3.5, this.amberInt, 0.35).setScrollFactor(0);
this.add(this.dot);
const dx = this.pad + 3.5 + 5;
this.name = this.scene.add
.text(0, 0, String(label ?? '').toUpperCase(), {
fontFamily: fam,
fontSize: '11px',
color: this.inkCss,
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0);
this.name.setPosition(dx, this.pad + 8);
this.add(this.name);
this.roleText = this.scene.add
.text(0, 0, String(role ?? '').toUpperCase(), {
fontFamily: fam,
fontSize: '9px',
color: this.faintCss,
letterSpacing: 1.5,
})
.setOrigin(0, 0.5)
.setScrollFactor(0);
this.roleText.setPosition(dx + this.name.width + 10, this.pad + 8);
this.add(this.roleText);
this.dot.setPosition(this.pad + 3.5, this.pad + 8);
// A faint hairline under the header, across the window width.
const line = this.scene.add.graphics().setScrollFactor(0);
line.lineStyle(1, this.borderInt, 0.45);
line.lineBetween(this.pad, this.pad + this.headerH, this.W - this.pad, this.pad + this.headerH);
this.add(line);
}
// ------------------------------------------------------------------
// the feed (the speaker's face)
// ------------------------------------------------------------------
/**
* The feed panel: the bracket frame (static), then either the speaker's
* planet clip (muted, looping, contain-fit, hidden until ready) or the
* NO SIGNAL plate (a clip the stall valve couldn't land). `setLine`
* re-enters here when the NEXT line's speaker changes (the clip is
* cached game-wide — the swap is a fresh element off the cache).
*/
_setVideo(spec) {
const s = this.scene;
const key = spec && spec.key ? String(spec.key) : null;
// The feed frame: bracket corners + the side ticks (the
// ResearchWindow archive-feed idiom) — built once, every face under it.
if (!this._feedFrame) {
const s = this.scene;
const vx = this.pad;
const vy = this._contentTop();
const vs = this.videoSize;
const frame = s.add.graphics().setScrollFactor(0);
frame.clear();
_brackets(frame, vx - 5, vy - 5, vs + 10, vs + 10, { length: 12, color: this.neonInt, alpha: 0.7 });
frame.lineStyle(1, this.borderInt, 0.55);
frame.lineBetween(vx - 5, vy + vs / 2, vx - 1, vy + vs / 2);
frame.lineBetween(vx + vs + 1, vy + vs / 2, vx + vs + 5, vy + vs / 2);
this.add(frame);
this._feedFrame = frame;
}
// Same face still in the feed — keep it rolling (the comms idiom).
if (key === this._videoKey) return;
this._destroyFace();
const vx = this.pad;
const vy = this._contentTop();
const vs = this.videoSize;
const cx = vx + vs / 2;
const cy = vy + vs / 2;
this._videoKey = key;
if (key && _hasVideo(s, key)) {
// v4: add.video(x, y, key) — the key is the LAST argument (the
// factory creates AND displays the element).
const v = s.add.video(0, 0, key);
v.setOrigin(0.5);
v.setScrollFactor(0);
// The feed is the speaker's face — muted (the project's video rule:
// the voice is the line's speech clip, never the video). v4's
// setVolume() only sets el.volume, so mute the element too (the
// autoplay-policy check).
v.setVolume(0);
if (v.video) v.video.muted = true;
v.setLoop(true); // the face loops silently while the line holds
// Fire it straight away (the SurfaceScene idiom): v4 retries
// internally until autoplay is allowed, and the game only runs
// after a gesture (the landing click), so muted autoplay is fine.
v.play();
// Fit: contain (the face is never cropped), refit on 'created'
// (the true dimensions), hidden until ready (no black flash).
const fit = (vv, iw = 0, ih = 0) => {
const el = vv.video;
const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 256;
const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 256;
const sc = Math.min(vs / vw, vs / vh);
vv.setPosition(cx, cy);
vv.setScale(sc);
};
fit(v);
const ready = (vv, w, h) => {
if (vv !== v || !vv.active) return;
fit(vv, w, h);
vv.setVisible(true);
if (!vv.video?.paused) return; // already rolling (v4 autoplay retry)
vv.play(); // the first start raced 'created' — nudge it again
};
v.on('created', ready);
if (v.video && v.video.readyState >= 1) ready(v, 0, 0);
this.add(v); // into the window's container
this.video = v;
} else {
this._noSignal(cx, cy);
}
}
_contentTop() {
return this.pad + this.headerH + (config.section('vignettes.window', {}).headerGap ?? 8);
}
/** Tear the current face down (clip + plate) — a swap or destroy. */
_destroyFace() {
if (this.video) {
_destroyVideo(this.video);
this.video = null;
}
this._plate?.destroy();
this._plate = null;
this._plateText?.destroy();
this._plateText = null;
}
_noSignal(cx, cy) {
const s = this.scene;
const vs = this.videoSize;
const ph = s.add.graphics().setScrollFactor(0);
ph.clear();
CyberShape.draw(ph, vs, vs, {
notch: 6,
fill: 0x050a12,
fillAlpha: 0.9,
stroke: this.borderInt,
strokeAlpha: 0.5,
lineWidth: 1,
});
ph.setPosition(cx, cy);
this._plate = ph;
this.add(ph);
const ns = s.add
.text(cx, cy, 'NO SIGNAL', {
fontFamily: fontStack('header'),
fontSize: '12px',
color: this.faintCss,
letterSpacing: 3,
})
.setOrigin(0.5)
.setScrollFactor(0);
this._plateText = ns;
this.add(ns);
}
// ------------------------------------------------------------------
// the button pulse (the one-live-control cue)
// ------------------------------------------------------------------
/** The amber halo (breathing) + two staggered sonar rings (expanding
* out from the button), behind it in the container. Sized off the
* button's final geometry — called after its position is set. */
_buildPulse() {
const s = this.scene;
const bw = this.button.width;
const bh = this.button.height;
const notch = this.button.style?.notch ?? 8;
// The halo — a soft amber fill hugging the button (additive: light
// from behind, not a solid slab).
const halo = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
halo.clear();
CyberShape.draw(halo, bw + 8, bh + 8, {
notch: notch + 2,
fill: this._pulseColor,
fillAlpha: 1, // the alpha is the breathing (update drives it)
});
halo.setPosition(this.button.x, this.button.y);
this._halo = halo;
// The sonar rings — a plain outline a touch larger than the button;
// update() scales/fades them on a half-period stagger, so one is
// always leaving while the other sets.
const mkRing = () => {
const ring = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
ring.clear();
CyberShape.draw(ring, bw + 12, bh + 12, {
notch: notch + 3,
stroke: this._pulseColor,
strokeAlpha: 1,
lineWidth: 2.5,
});
ring.setPosition(this.button.x, this.button.y);
return ring;
};
this._ringA = mkRing();
this._ringB = mkRing();
this.add(this._halo);
this.add(this._ringA);
this.add(this._ringB);
}
/** Per-frame (the hub pumps while the scene is in play): the pulse. A
* pure function of `time` — nothing to stop, and the hub stops
* pumping when the window closes. */
_pulse(time) {
if (!this._pulseOn || !this._ringA || !this._halo) return;
const P = this._pulsePeriod;
const ring = (r, off) => {
const u = ((time + off) % P) / P; // 0..1 — the ring's age this cycle
// Never fully gone: the floor keeps the button framed so the cue
// reads even in the "between waves" moment.
r.setScale(1 + 0.3 * u).setAlpha(0.18 + (1 - u) * 0.67);
};
ring(this._ringA, 0);
ring(this._ringB, P / 2);
// The halo breathes once per cycle — the button's own soft glow.
const b = 0.5 + 0.5 * Math.sin(((time % P) / P) * Math.PI * 2);
this._halo.setAlpha(0.12 + 0.26 * b);
}
// ------------------------------------------------------------------
// layout (the window sizes to the line in play — a taller line must
// never run into the button or the window's edge)
// ------------------------------------------------------------------
/**
* The wrapped height of `value` in the window's text style (a PROBE
* text with the same font/size/line-height/wrap as the live line — this
* build's setText measures synchronously, so a never-added probe is
* exact). The layout math (constructor + setLine) and the dev flow test
* all read THIS — one source of truth.
* @returns {number}
*/
textHeightFor(value) {
const textCfg = config.section('vignettes.window.text', {});
const probe = new Phaser.GameObjects.Text(this.scene, 0, 0, String(value ?? ''), {
fontFamily: fontStack('body'),
fontSize: `${textCfg.fontSize ?? 13}px`,
lineHeight: { value: textCfg.lineHeight ?? 18 },
letterSpacing: textCfg.letterSpacing ?? 0.4,
wordWrap: { width: this.textMaxW, useAdvancedWrap: true },
});
const h = probe.height;
probe.destroy();
return h;
}
/**
* Re-size the window to the line just set (setLine): the backplate
* grows, the NEXT/DONE button seats below the NEW text (not the first
* line's), the pulse follows the button, and the window re-centers —
* anchored top, it grows down. The live text is untouched (its decode
* starts a tick later, so the measurement never renders).
*/
_rebuildLayout(value) {
const winCfg = config.section('vignettes.window', {});
const buttonCfg = winCfg.button ?? {};
const contentTop = this._contentTop();
const th = this.textHeightFor(value);
const gap = buttonCfg.gap ?? 10;
this.H = contentTop + Math.max(this.videoSize, th + gap + this.buttonH) + this.pad;
this._stopContainerTweens(); // no slide/fade fighting the re-center
this._drawBackplate();
const textX = this.pad + this.videoSize + this.videoGap;
this.button.setPosition(
textX + Math.min(this.button.width, this.textMaxW) - this.button.width / 2,
contentTop + th + gap + this.buttonH / 2,
);
this._reseatPulse();
this.finalX = (this.scene.scale?.width ?? 1280) / 2 - this.W / 2;
this.finalY = (this.scene.scale?.height ?? 720) / 2 - this.H / 2;
this.setPosition(this.finalX, this.finalY);
}
/** The pulse follows the button (a re-layout moves it). */
_reseatPulse() {
if (!this._halo) return;
this._halo.setPosition(this.button.x, this.button.y);
this._ringA?.setPosition(this.button.x, this.button.y);
this._ringB?.setPosition(this.button.x, this.button.y);
}
/** Kill the window's own tweens (a fast NEXT press mid-slide-in). */
_stopContainerTweens() {
const tweens = this.scene.tweens?.getTweensOf?.(this) ?? [];
for (const t of tweens) {
try {
t.stop();
} catch {
/* already done */
}
}
}
// ------------------------------------------------------------------
// the hub's handles
// ------------------------------------------------------------------
/**
* The line's words decoding in (the hub calls this at open + setLine).
*/
beginText() {
const dur = decodeDur(this._textValue.length);
this._decT0 = this.scene.time.now;
this._dec = new ScrambleDecode(this._textValue, this._decT0, dur);
this._decFinished = false;
this.text.setText('');
}
/**
* The NEXT line (the hub advances): the header relabels (the new
* speaker), the face swaps when the speaker does (the same clip keeps
* rolling), the text re-decodes, and the button relabels NEXT → DONE
* on the last line.
*
* @param {{
* label: string, role: string,
* line: {text: string},
* video: {key:string}|null,
* last: boolean,
* }} spec
*/
setLine(spec) {
if (spec.label !== undefined) this.name.setText(String(spec.label ?? '').toUpperCase());
if (spec.role !== undefined) {
const roleStr = String(spec.role ?? '').toUpperCase();
this.roleText.setText(roleStr);
// Re-seat the role line beside the (possibly new) name.
this.roleText.setPosition(this.pad + 3.5 + 5 + this.name.width + 10, this.pad + 8);
}
this._textValue = String(spec.line?.text ?? spec.text ?? '');
if (spec.video !== undefined) this._setVideo(spec.video);
if (spec.last !== undefined) {
this.lastLine = spec.last === true;
this.button.setLabel(this.lastLine ? this.doneLabel : this.nextLabel);
}
// Size the window to THIS line (it may be taller than the first —
// the button and the backplate follow; the old first-line height is
// gone), then let the words decode in.
this._rebuildLayout(this._textValue);
this.beginText();
}
/** Per-frame (the hub pumps it): the button pulse, then the text's decode. */
update(time) {
this._pulse(time);
if (!this._dec || this._decFinished) return;
if (!this._dec.started(time)) return;
this.text.setText(this._dec.display(time));
if (this._dec.finished(time)) {
this._decFinished = true;
this.text.setText(this._textValue); // land exactly
}
}
/** The voice is live / done — the header dot pulses while it talks. */
setVoice(on) {
this.voiceOn = !!on;
if (this._dotTween) {
this._dotTween.stop();
this._dotTween = null;
}
if (this.voiceOn) {
this.dot.setFillStyle(this.amberInt, 1);
this._dotTween = this.scene.tweens.add({
targets: this.dot,
scale: 1.5,
duration: 420,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
} else {
this.dot.setFillStyle(this.amberInt, 0.35).setScale(1);
}
}
/** The slide-in (the hub opens it, then the first line decodes). */
open() {
this.beginText();
this.scene.tweens.add({
targets: this,
y: this.finalY,
alpha: 1,
duration: this.inMs,
ease: 'Cubic.easeOut',
});
}
/** The fade-out → destroy. `cb` runs after the teardown (or now). */
close(cb) {
if (this._closing) {
if (typeof cb === 'function') cb();
return;
}
this._closing = true;
if (typeof cb === 'function') {
this.scene.tweens.add({
targets: this,
y: this.finalY + 10,
alpha: 0,
duration: this.outMs,
ease: 'Cubic.easeIn',
onComplete: () => {
this.destroy();
cb();
},
});
} else {
this.destroy();
}
}
/** Tear the window down (the hub's shutdown / close path). */
destroy() {
if (this._closing === 'done') return;
this._closing = 'done';
if (this._dotTween) {
this._dotTween.stop();
this._dotTween = null;
}
this._destroyFace();
this._feedFrame?.destroy();
this._feedFrame = null;
this._halo?.destroy();
this._halo = null;
this._ringA?.destroy();
this._ringA = null;
this._ringB?.destroy();
this._ringB = null;
this.button?.destroy();
this.button = null;
this.dot?.destroy();
this.dot = null;
this.name?.destroy();
this.name = null;
this.roleText?.destroy();
this.roleText = null;
this.text?.destroy();
this.text = null;
this.backplate?.destroy();
this.backplate = null;
this.scrim?.destroy();
this.scrim = null;
super.destroy();
}
_onNextPress() {
if (this._closing) return;
// The press plays its own tick (the MenuButton calls the scene's
// voice); the hub is the ear — it advances the scene from here.
if (this.onNext) this.onNext();
}
}
// ----------------------------------------------------------------------
// small local helpers (the CommsBox video idiom)
// ----------------------------------------------------------------------
function _hasVideo(scene, key) {
const c = scene.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/** Bracket corners framing a rect (the viewfinder chrome). */
function _brackets(g, x, y, w, h, o = {}) {
const L = o.length ?? 12;
g.lineStyle(o.lineWidth ?? 2, o.color ?? 0x00e5ff, o.alpha ?? 0.7);
g.lineBetween(x, y + L, x, y);
g.lineBetween(x, y, x + L, y);
g.lineBetween(x + w - L, y, x + w, y);
g.lineBetween(x + w, y, x + w, y + L);
g.lineBetween(x + w, y + h - L, x + w, y + h);
g.lineBetween(x + w, y + h, x + w - L, y + h);
g.lineBetween(x + L, y + h, x, y + h);
g.lineBetween(x, y + h, x, y + h - L);
}
/** Destroy a video element the project-safe way (the SurfaceScene idiom). */
function _destroyVideo(v) {
if (!v) return;
try {
v.off();
if (typeof v.stop === 'function') v.stop(false);
} catch {
/* the element may already be gone */
}
try {
v.destroy();
} catch {
/* already destroyed */
}
}