DiceCloud20

Allows rolling and sending abilities from Dicecloud character sheets into roll20.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey to install this script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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:12px;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, '"')
                .replace(/[‘’]/g, "'")
                .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 brackets escaped only
                return `${count}d${sides} &#91;${styled.join(", ")}&#93;`;
            });
        }

        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 && (label === "Error")) return; // Don't show Dicecloud Errors
                if (!label && !value) return;

                // if value missing, just show the label itself
                if (!value) {
                    value = `[***${label}***](#" style="display:block;text-align:center)`;
                    label = `‎ `;
                }
                // 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("&#91;"))) {
                    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";
    `;

            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);
    }

})();