235 lines
8.0 KiB
JavaScript
235 lines
8.0 KiB
JavaScript
/**
|
|
* @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<jQuery|null>} 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<JQuery|null>} 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<string>} 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 = $(`<div class="chat-bubble" style="visibility:hidden">${message}</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);
|
|
}
|
|
}
|