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