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