DiceCloud20

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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

})();