Add compact video-only comms box for in-combat visual calls

- Introduce a `compact` hook on CommsHub so scenes can flag which
  comms should play as the small right-edge plate.
- GameScene passes `true` for 'visual' comms while combat is active,
  keeping the battlefield clear while the clip and voice still play.
- CommsBox builds a header/text/button-free variant scaled to
  `box.combat.scale` (2/3) with a configurable right-edge margin.
- Guard `beginText` and `setVoice` for the compact form, which has no
  text object or status dot.
This commit is contained in:
Brian Fertig 2026-09-28 13:28:31 -06:00
parent a5f69f3221
commit 52650ff5b9
4 changed files with 71 additions and 2 deletions

View File

@ -586,6 +586,11 @@
"dim": "#7d92c4",
"faint": "#3d4c74",
"amber": "#ffc94d"
},
"combat": {
"$comment": "In-combat 'visual' comms: the box sheds to a video-only plate hugging the screen's right edge (the hub's compact hook — GameScene passes it when combat is live). scale = the clip's size as a fraction of videoSize; margin = px from the screen's right edge.",
"scale": 0.667,
"margin": 16
}
}
}

View File

@ -77,6 +77,12 @@ export class CommsHub {
* the ACKNOWLEDGE press itself —
* scene bookkeeping that rides on
* a comm being acknowledged)
* compact?: (comm: object) => boolean,
* the comm should play in the compact
* (video-only) form — the box's small
* right-edge plate (the in-combat 'visual'
* call is the scene's; it passes the comm
* about to start)
* onAck?: (comm: object) => void, an 'ack' comm was ACKNOWLEDGED —
* the press itself, fired BEFORE
* onEnd for the same def (so the
@ -102,6 +108,7 @@ export class CommsHub {
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
this.onEnd = typeof o.onEnd === 'function' ? o.onEnd : null;
this.onAck = typeof o.onAck === 'function' ? o.onAck : null;
this.compact = typeof o.compact === 'function' ? o.compact : null;
this.queue = []; // { def, dueAt, assets, loadingDone }
this.current = null; // the comm in play (its def)
@ -284,6 +291,17 @@ export class CommsHub {
const video = entry.assets.video && _hasVideo(s, entry.assets.video.key)
? { key: entry.assets.video.key }
: null;
// The compact (video-only) form — the scene's call (the in-combat
// 'visual' comms in GameScene). A failing hook falls back to the
// full box, never to no box.
let compact = false;
if (this.compact) {
try {
compact = !!this.compact(def);
} catch (e) {
console.warn('[comms] compact hook failed', e);
}
}
this.box = new CommsBox(s, this._anchor(), {
label: char.label ?? def.from,
role: char.role ?? '',
@ -292,6 +310,7 @@ export class CommsHub {
video,
showAck: def.type === 'ack',
onAck: () => this._ack(),
compact,
});
this.box.open();
}

View File

@ -1385,6 +1385,12 @@ export class GameScene extends Phaser.Scene {
if (!hud) return null;
return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH };
},
// IN-COMBAT 'visual' comms take the compact form: the box's small
// video-only plate hugging the right edge of the screen (2/3-size
// clip, no text) — the battlefield stays clear while the video and
// the voice still play. 'ack' comms keep the full box (they need
// the words + the ACKNOWLEDGE button).
compact: (def) => def?.type === 'visual' && !!(this.combat && this.combat.active),
// The ack pause's scene-side effects: the action holds — the ship
// parks dead (target + throttle cleared, velocity zeroed) and the
// mining arm drops. Resuming needs no work (the ship stays parked;

View File

@ -65,7 +65,17 @@ export class CommsBox extends Phaser.GameObjects.Container {
* 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)
* compact: boolean, the IN-COMBAT 'visual' form (see below)
* }} spec
*
* COMPACT MODE (in-combat 'visual' comms — spec.compact): the box sheds
* everything but the clip — no header, no text, no button — and docks
* as a small plate hugging the RIGHT edge of the screen, vertically
* centered, its clip at box.combat.scale (2/3) of the normal videoSize.
* The goal: the combat battlefield stays as unobstructed as possible
* while the comm's video still shows. The clip is still muted (the
* voice is the hub's speech clip — the project's video rule, unchanged);
* the box just stops showing the words.
*/
constructor(scene, anchor, spec) {
super(scene, 0, 0);
@ -101,6 +111,7 @@ export class CommsBox extends Phaser.GameObjects.Container {
this.inMs = Math.max(0, anim.inMs ?? 260);
this.outMs = Math.max(0, anim.outMs ?? 420);
this.compact = spec.compact === true;
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)
@ -118,6 +129,29 @@ export class CommsBox extends Phaser.GameObjects.Container {
this._ringA = null;
this._ringB = null;
// ---- compact (in-combat) form — video only, hugging the right ----
// The clip at box.combat.scale of the normal videoSize, in a plate
// of just its frame: the battlefield keeps the rest of the screen.
// No header, no text, no decode, no button — the hub still plays
// the voice over it, and the open/close paths are the same as the
// full box.
if (this.compact) {
const c = boxCfg.combat ?? {};
this.videoSize = Math.max(40, Math.round(this.videoSize * (c.scale ?? 2 / 3)));
this.W = this.pad * 2 + this.videoSize;
this.H = this.pad * 2 + this.videoSize;
const sw = this.scene.scale?.width ?? 1280;
const sh = this.scene.scale?.height ?? 720;
const m = c.margin ?? 16;
this.finalX = sw - m - this.W;
this.finalY = Math.round(sh / 2 - this.H / 2); // the screen's right-mid line
this.setPosition(this.finalX, this.finalY - 16); // the same 16px slide-in
this.setAlpha(0);
this._buildBackplate();
this._buildVideoPanel(this.pad); // the clip, contain-fit as always
return; // no header / text / button / pulse in this form
}
const fam = fontStack('body');
// ---- measure the text (its height sizes the box) ------------------
@ -428,8 +462,10 @@ export class CommsBox extends Phaser.GameObjects.Container {
// the hub's handles
// ------------------------------------------------------------------
/** The comm's words decoding in (the hub calls this once, at open). */
/** The comm's words decoding in (the hub calls this once, at open).
* A no-op in the compact form (no text object — the clip carries it). */
beginText() {
if (!this.text) return;
const dur = decodeDur(this._textValue.length);
this._decT0 = this.scene.time.now;
this._dec = new ScrambleDecode(this._textValue, this._decT0, dur);
@ -448,9 +484,12 @@ export class CommsBox extends Phaser.GameObjects.Container {
}
}
/** The voice is live / done — the header dot pulses while it talks. */
/** The voice is live / done — the header dot pulses while it talks.
* The compact form has no dot (no header): the state is noted and
* that's all it is for. */
setVoice(on) {
this.voiceOn = !!on;
if (!this.dot) return;
if (this._dotTween) {
this._dotTween.stop();
this._dotTween = null;