☰

YUA × XToys Bridge

YUA様のゲーム内イベントをXToysデバイスとリアルタイムで連携します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         YUA × XToys Bridge
// @name:zh-CN   YUA × XToys Bridge
// @name:ja      YUA × XToys Bridge
// @name:en      YUA × XToys Bridge
// @namespace    yua-xtoys
// @version      1.0.0
// @description         Connect YUA's games with XToys.
// @description:zh-CN   将 YUA 的游戏事件实时联动至 XToys 设备。
// @description:ja      YUA様のゲーム内イベントをXToysデバイスとリアルタイムで連携します。
// @description:en      Connect YUA's in-game events to XToys devices in real time.
// @author       Aoba
// @match        https://yotogi-chan.itch.io/*
// @match        https://html-classic.itch.zone/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    const VERSION = '1.0.0';
    const OUTER_ORIGIN = 'https://yotogi-chan.itch.io';
    const GAME_ORIGIN = 'https://html-classic.itch.zone';
    const STORAGE_KEY = 'yua.manager.v1';
    const LANG_KEY = 'yua.xtoys.lang';

    // Prevent duplicate initialization
    if (window.__YUA_XTOYS_V1__) return;
    window.__YUA_XTOYS_V1__ = true;

    const log = (...args) => console.log(
        `%c[YUA × XToys ${VERSION}]`,
        'background:#8e24aa;color:white;font-weight:bold;padding:2px 5px',
        ...args
    );

    /* =========================================================
       i18n
       ========================================================= */

    const I18N = {
        'zh-CN': {
            bridgeReady: 'Bridge 已就绪',
            waitingGame: '等待游戏',
            gameReady: '游戏检测已就绪',
            lastEvent: '最后事件',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: '处罚',
            sec: '秒',

            temptation: '拒绝 YUA 的诱惑',
            false_cheat: '错误揭发作弊',
            tongue_self: '舌尖裁定:选择自己',
            othello_loss: '黑白棋失败',
            mahjong_loss: '麻将失败',

            othello_win: '黑白棋胜利',
            mahjong_win: '麻将胜利',
            temptation_follow: '服从 YUA 的诱惑',
            tongue_follow: '舌尖裁定:交给 YUA'
        },

        ja: {
            bridgeReady: 'Bridge 準備完了',
            waitingGame: 'ゲームを待機中',
            gameReady: 'ゲーム検出 準備完了',
            lastEvent: '最後のイベント',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: '罰',
            sec: '秒',

            temptation: 'YUA様の誘惑を拒否',
            false_cheat: '誤って作弊を指摘',
            tongue_self: '舌尖裁定:自分で打つ',
            othello_loss: 'オセロ敗北',
            mahjong_loss: '麻雀敗北',

            othello_win: 'オセロ勝利',
            mahjong_win: '麻雀勝利',
            temptation_follow: 'YUA様の誘惑に従う',
            tongue_follow: '舌尖裁定:YUA様に任せる'
        },

        en: {
            bridgeReady: 'Bridge Ready',
            waitingGame: 'Waiting for Game',
            gameReady: 'Game Detector Ready',
            lastEvent: 'Last Event',
            none: '—',
            pulse: 'Rapid Tap',
            punishment: 'Punishment',
            sec: 'sec',

            temptation: "YUA's temptation refused",
            false_cheat: 'False cheat accusation',
            tongue_self: 'Tongue Judgement: self choice',
            othello_loss: 'Othello loss',
            mahjong_loss: 'Mahjong loss',

            othello_win: 'Othello win',
            mahjong_win: 'Mahjong win',
            temptation_follow: "Followed YUA's temptation",
            tongue_follow: 'Tongue Judgement: followed YUA'
        }
    };

    function detectLanguage() {
        const saved = localStorage.getItem(LANG_KEY);

        if (I18N[saved]) {
            return saved;
        }

        const browser =
            (navigator.language || '').toLowerCase();

        if (browser.startsWith('ja')) {
            return 'ja';
        }

        if (browser.startsWith('zh')) {
            return 'zh-CN';
        }

        return 'en';
    }

    /* =========================================================
       OUTER PAGE
       itch.io → XToys Webpage Monitor
       ========================================================= */

    if (location.origin === OUTER_ORIGIN) {

        let lang = detectLanguage();

        let lastState = {
            type: 'none'
        };

        let gameReady = false;

        /* -----------------------------------------------------
           Hidden XToys bridge
           ----------------------------------------------------- */

        let bridge =
            document.querySelector('#yua-xtoys-bridge');

        if (!bridge) {
            bridge = document.createElement('div');

            bridge.id = 'yua-xtoys-bridge';
            bridge.style.display = 'none';
            bridge.textContent = 'ready';

            document.body.appendChild(bridge);
        }

        /* -----------------------------------------------------
           Status UI
           ----------------------------------------------------- */

        let status =
            document.querySelector('#yua-xtoys-status');

        if (!status) {
            status = document.createElement('div');
            status.id = 'yua-xtoys-status';

            Object.assign(
                status.style,
                {
                    position: 'fixed',
                    right: '12px',
                    bottom: '12px',
                    zIndex: '2147483647',

                    minWidth: '230px',

                    padding: '10px 12px',

                    background:
                        'rgba(20,12,24,.90)',

                    color: '#eee',

                    border:
                        '1px solid rgba(210,120,255,.45)',

                    borderRadius: '9px',

                    font:
                        '12px/1.45 sans-serif',

                    boxShadow:
                        '0 2px 12px rgba(0,0,0,.35)',

                    backdropFilter:
                        'blur(5px)'
                }
            );

            document.body.appendChild(status);
        }

        function eventLabel(reason) {
            return (
                I18N[lang][reason]
                ||
                reason
                ||
                I18N[lang].none
            );
        }

        function render() {

            const t = I18N[lang];

            let eventText = t.none;
            let detail = '';

            if (lastState.type === 'pulse') {

                eventText =
                    t.pulse;

            } else if (
                lastState.type === 'punish'
            ) {

                eventText =
                    eventLabel(
                        lastState.reason
                    );

                detail =
                    `${t.punishment}: ` +
                    `${lastState.seconds} ${t.sec}`;

            } else if (
                lastState.type === 'status'
            ) {

                eventText =
                    eventLabel(
                        lastState.key
                    );
            }

            status.innerHTML = `
                <div style="
                    display:flex;
                    align-items:center;
                    justify-content:space-between;
                    gap:12px;
                ">
                    <strong style="
                        color:#e69cff;
                    ">
                        YUA × XToys
                    </strong>

                    <select
                        id="yua-xtoys-lang"
                        style="
                            font:11px sans-serif;
                            background:#211526;
                            color:#eee;
                            border:1px solid #704080;
                            border-radius:5px;
                            padding:2px 4px;
                        "
                    >
                        <option
                            value="zh-CN"
                            ${lang === 'zh-CN'
                                ? 'selected'
                                : ''}
                        >
                            中文
                        </option>

                        <option
                            value="ja"
                            ${lang === 'ja'
                                ? 'selected'
                                : ''}
                        >
                            日本語
                        </option>

                        <option
                            value="en"
                            ${lang === 'en'
                                ? 'selected'
                                : ''}
                        >
                            English
                        </option>
                    </select>
                </div>

                <div style="
                    border-top:
                        1px solid rgba(255,255,255,.12);
                    margin:6px 0;
                "></div>

                <div>
                    ● ${t.bridgeReady}
                </div>

                <div>
                    ${
                        gameReady
                        ? '● ' + t.gameReady
                        : '○ ' + t.waitingGame
                    }
                </div>

                <div style="
                    margin-top:7px;
                    opacity:.7;
                ">
                    ${t.lastEvent}
                </div>

                <div>
                    ${eventText}
                </div>

                ${
                    detail
                    ? `
                        <div style="
                            color:#e69cff;
                        ">
                            ${detail}
                        </div>
                    `
                    : ''
                }

                <div style="
                    text-align:right;
                    opacity:.45;
                    margin-top:4px;
                ">
                    v${VERSION}
                </div>
            `;

            const selector =
                status.querySelector(
                    '#yua-xtoys-lang'
                );

            selector.addEventListener(
                'change',

                e => {

                    lang =
                        e.target.value;

                    localStorage.setItem(
                        LANG_KEY,
                        lang
                    );

                    render();
                }
            );
        }

        render();

        /* -----------------------------------------------------
           Game → outer page receiver
           ----------------------------------------------------- */

        window.addEventListener(
            'message',

            e => {

                if (
                    e.origin !== GAME_ORIGIN
                ) {
                    return;
                }

                const d =
                    e.data;

                if (
                    !d
                    ||
                    d.source !== 'YUA_XTOYS'
                ) {
                    return;
                }

                /* ---------- READY ---------- */

                if (
                    d.type === 'ready'
                ) {

                    gameReady = true;

                    render();

                    log(
                        'Game Detector connected',
                        d.version
                    );

                    return;
                }

                /* ---------- STATUS ---------- */

                if (
                    d.type === 'status'
                ) {

                    lastState = {
                        type: 'status',
                        key: d.key
                    };

                    render();

                    return;
                }

                /* ---------- PULSE ---------- */

                if (
                    d.type === 'pulse'
                ) {

                    bridge.textContent =
                        `pulse:${d.seq}`;

                    gameReady = true;

                    lastState = {
                        type: 'pulse',
                        reason: d.reason
                    };

                    render();

                    log(
                        'PULSE ← GAME',
                        d.reason || '',
                        d.seq
                    );

                    return;
                }

                /* ---------- PUNISH ---------- */

                if (
                    d.type === 'punish'
                ) {

                    const seconds =
                        Number(
                            d.seconds
                        );

                    if (
                        !Number.isFinite(seconds)
                        ||
                        seconds <= 0
                    ) {
                        return;
                    }

                    /*
                     * Keep protocol reasons language-independent.
                     */

                    const reason =
                        String(
                            d.reason || 'unknown'
                        )
                            .replace(
                                /[^a-z0-9_]/gi,
                                '_'
                            )
                            .toLowerCase();

                    bridge.textContent =
                        `punish:${seconds}:${reason}:${d.seq}`;

                    gameReady = true;

                    lastState = {
                        type: 'punish',
                        seconds,
                        reason
                    };

                    render();

                    log(
                        'PUNISH ← GAME',
                        `${seconds}s`,
                        reason,
                        d.seq
                    );
                }
            }
        );

        log(
            'Outer Bridge READY'
        );

        return;
    }

    /* =========================================================
       GAME IFRAME
       ========================================================= */

    if (
        location.origin !== GAME_ORIGIN
    ) {
        return;
    }

    /* =========================================================
       Sequence
       ========================================================= */

    let seq =
        Date.now();

    function nextSeq() {

        const now =
            Date.now();

        seq =
            Math.max(
                seq + 1,
                now
            );

        return seq;
    }

    /* =========================================================
       Messaging
       ========================================================= */

    function sendMessage(data) {

        parent.postMessage(
            {
                source: 'YUA_XTOYS',
                ...data
            },

            OUTER_ORIGIN
        );
    }

    function sendStatus(key) {

        sendMessage({
            type: 'status',
            key
        });
    }

    /* =========================================================
       XToys output
       ========================================================= */

    function pulse(
        reason = 'pulse'
    ) {

        const id =
            nextSeq();

        sendMessage({
            type: 'pulse',
            reason,
            seq: id
        });

        log(
            'PULSE → XToys',
            reason,
            id
        );
    }

    function punish(
        seconds,
        reason = 'unknown'
    ) {

        seconds =
            Number(seconds);

        if (
            !Number.isFinite(seconds)
            ||
            seconds <= 0
        ) {
            return;
        }

        const id =
            nextSeq();

        sendMessage({
            type: 'punish',
            seconds,
            reason,
            seq: id
        });

        log(
            'PUNISH → XToys',
            `${seconds}s`,
            reason,
            id
        );
    }

    /*
     * Debug interface.
     */

    window.YUA_XTOYS = {
        pulse,
        punish,
        version: VERSION
    };

    /* =========================================================
       Game state
       ========================================================= */

    function getState() {

        try {

            return JSON.parse(
                localStorage.getItem(
                    STORAGE_KEY
                )
                ||
                '{}'
            );

        } catch {

            return {};
        }
    }

    const initialState =
        getState();

    /*
     * Remember the event that already existed
     * when the script started.
     *
     * This prevents a refresh from replaying
     * the previous punishment.
     */

    let lastEventId =
        initialState.events
            ?.at(-1)
            ?.id
        ||
        null;

    /* =========================================================
       OTHELLO / MAHJONG
       ========================================================= */

    function handleGameEvent(event) {

        if (!event) {
            return;
        }

        log(
            'GAME EVENT',
            event.type,
            event
        );

        /* -----------------------------------------------------
           OTHELLO WIN
           ----------------------------------------------------- */

        if (
            event.type === 'othello.win'
        ) {

            log(
                'Othello WIN → no punishment'
            );

            sendStatus(
                'othello_win'
            );

            return;
        }

        /* -----------------------------------------------------
           OTHELLO LOSE
           ----------------------------------------------------- */

        if (
            event.type === 'othello.lose'
        ) {

            const lost =
                Number(
                    event.detail
                        ?.losingSquares
                    ||
                    0
                );

            if (
                lost > 0
            ) {

                log(
                    `Othello LOSE → ${lost}s`,
                    event.detail
                );

                punish(
                    lost,
                    'othello_loss'
                );
            }

            return;
        }

        /* -----------------------------------------------------
           MAHJONG WIN
           ----------------------------------------------------- */

        if (
            event.type === 'mahjong.win'
        ) {

            log(
                'Mahjong WIN → no punishment'
            );

            sendStatus(
                'mahjong_win'
            );

            return;
        }

        /* -----------------------------------------------------
           MAHJONG LOSE
           ----------------------------------------------------- */

        if (
            event.type === 'mahjong.lose'
        ) {

            const points =
                Number(
                    event.detail
                        ?.points
                    ||
                    0
                );

            /*
             * 1000 points = 1 second
             * Minimum 1 second
             * Maximum 9.6 seconds
             */

            const seconds =
                Math.min(
                    9.6,

                    Math.max(
                        1,
                        points / 1000
                    )
                );

            log(
                `Mahjong LOSE ${points}pt → ${seconds}s`,
                event.detail
            );

            punish(
                seconds,
                'mahjong_loss'
            );

            return;
        }
    }

    /* ---------------------------------------------------------
       Match event watcher
       --------------------------------------------------------- */

    setInterval(
        () => {

            const state =
                getState();

            const latest =
                state.events
                    ?.at(-1);

            if (!latest) {
                return;
            }

            const id =
                latest.id
                ||
                JSON.stringify(
                    latest
                );

            if (
                id === lastEventId
            ) {
                return;
            }

            lastEventId =
                id;

            handleGameEvent(
                latest
            );
        },

        200
    );

    /* =========================================================
       RAPID TAP
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            const button =
                e.target.closest?.(
                    '#tapTemptationBtn'
                );

            if (!button) {
                return;
            }

            pulse(
                'rapid_tap'
            );
        },

        true
    );

    /* =========================================================
       TONGUE JUDGEMENT
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            /*
             * Follow YUA
             */

            const follow =
                e.target.closest?.(
                    '#tongueJudgementFollowBtn'
                );

            if (follow) {

                log(
                    'Tongue → Follow YUA → no punishment'
                );

                sendStatus(
                    'tongue_follow'
                );

                return;
            }

            /*
             * Choose self
             */

            const refuse =
                e.target.closest?.(
                    '#tongueJudgementRefuseBtn'
                );

            if (refuse) {

                log(
                    'Tongue → Self choice → 1s'
                );

                punish(
                    1,
                    'tongue_self'
                );

                return;
            }
        },

        true
    );

    /* =========================================================
       NORMAL TEMPTATION

       Verified final method:
       capture-phase pointerdown

       Legal cell:
       .cell.legal

       YUA target:
       .cell.legal.tempted
       ========================================================= */

    document.addEventListener(
        'pointerdown',

        e => {

            /*
             * These events are handled separately.
             */

            if (
                e.target.closest?.(
                    '#tongueJudgementFollowBtn'
                )
                ||
                e.target.closest?.(
                    '#tongueJudgementRefuseBtn'
                )
                ||
                e.target.closest?.(
                    '#tapTemptationBtn'
                )
            ) {
                return;
            }

            /*
             * Find the clicked board cell from
             * the actual event path.
             */

            const path =
                e.composedPath?.()
                ||
                [];

            const clicked =
                path.find(
                    el =>
                        el
                            ?.classList
                            ?.contains?.(
                                'cell'
                            )
                );

            if (!clicked) {
                return;
            }

            /*
             * Only legal moves matter.
             */

            if (
                !clicked
                    .classList
                    .contains(
                        'legal'
                    )
            ) {
                return;
            }

            /*
             * Read YUA's target at the exact
             * pointerdown moment.
             */

            const tempted =
                document.querySelector(
                    '.cell.legal.tempted'
                );

            /*
             * No temptation event active.
             */

            if (!tempted) {
                return;
            }

            const clickedIndex =
                clicked.dataset.index;

            const temptedIndex =
                tempted.dataset.index;

            if (
                clickedIndex == null
                ||
                temptedIndex == null
            ) {
                return;
            }

            log(
                'Temptation move',
                {
                    clickedIndex,
                    temptedIndex
                }
            );

            /* -------------------------------------------------
               Follow YUA
               ------------------------------------------------- */

            if (
                clickedIndex ===
                temptedIndex
            ) {

                log(
                    'Temptation → Follow YUA → no punishment'
                );

                sendStatus(
                    'temptation_follow'
                );

                return;
            }

            /* -------------------------------------------------
               Refuse YUA
               ------------------------------------------------- */

            log(
                'Temptation → Refused YUA → 1s'
            );

            punish(
                1,
                'temptation'
            );
        },

        true
    );

    /* =========================================================
       CHEAT CHALLENGE
       ========================================================= */

    let previousCheatFalse =
        false;

    function detectCheatResult(
        text
    ) {

        const falseCheat =
            text.includes(
                '误判惩罚:'
            )
            ||
            text.includes(
                '猜错♡'
            );

        /*
         * Trigger only on false → true.
         */

        if (
            falseCheat
            &&
            !previousCheatFalse
        ) {

            log(
                'False Cheat accusation → 1s'
            );

            punish(
                1,
                'false_cheat'
            );
        }

        previousCheatFalse =
            falseCheat;
    }

    let cheatScanScheduled =
        false;

    function scanCheatDOM() {

        cheatScanScheduled =
            false;

        const text =
            document.body
                ?.innerText
            ||
            '';

        detectCheatResult(
            text
        );
    }

    const cheatObserver =
        new MutationObserver(
            () => {

                if (
                    cheatScanScheduled
                ) {
                    return;
                }

                cheatScanScheduled =
                    true;

                requestAnimationFrame(
                    scanCheatDOM
                );
            }
        );

    cheatObserver.observe(
        document.body,
        {
            subtree: true,
            childList: true,
            characterData: true
        }
    );

    /* =========================================================
       READY
       ========================================================= */

    log(
        'Game Detector READY'
    );

    log(
        'Enabled:',
        'Othello / Mahjong / Temptation / Cheat / Rapid Tap / Tongue'
    );

    sendMessage({
        type: 'ready',
        version: VERSION
    });

})();