DiceCloud20

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
    }

})();