762 lines
29 KiB
JavaScript
762 lines
29 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';
|
||
|
||
/**
|
||
* 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 */
|
||
}
|
||
}
|