Greasy Fork is available in English.
Allows rolling and sending abilities from Dicecloud character sheets into roll20.
// ==UserScript==
// @name DiceCloud20
// @namespace http://tampermonkey.net/
// @version 1.234
// @description Allows rolling and sending abilities from Dicecloud character sheets into roll20.
// @match *://dicecloud.com/*
// @match *://app.roll20.net/editor*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @esversion 11
// @license GPL-3.0-or-later
// ==/UserScript==
(function () {
"use strict";
const KEY = "DC_ROLL20_BRIDGE";
const KEY_WHISPER = "DC_WHISPER_MODE";
const clean = s => s?.replace(/\s+/g, " ").trim();
////////////////////////////////////////////////////////////
// DICECLOUD SIDE
////////////////////////////////////////////////////////////
if (location.hostname.includes("dicecloud")) {
const LOG_SELECTOR = ".character-log";
function htmlToMarkdown(el) {
if (!el) return "";
let html = el.innerHTML;
return clean(
html
.replace(/<\/p>\s*<p[^>]*>/gi, `[ ](# "style='display:block;margin-top:6px;pointer-events:none'")`)
//.replace(/<br\s*\/?>/gi, `[ ](# "style='display:block;margin-top:12px;pointer-events:none'")`)
.replace(/<(strong|b)>(.*?)<\/\1>/gi, "**$2**")
.replace(/<(em|i)>(.*?)<\/\1>/gi, "*$2*")
.replace(/<del>(.*?)<\/del>/gi, "~~$1~~")
.replace(/<a\s+href="(.*?)">(.*?)<\/a>/gi, "[$2]($1)")
.replace(/<img\s+[^>]*alt="(.*?)"[^>]*src="(.*?)"[^>]*>/gi, "[$1]($2)")
.replace(/<[^>]+>/g, "") // strip any remaining tags
);
}
function highlightRolls(text) {
if (!text) return text;
return text.replace(/(\d+)d(\d+)\s*\[([^\]]+)\]/gi, (match, count, sides, rolls) => {
const s = Number(sides);
const styled = rolls.split(",").map(r => {
r = r.trim();
const n = Number(r);
const style = c => `[${n}](# " style='color:${c};font-weight:bold;pointer-events:none'")`;
if (n === 1) return style("red"); // fail
if (n === s) return style("green"); // max/crit
return r;
});
// 👇 OUTER bracket escaped only
return `${count}d${sides} [${styled.join(", ")}]`;
});
}
function formatPrettyTemplate(entry) {
const charName = clean(document.querySelector(".v-toolbar__title")?.innerText || "Character");
const lines = entry.querySelectorAll(".content-line");
if (!lines.length) return null;
const templateLines = [];
lines.forEach(line => {
let label = htmlToMarkdown(line.querySelector(".content-name"));
let value = highlightRolls(htmlToMarkdown(line.querySelector(".content-value")));
if (!label && !value) return;
// if value missing, just show the label itself
if (!value) {
value = label;
label = "Meta";
}
// send Initiative rolls to Roll20 turn tracker
if (label.includes("Initiative") && value) {
const match = value.match(/^(.*?)(\d+)(\D*)$/);
if (match) {
value = `${match[1].trim()} [[${match[2]}${match[3]} &{tracker}]]`;
}
}
// add "=" only if missing and trailing number exists
if ((!value.includes(" = ")) && (value.includes("["))) {
value = value.trim();
const match = value.match(/^(.*?)(\d+)(\D*)$/);
if (match) {
value = `${match[1].trim()} = ${match[2]}${match[3]}`;
}
}
templateLines.push(`{{${label}=${value}}}`);
});
if (!templateLines.length) return null;
const whisper = GM_getValue(KEY_WHISPER, true); // default ON
const prefix = whisper ? "/w gm " : "";
return `${prefix}&{template:default} {{name=${charName}}} ${templateLines.join(" ")}`;
}
function hook() {
const log = document.querySelector(LOG_SELECTOR);
if (!log) return setTimeout(hook, 1000);
console.log("[Bridge] DiceCloud hooked");
let ready = false;
let idleTimer;
function resetIdle() {
clearTimeout(idleTimer);
// after 8s of silence, we consider history done
idleTimer = setTimeout(() => {
ready = true;
console.log("[Bridge] History load finished — live mode ON");
}, 8000);
}
resetIdle(); // start waiting immediately
new MutationObserver(muts => {
// while loading history, just keep resetting timer
if (!ready) {
resetIdle();
return;
}
for (const m of muts) {
for (const node of m.addedNodes) {
if (!node.classList?.contains("log-entry")) continue;
const msg = formatPrettyTemplate(node);
if (!msg) continue;
console.log("[Bridge] Sending:", msg);
GM_setValue(KEY, { msg, t: Date.now() });
}
}
}).observe(log, { childList: true, subtree: true });
}
hook();
}
////////////////////////////////////////////////////////////
// ROLL20 SIDE
////////////////////////////////////////////////////////////
if (location.hostname.includes("roll20")) {
console.log("[Bridge] Roll20 polling started");
function injectGameIconsFont() {
if (document.getElementById("dc-gameicons-font")) return;
const style = document.createElement("style");
style.id = "dc-gameicons-font";
style.textContent = `
@font-face {
font-family: "GameIcons";
src: url("https://raw.githubusercontent.com/ThaumRystra/DiceCloud/develop/app/public/fonts/game-icons.woff") format("woff");
}
`;
document.head.appendChild(style);
}
injectGameIconsFont();
function addWhisperToggleButton() {
const KEY_WHISPER = "DC_WHISPER_MODE";
if (document.getElementById("dc-whisper-toggle-btn")) return;
const sendBtn = document.getElementById("chatSendBtn");
if (!sendBtn) {
// Roll20 loads async, retry
return setTimeout(addWhisperToggleButton, 500);
}
const wrapper = document.createElement("div");
wrapper.id = "dc-whisper-toggle-btn";
wrapper.style.cssText = `
position: relative;
display: inline-block;
width: 1.7rem;
height: 1.7rem;
cursor: pointer;
vertical-align: middle;
`;
// main icon
const img = document.createElement("img");
img.src = "https://v1.dicecloud.com/crown-dice-logo-cropped-transparent.png";
img.style.cssText = `
width: 100%;
height: 100%;
border-radius: 0px;
transition: filter 0.15s ease;
`;
// small GM badge
const badge = document.createElement("span");
badge.textContent = "GM";
badge.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: bold;
padding: 0px 0px;
border-radius: 0px;
background: transparent;
color: white;
pointer-events: none;
font-family: "GameIcons", sans-serif;
`;
wrapper.appendChild(img);
wrapper.appendChild(badge);
// Updates button color + tooltip
function updateButton() {
const whisper = GM_getValue(KEY_WHISPER, true);
img.style.filter = whisper
? "hue-rotate(0deg) saturate(200%)" // green tint => hue-rotate(100deg)
: "hue-rotate(0deg) saturate(200%)"; // red tint => hue-rotate(0deg)
badge.style.color = whisper ? "white" : "transparent";
wrapper.title = `Dicecloud20 - Whisper to GM: ${whisper ? "ON" : "OFF"}`;
}
// Click toggles state
wrapper.addEventListener("click", () => {
const current = GM_getValue(KEY_WHISPER, true);
GM_setValue(KEY_WHISPER, !current);
// Update button color and tooltip
updateButton();
// Force immediate tooltip update
const t = wrapper.title;
wrapper.removeAttribute("title");
wrapper.setAttribute("title", t);
});
updateButton();
sendBtn.parentElement.appendChild(wrapper);
window.addEventListener("resize", () => {
const newSize = window.innerWidth < 500 ? "1.2rem" : "1.7rem";
btn.style.width = newSize;
btn.style.height = newSize;
});
}
// Call it after Roll20 loads
addWhisperToggleButton();
let lastTime = 0;
function send(message) {
const ta = document.querySelector("#textchat-input textarea");
if (!ta) {
console.log("[Bridge send] ERROR: textarea not found");
return;
}
ta.focus();
ta.value = message;
ta.textContent = message;
ta.dispatchEvent(new Event("input", { bubbles: true }));
// Simulate real Enter key
const evt = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13
});
ta.dispatchEvent(evt);
console.log("[Bridge send] Sent:", message);
}
setInterval(() => {
const data = GM_getValue(KEY);
if (!data) return;
if (data.t <= lastTime) return;
lastTime = data.t;
send(data.msg);
}, 200);
}
})();