
Summary:
This API script creates dynamically scaling chat bubbles above selected tokens in Roll20. The bubbles automatically adjust in size to fit your text and allow customization of font size and display duration. Chat Bubble API v1.0 Instructions
Default Settings:
Font Size: 20
Duration Multiplier: 200 ms/char
Commands:
!chatbubble [message] – Displays a chat bubble above the selected token.
!chatbubble ?{Enter your chat bubble text|Hello!} – Prompts you for a message.
!chatbubble-menu – Opens the settings menu to adjust font size and duration.
!chatbubble-help – Displays the help menu.
!restoreChatBubbleDefaults – Resets settings to default values.
Installation:
Copy and paste the full API code into your Roll20 API Scripts.
/**
* Chat Bubble API v1.0
* Date: 2023-08-28
* Contact: Surok (<a href="https://app.roll20.net/users/335573/surok" rel="nofollow">https://app.roll20.net/users/335573/surok</a>)
*
* This API script creates square chat bubbles above tokens on Roll20,
* with configurable font size and duration multiplier.
*
* Commands:
* - !chatbubble [message]
* Displays a chat bubble above a selected token using the current settings.
* - !chatbubble ?{Enter your chat bubble text|Hello!}
* Alternative macro command that uses a query prompt.
* - !chatbubble-menu
* Opens the settings menu to adjust font size and duration multiplier.
* - !chatbubble-help
* Displays help information about using the API.
*
* Settings in the menu:
* - Set Font Size: Changes the font size used in chat bubbles.
* - Set Duration: Sets the duration multiplier (milliseconds per character).
* - Restore Defaults: Resets font size to 20 and duration multiplier to 200 ms/char.
*/
(function() {
// ============================
// Chat Bubble Settings State
// ============================
state.chatBubbleSettings = state.chatBubbleSettings || {
font_size: 20, // <-- Default font size is now 20
durationMultiplier: 200
};
// ============================
// UI Styling (Chat Menu)
// ============================
const containerStyle = "border: 2px solid black; border-radius: 4px; " +
"box-shadow: 1px 1px 1px #707070; text-align: center; " +
"padding: 3px 0; margin: 0 auto; color: #000; " +
"background-image: -webkit-linear-gradient(-45deg, #a7c7dc 0%, #85b2d3 100%);";
const headerStyle = "text-align: center; margin: 0 0 10px;";
const menuButtonStyle = "display: block; text-decoration: none; color: white; " +
"background-color: #6FAEC7; padding: 5px; border-radius: 4px; " +
"box-shadow: 1px 1px 1px #707070; margin-bottom: 5px;";
const navBlock = '';
function buildMenu(title, content, nav) {
return `<div style="${containerStyle}">` +
`<h3 style="${headerStyle}">${title}</h3>` +
`<div>${content}</div>` +
nav +
`</div>`;
}
// ===================================
// Chat Bubble Appearance Config
// ===================================
const BASE_MARGIN = 3; // Padding inside bubble at default scale
const BASE_CHAR_WIDTH = 7; // Approx. pixels per character at base font
const BASE_LINE_HEIGHT = 18; // Vertical spacing per line at base font
const MIN_WIDTH = 80; // Minimum bubble width
const WRAP_LENGTH = 35; // Max characters per line
const TAIL_WIDTH = 20; // Tail width
const TAIL_HEIGHT = 15; // Tail height
// If you need a horizontal buffer or no max width, you can add that here.
/**
* Splits text into lines of up to maxCharsPerLine, trying to break on spaces.
*/
function wrapText(text, maxCharsPerLine) {
let words = text.split(" ");
let lines = [];
let currentLine = "";
words.forEach(word => {
if ((currentLine.length + word.length + 1) <= maxCharsPerLine) {
currentLine += (currentLine ? " " : "") + word;
} else {
lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) lines.push(currentLine);
return lines.join("\n");
}
/**
* squareBubblePath(width, height):
* Generates a rectangular bubble with a tail at the bottom center.
*/
function squareBubblePath(width, height) {
return JSON.stringify([
["M", 0, 0],
["L", width, 0],
["L", width, height],
["L", (width / 2) + (TAIL_WIDTH / 2), height],
["L", width / 2, height + TAIL_HEIGHT],
["L", (width / 2) - (TAIL_WIDTH / 2), height],
["L", 0, height],
["L", 0, 0],
["Z"]
]);
}
/**
* createChatBubble(anchorX, anchorY, messageText, pageId):
* The bubble's bottom-center is anchored at (anchorX, anchorY).
* This function scales bubble dimensions based on the current font size
* and uses the squareBubblePath for the shape.
*/
function createChatBubble(anchorX, anchorY, messageText, pageId) {
let scaleFactor = state.chatBubbleSettings.font_size / 14;
let effectiveCharWidth = BASE_CHAR_WIDTH * scaleFactor;
let effectiveLineHeight = BASE_LINE_HEIGHT * scaleFactor;
let effectiveMargin = BASE_MARGIN * scaleFactor;
let wrappedText = wrapText(messageText, WRAP_LENGTH);
let lines = wrappedText.split("\n");
let longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0);
// Calculate bubble width & height
let rawWidth = (longestLine * effectiveCharWidth) + (effectiveMargin * 2);
let bubbleWidth = Math.max(MIN_WIDTH, rawWidth);
let bubbleHeight = (lines.length * effectiveLineHeight) + (effectiveMargin * 2);
let totalHeight = bubbleHeight + TAIL_HEIGHT;
// Position so bubble's bottom-center is at (anchorX, anchorY)
let bubbleCenterX = anchorX;
let bubbleCenterY = anchorY - (totalHeight / 2);
// Generate the path
let bubblePath = squareBubblePath(bubbleWidth, bubbleHeight);
// Create the bubble path object
let bubbleObj = createObj("path", {
pageid: pageId,
layer: "objects",
left: bubbleCenterX,
top: bubbleCenterY,
width: bubbleWidth,
height: totalHeight,
stroke: "#000000",
stroke_width: 3,
fill: "#ffffff",
path: bubblePath,
controlledby: ""
});
// Create the text object
let textCenterX = bubbleCenterX;
let textCenterY = bubbleCenterY - (TAIL_HEIGHT / 2);
let textObj = createObj("text", {
pageid: pageId,
layer: "objects",
left: textCenterX,
top: textCenterY,
text: wrappedText,
font_size: state.chatBubbleSettings.font_size,
width: bubbleWidth - (effectiveMargin * 2),
height: bubbleHeight - (effectiveMargin * 2)
});
toFront(bubbleObj);
toFront(textObj);
return { bubbleObj, textObj };
}
// ============================
// Menu & Commands
// ============================
on("chat:message", function(msg) {
if (msg.type !== "api") return;
// !chatbubble-menu
if (msg.content.startsWith("!chatbubble-menu")) {
let content = `<p style="margin:5px 0;">` +
`Font Size: <strong>${state.chatBubbleSettings.font_size}</strong><br>` +
`Duration Multiplier: <strong>${state.chatBubbleSettings.durationMultiplier} ms/char</strong>` +
`</p>`;
let options = `<ul style="list-style:none; padding:0; margin:0;">` +
`<li><a href="!setChatBubbleFontSize ?{New Font Size|${state.chatBubbleSettings.font_size}}" style="${menuButtonStyle}">Set Font Size</a></li>` +
`<li><a href="!setChatBubbleDuration ?{New Duration Multiplier (ms per char)|${state.chatBubbleSettings.durationMultiplier}}" style="${menuButtonStyle}">Set Duration</a></li>` +
`<li><a href="!restoreChatBubbleDefaults" style="${menuButtonStyle}">Restore Defaults</a></li>` +
`<li><a href="!chatbubble-help" style="${menuButtonStyle}">Help</a></li>` +
`</ul>`;
let fullMenu = buildMenu("Chat Bubble Settings", content, options);
sendChat("ChatBubble Menu", "/w gm " + fullMenu);
}
// !setChatBubbleFontSize <value>
else if (msg.content.startsWith("!setChatBubbleFontSize")) {
let args = msg.content.split(" ");
let newSize = parseInt(args[1], 10);
if (isNaN(newSize)) {
sendChat("ChatBubble Settings", "/w gm Please enter a valid number for font size.");
return;
}
state.chatBubbleSettings.font_size = newSize;
sendChat("ChatBubble Settings", "/w gm Font size updated to " + newSize);
sendChat("ChatBubble Settings", "!chatbubble-menu");
}
// !setChatBubbleDuration <value>
else if (msg.content.startsWith("!setChatBubbleDuration")) {
let args = msg.content.split(" ");
let newDur = parseInt(args[1], 10);
if (isNaN(newDur)) {
sendChat("ChatBubble Settings", "/w gm Please enter a valid number for duration multiplier.");
return;
}
state.chatBubbleSettings.durationMultiplier = newDur;
sendChat("ChatBubble Settings", "/w gm Duration multiplier updated to " + newDur + " ms/char");
sendChat("ChatBubble Settings", "!chatbubble-menu");
}
// !restoreChatBubbleDefaults
else if (msg.content.startsWith("!restoreChatBubbleDefaults")) {
state.chatBubbleSettings.font_size = 20; // Default now 20 if you want
state.chatBubbleSettings.durationMultiplier = 200;
sendChat("ChatBubble Settings", "/w gm Settings restored to defaults (font_size=20).");
sendChat("ChatBubble Settings", "!chatbubble-menu");
}
// !chatbubble-help
else if (msg.content.startsWith("!chatbubble-help")) {
let helpContent = `<p style="margin:5px 0; text-align:left;">` +
`<strong>Usage:</strong><br>` +
`1. Select a token on the tabletop.<br>` +
`2. Type <code>!chatbubble [message]</code> to display a chat bubble above the token.<br>` +
` Alternatively, use the macro: <code>!chatbubble ?{Enter your chat bubble text|Hello!}</code><br>` +
`3. Use <code>!chatbubble-menu</code> to adjust settings (font size, duration).<br>` +
`4. Use <code>!chatbubble-help</code> to view this help message.` +
`</p>`;
let backButton = `<a href="!chatbubble-menu" style="${menuButtonStyle}">Back to Main Menu</a>`;
let fullHelp = buildMenu("ChatBubble Help", helpContent, backButton);
sendChat("ChatBubble Help", "/w gm " + fullHelp);
}
// !chatbubble <message>
else if (msg.content.startsWith("!chatbubble ")) {
let messageText = msg.content.slice("!chatbubble ".length).trim();
if (!messageText) {
sendChat("ChatBubble", "/w gm Usage: !chatbubble <message>");
return;
}
if (!msg.selected || !msg.selected.length) {
sendChat("ChatBubble", "/w gm Please select a token for the chat bubble.");
return;
}
msg.selected.forEach(sel => {
let token = getObj("graphic", sel._id);
if (!token) return;
let tokenHalfW = token.get("width") / 2;
let tokenHalfH = token.get("height") / 2;
let anchorX = token.get("left") + tokenHalfW;
let anchorY = token.get("top") - tokenHalfH;
// Create the bubble
let bubble = createChatBubble(anchorX, anchorY, messageText, token.get("pageid"));
// Remove after a duration
let duration = Math.max(3000, messageText.length * state.chatBubbleSettings.durationMultiplier);
setTimeout(() => {
if (bubble.bubbleObj) bubble.bubbleObj.remove();
if (bubble.textObj) bubble.textObj.remove();
}, duration);
});
}
});
})();