/** * @typedef {Object} ChatBubbleOptions * @property {string[]} [cssClasses] An optional array of CSS classes to apply to the resulting bubble * @property {boolean} [pan=true] Pan to the token speaker for this bubble, if allowed by the client * @property {boolean} [requireVisible=false] Require that the token be visible in order for the bubble to be rendered */ /** * The Chat Bubble Class * This application displays a temporary message sent from a particular Token in the active Scene. * The message is displayed on the HUD layer just above the Token. */ class ChatBubbles { constructor() { this.template = "templates/hud/chat-bubble.html"; /** * Track active Chat Bubbles * @type {object} */ this.bubbles = {}; /** * Track which Token was most recently panned to highlight * Use this to avoid repeat panning * @type {Token} * @private */ this._panned = null; } /* -------------------------------------------- */ /** * A reference to the chat bubbles HTML container in which rendered bubbles should live * @returns {jQuery} */ get container() { return $("#chat-bubbles"); } /* -------------------------------------------- */ /** * Create a chat bubble message for a certain token which is synchronized for display across all connected clients. * @param {TokenDocument} token The speaking Token Document * @param {string} message The spoken message text * @param {ChatBubbleOptions} [options] Options which affect the bubble appearance * @returns {Promise} A promise which resolves with the created bubble HTML, or null */ async broadcast(token, message, options={}) { if ( token instanceof Token ) token = token.document; if ( !(token instanceof TokenDocument) || !message ) { throw new Error("You must provide a Token instance and a message string"); } game.socket.emit("chatBubble", { sceneId: token.parent.id, tokenId: token.id, message, options }); return this.say(token.object, message, options); } /* -------------------------------------------- */ /** * Speak a message as a particular Token, displaying it as a chat bubble * @param {Token} token The speaking Token * @param {string} message The spoken message text * @param {ChatBubbleOptions} [options] Options which affect the bubble appearance * @returns {Promise} A Promise which resolves to the created bubble HTML element, or null */ async say(token, message, {cssClasses=[], requireVisible=false, pan=true}={}) { // Ensure that a bubble is allowed for this token if ( !token || !message ) return null; let allowBubbles = game.settings.get("core", "chatBubbles"); if ( !allowBubbles ) return null; if ( requireVisible && !token.visible ) return null; // Clear any existing bubble for the speaker await this._clearBubble(token); // Create the HTML and call the chatBubble hook const actor = ChatMessage.implementation.getSpeakerActor({scene: token.scene.id, token: token.id}); message = await TextEditor.enrichHTML(message, { rollData: actor?.getRollData() }); let html = $(await this._renderHTML({token, message, cssClasses: cssClasses.join(" ")})); const allowed = Hooks.call("chatBubble", token, html, message, {cssClasses, pan}); if ( allowed === false ) return null; // Set initial dimensions let dimensions = this._getMessageDimensions(message); this._setPosition(token, html, dimensions); // Append to DOM this.container.append(html); // Optionally pan to the speaker const panToSpeaker = game.settings.get("core", "chatBubblesPan") && pan && (this._panned !== token); const promises = []; if ( panToSpeaker ) { const scale = Math.max(1, canvas.stage.scale.x); promises.push(canvas.animatePan({x: token.document.x, y: token.document.y, scale, duration: 1000})); this._panned = token; } // Get animation duration and settings const duration = this._getDuration(html); const scroll = dimensions.unconstrained - dimensions.height; // Animate the bubble promises.push(new Promise(resolve => { html.fadeIn(250, () => { if ( scroll > 0 ) { html.find(".bubble-content").animate({top: -1 * scroll}, duration - 1000, "linear", resolve); } setTimeout(() => html.fadeOut(250, () => html.remove()), duration); }); })); // Return the chat bubble HTML after all animations have completed await Promise.all(promises); return html; } /* -------------------------------------------- */ /** * Activate Socket event listeners which apply to the ChatBubbles UI. * @param {Socket} socket The active web socket connection * @internal */ static _activateSocketListeners(socket) { socket.on("chatBubble", ({sceneId, tokenId, message, options}) => { if ( !canvas.ready ) return; const scene = game.scenes.get(sceneId); if ( !scene?.isView ) return; const token = scene.tokens.get(tokenId); if ( !token ) return; return canvas.hud.bubbles.say(token.object, message, options); }); } /* -------------------------------------------- */ /** * Clear any existing chat bubble for a certain Token * @param {Token} token * @private */ async _clearBubble(token) { let existing = $(`.chat-bubble[data-token-id="${token.id}"]`); if ( !existing.length ) return; return new Promise(resolve => { existing.fadeOut(100, () => { existing.remove(); resolve(); }); }); } /* -------------------------------------------- */ /** * Render the HTML template for the chat bubble * @param {object} data Template data * @returns {Promise} The rendered HTML * @private */ async _renderHTML(data) { return renderTemplate(this.template, data); } /* -------------------------------------------- */ /** * Before displaying the chat message, determine it's constrained and unconstrained dimensions * @param {string} message The message content * @returns {object} The rendered message dimensions * @private */ _getMessageDimensions(message) { let div = $(``); $("body").append(div); let dims = { width: div[0].clientWidth + 8, height: div[0].clientHeight }; div.css({maxHeight: "none"}); dims.unconstrained = div[0].clientHeight; div.remove(); return dims; } /* -------------------------------------------- */ /** * Assign styling parameters to the chat bubble, toggling either a left or right display (randomly) * @param {Token} token The speaking Token * @param {JQuery} html Chat bubble content * @param {Rectangle} dimensions Positioning data * @private */ _setPosition(token, html, dimensions) { let cls = Math.random() > 0.5 ? "left" : "right"; html.addClass(cls); const pos = { height: dimensions.height, width: dimensions.width, top: token.y - dimensions.height - 8 }; if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w); else pos.left = token.x; html.css(pos); } /* -------------------------------------------- */ /** * Determine the length of time for which to display a chat bubble. * Research suggests that average reading speed is 200 words per minute. * Since these are short-form messages, we multiply reading speed by 1.5. * Clamp the result between 1 second (minimum) and 20 seconds (maximum) * @param {jQuery} html The HTML message * @returns {number} The number of milliseconds for which to display the message */ _getDuration(html) { const words = html.text().split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0); const ms = (words * 60 * 1000) / 300; return Math.clamp(1000, ms, 20000); } }