DiceCloud20

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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} &#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 && !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("&#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", 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);
    }

})();