584 lines
22 KiB
JavaScript
584 lines
22 KiB
JavaScript
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';
|
|
|
|
/**
|
|
* COMMS BOX — the wide box a comm shows in, docked just below the mineral
|
|
* bar (upper-right): the sender's clip in a bracketed feed panel on the
|
|
* LEFT, the comm text decoding in on the RIGHT, and — for 'ack' comms —
|
|
* the ACKNOWLEDGE button below the text.
|
|
*
|
|
* ┌──────────────────────────────────────────────────────┐
|
|
* │ ● O.A.C. // ONBOARD A.I. COMPUTER │
|
|
* │ ┌──────┐ So you want to explore the galaxy do │
|
|
* │ │ O A C │ you?... Welcome to your ship... she's │
|
|
* │ │ clip │ not much but she's got room to grow. [ACK]│
|
|
* │ └──────┘ │
|
|
* └──────────────────────────────────────────────────────┘
|
|
*
|
|
* The box is a PASSIVE view (the project rule): the CommsHub
|
|
* (js/comms/CommsHub.js) owns the comm's life — it builds a box per
|
|
* comm, drives `open()` / `update(time)` / `setVoice(on)` / `close()`,
|
|
* and tears it down. The box never plays the voice, never touches the
|
|
* scene's action; it only shows the comm and reports the button press
|
|
* (`onAck` — the hub is the ear, the scene is the voice).
|
|
*
|
|
* The clip is ALWAYS muted: the voice is the comm's speech clip, and
|
|
* the video is the sender's face (the project's video rule — the
|
|
* Research/Build feeds, the landing stage). 'ack' loops it silently
|
|
* while the box holds; 'visual' plays it once.
|
|
*
|
|
* The ACKNOWLEDGE button is the PRIMARY action while a comm holds: it
|
|
* wears the box's amber (against the cyan UI) and — for 'ack' only —
|
|
* a pulsing attention cue (config: box.pulse): an amber halo breathing
|
|
* under it plus two staggered sonar rings expanding out. The pulse is
|
|
* driven per-frame from update() (no tween bookkeeping — it lives
|
|
* exactly as long as the box does, and the hub stops pumping when the
|
|
* box closes).
|
|
*
|
|
* v4 quirks honored (see the ResearchWindow/SurfaceScene notes):
|
|
* - 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 (v4's play() is a no-op then).
|
|
*
|
|
* Config: data/comms.json → box.
|
|
*/
|
|
export class CommsBox extends Phaser.GameObjects.Container {
|
|
/**
|
|
* @param {Phaser.Scene} scene
|
|
* @param {{right:number, top:number}} anchor the mineral bar's lower-right
|
|
* (the box's right edge sticks to anchor.right, its top sits
|
|
* `box.topPad` below anchor.top)
|
|
* @param {{
|
|
* label: string, the sender's display name (the header)
|
|
* role: string, the small line beside it
|
|
* text: string, the comm's words (decode in)
|
|
* loop: boolean, the clip loops silently (ack) or plays once (visual)
|
|
* video: {key:string}|null, the sender's cached clip (null → NO SIGNAL plate)
|
|
* showAck: boolean, the ACKNOWLEDGE button (ack comms)
|
|
* onAck: () => void, the button press (the hub finishes the comm)
|
|
* }} spec
|
|
*/
|
|
constructor(scene, anchor, spec) {
|
|
super(scene, 0, 0);
|
|
this.scene.add.existing(this);
|
|
this.setScrollFactor(0); // UI — pinned to the screen, not the world
|
|
this.setDepth(55); // above the mineral bar (30) and the deck (50); under the full-screen consoles (80)
|
|
|
|
const boxCfg = config.section('comms.box', {});
|
|
const colors = boxCfg.colors ?? {};
|
|
const textCfg = boxCfg.text ?? {};
|
|
const buttonCfg = boxCfg.button ?? {};
|
|
const anim = boxCfg.animation ?? {};
|
|
|
|
this.W = Math.max(320, boxCfg.width ?? 560);
|
|
this.pad = boxCfg.pad ?? 14;
|
|
this.topPad = boxCfg.topPad ?? 12;
|
|
this.videoSize = Math.max(80, boxCfg.videoSize ?? 150);
|
|
this.videoGap = boxCfg.videoGap ?? 14;
|
|
this.headerH = 20;
|
|
|
|
// Palette (data/comms.json → box.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 = boxCfg.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.inMs = Math.max(0, anim.inMs ?? 260);
|
|
this.outMs = Math.max(0, anim.outMs ?? 420);
|
|
|
|
this.onAck = typeof spec.onAck === 'function' ? spec.onAck : null;
|
|
this.showAck = spec.showAck === true;
|
|
this.loopVideo = spec.loop === true; // the clip loops silently (ack) or plays once (visual)
|
|
this.videoSpec = spec.video && spec.video.key ? spec.video : null;
|
|
this.voiceOn = false;
|
|
this._textValue = String(spec.text ?? '');
|
|
|
|
// The ACK pulse (box.pulse config) — built with the button (below);
|
|
// nulls for 'visual' comms (no button, no pulse).
|
|
const pulseCfg = config.section('comms.box.pulse', {});
|
|
this._pulseOn = this.showAck && 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 box) ------------------
|
|
const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap);
|
|
this.text = scene.add
|
|
.text(0, 0, spec.text, {
|
|
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);
|
|
const textW = this.text.width;
|
|
const textH = this.text.height;
|
|
|
|
// ---- the button (ack comms) — measured too (it adds a row) ---------
|
|
this.button = null;
|
|
let buttonH = 0;
|
|
const buttonLabel = String(buttonCfg.label ?? 'ACKNOWLEDGE');
|
|
if (this.showAck) {
|
|
// Measure first (a throwaway) so the box height is final before the
|
|
// real one is built at its slot.
|
|
const probe = new Phaser.GameObjects.Text(scene, 0, 0, buttonLabel.toUpperCase(), {
|
|
fontFamily: fam,
|
|
fontSize: `${buttonCfg.fontSize ?? 11}px`,
|
|
});
|
|
buttonH = probe.height + (buttonCfg.paddingY ?? 7) * 2;
|
|
probe.destroy();
|
|
}
|
|
|
|
const contentTop = this.pad + this.headerH + (boxCfg.headerGap ?? 8);
|
|
const rightColH = textH + (this.showAck ? (buttonCfg.gap ?? 10) + buttonH : 0);
|
|
this.H = contentTop + Math.max(this.videoSize, rightColH) + this.pad;
|
|
|
|
// ---- placement: just below the mineral bar, right edge aligned -----
|
|
this.finalX = anchor.right - this.W;
|
|
this.finalY = anchor.top + this.topPad;
|
|
this.setPosition(this.finalX, this.finalY - 16); // starts 16px high — it slides in
|
|
this.setAlpha(0);
|
|
|
|
this._buildBackplate();
|
|
this._buildHeader(spec.label, spec.role);
|
|
this._buildVideoPanel(contentTop);
|
|
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
|
|
if (this.showAck) {
|
|
// The button wears the box'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._onAckPress(),
|
|
{
|
|
fontSize: buttonCfg.fontSize ?? 11,
|
|
paddingX: buttonCfg.paddingX ?? 16,
|
|
paddingY: buttonCfg.paddingY ?? 7,
|
|
letterSpacing: buttonCfg.letterSpacing ?? 2,
|
|
upper: true,
|
|
screenFixed: true, // the box lives in a scrolling-cam scene
|
|
bgColor: '#181104', // warm dark panel (the box's amber family)
|
|
hoverColor: '#2f2409', // hover warms it
|
|
borderColor: amberCss, // the box's amber — the primary-action edge
|
|
textColor: '#ffe9b8', // warm ink on the amber
|
|
},
|
|
);
|
|
// Right-aligned in the text column: the action anchors the box's
|
|
// lower-right (the menu's button idiom, the console's balance).
|
|
const textX = this.pad + this.videoSize + this.videoGap;
|
|
const textMaxW = Math.max(120, this.W - 2 * 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);
|
|
}
|
|
|
|
// The decode-in (the console's type-out): the comm's words pull
|
|
// themselves out of static over the canonical window.
|
|
this._dec = null;
|
|
this._decT0 = null;
|
|
this._decFinished = false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// the chrome
|
|
// ------------------------------------------------------------------
|
|
|
|
_buildBackplate() {
|
|
const g = this.scene.add.graphics().setScrollFactor(0);
|
|
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);
|
|
this.backplate = g;
|
|
this.add(g);
|
|
}
|
|
|
|
_buildHeader(label, role) {
|
|
const fam = fontStack('body');
|
|
// The voice indicator — amber while the comm'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;
|
|
const 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);
|
|
name.setPosition(dx, this.pad + 8);
|
|
this.add(name);
|
|
const 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);
|
|
roleText.setPosition(dx + name.width + 10, this.pad + 8);
|
|
this.add(roleText);
|
|
this.dot.setPosition(this.pad + 3.5, this.pad + 8);
|
|
// A faint hairline under the header, across the box 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);
|
|
}
|
|
|
|
_buildVideoPanel(contentTop) {
|
|
const s = this.scene;
|
|
const vx = this.pad;
|
|
const vy = contentTop;
|
|
const vs = this.videoSize;
|
|
|
|
// The feed frame: bracket corners + the side ticks (the
|
|
// ResearchWindow archive-feed idiom).
|
|
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);
|
|
|
|
const cx = vx + vs / 2;
|
|
const cy = vy + vs / 2;
|
|
|
|
this.video = null;
|
|
const key = this.videoSpec?.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 sender's face — muted. The voice is the comm's
|
|
// speech clip (the hub plays it); 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(this.loopVideo);
|
|
// Fire it straight away (the SurfaceScene landing-clip idiom): if
|
|
// the browser's autoplay policy holds it, v4 retries internally
|
|
// until it's allowed — and the game only runs after a gesture
|
|
// (the menu click), so muted autoplay is permitted from the start.
|
|
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 box's container
|
|
this.video = v;
|
|
} else {
|
|
this._noSignal(cx, cy, vx, vy, vs);
|
|
}
|
|
}
|
|
|
|
_noSignal(cx, cy, vx, vy, vs) {
|
|
const s = this.scene;
|
|
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.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.add(ns);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// the ACK pulse (the primary-action cue — ack comms only)
|
|
// ------------------------------------------------------------------
|
|
|
|
/** 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 comm is in play): the pulse. A
|
|
* pure function of `time` — nothing to stop, and the hub stops
|
|
* calling it the moment the box 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);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// the hub's handles
|
|
// ------------------------------------------------------------------
|
|
|
|
/** The comm's words decoding in (the hub calls this once, at open). */
|
|
beginText() {
|
|
const dur = decodeDur(this._textValue.length);
|
|
this._decT0 = this.scene.time.now;
|
|
this._dec = new ScrambleDecode(this._textValue, this._decT0, dur);
|
|
this.text.setText('');
|
|
}
|
|
|
|
/** Per-frame (the hub pumps it): the ACK 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 begins the text). */
|
|
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 box 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;
|
|
}
|
|
if (this.video) {
|
|
_destroyVideo(this.video);
|
|
this.video = 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.text?.destroy();
|
|
this.text = null;
|
|
this.backplate?.destroy();
|
|
this.backplate = null;
|
|
super.destroy();
|
|
}
|
|
|
|
_onAckPress() {
|
|
if (this._closing) return;
|
|
// The press plays its own tick (the MenuButton calls the scene's
|
|
// voice); the hub is the ear — it finishes the comm from here.
|
|
if (this.onAck) this.onAck();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// small local helpers (the ResearchWindow/SurfaceScene 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 */
|
|
}
|
|
}
|