DiceCloud20

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();