MWI-Hit-Tracker

战斗过程中实时显示攻击命中目标

目前为 2025-05-08 提交的版本。查看 最新版本

// ==UserScript==
// @name         MWI-Hit-Tracker
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  战斗过程中实时显示攻击命中目标
// @author       Artintel
// @license MIT
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @icon         https://www.milkywayidle.com/favicon.svg
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const isZHInGameSetting = localStorage.getItem("i18nextLng")?.toLowerCase()?.startsWith("zh"); // 获取游戏内设置语言
    let isZH = isZHInGameSetting; // MWITools 本身显示的语言默认由游戏内设置语言决定

    /*
    const lineColor = [
        "rgba(255, 99, 132, 1)", // 浅粉色
        "rgba(54, 162, 235, 1)", // 浅蓝色
        "rgba(255, 206, 86, 1)", // 浅黄色
        "rgba(75, 192, 192, 1)", // 浅绿色
        "rgba(153, 102, 255, 1)", // 浅紫色
        "rgba(255, 159, 64, 1)", // 浅橙色
        "rgba(255, 0, 0, 1)", // 敌人攻击颜色
    ];
    const filterColor = [
        "rgba(255, 99, 132, 0.8)", // 浅粉色
        "rgba(54, 162, 235, 0.8)", // 浅蓝色
        "rgba(255, 206, 86, 0.8)", // 浅黄色
        "rgba(75, 192, 192, 0.8)", // 浅绿色
        "rgba(153, 102, 255, 0.8)", // 浅紫色
        "rgba(255, 159, 64, 0.8)", // 浅橙色
        "rgba(255, 0, 0, 0.8)", // 敌人攻击颜色
    ];
    */
    let settingsMap = {
        tracker0 : {
            id: "tracker0",
            desc: isZH ? "玩家 #1":"player #1",
            isTrue: true,
            r: 255,
            g: 99,
            b: 132,
        },
        tracker1 : {
            id: "tracker1",
            desc: isZH ? "玩家 #2":"player #2",
            isTrue: true,
            r: 54,
            g: 162,
            b: 235,
        },
        tracker2 : {
            id: "tracker2",
            desc: isZH ? "玩家 #3":"player #3",
            isTrue: true,
            r: 255,
            g: 206,
            b: 86,
        },
        tracker3 : {
            id: "tracker3",
            desc: isZH ? "玩家 #4":"player #4",
            isTrue: true,
            r: 75,
            g: 192,
            b: 192,
        },
        tracker4 : {
            id: "tracker4",
            desc: isZH ? "玩家 #5":"player #5",
            isTrue: true,
            r: 153,
            g: 102,
            b: 255,
        },
        tracker6 : {
            id: "tracker6",
            desc: isZH ? "敌人":"enemies",
            isTrue: true,
            r: 255,
            g: 0,
            b: 0,
        }
    };
    readSettings();

    /* 脚本设置面板 */
    const waitForSetttins = () => {
        const targetNode = document.querySelector("div.SettingsPanel_profileTab__214Bj");
        if (targetNode) {
            if (!targetNode.querySelector("#tracker_settings")) {
                targetNode.insertAdjacentHTML("beforeend", `<div id="tracker_settings"></div>`);
                const insertElem = targetNode.querySelector("div#tracker_settings");
                insertElem.insertAdjacentHTML(
                    "beforeend",
                    `<div style="float: left; color: orange">${
                        isZH ? "MWI-Hit-Tracker 设置 :" : "MWI-Hit-Tracker Settings: "
                    }</div></br>`
                );

                for (const setting of Object.values(settingsMap)) {
                    insertElem.insertAdjacentHTML(
                        "beforeend",
                        `<div class="tracker-option"><input type="checkbox" id="${setting.id}" ${setting.isTrue ? "checked" : ""}></input>${
                            setting.desc
                        }<div class="color-preview" id="colorPreview_${setting.id}"></div></div>`
                    );
                    // 颜色自定义
                    const colorPreview = document.getElementById('colorPreview_'+setting.id);
                    let currentColor = { r: setting.r, g: setting.g, b: setting.b };

                    // 点击打开颜色选择器
                    colorPreview.addEventListener('click', () => {
                        const settingColor = { r: settingsMap[setting.id].r, g: settingsMap[setting.id].g, b: settingsMap[setting.id].b }
                        const modal = createColorPicker(settingColor, (newColor) => {
                            currentColor = newColor;
                            settingsMap[setting.id].r = newColor.r;
                            settingsMap[setting.id].g = newColor.g;
                            settingsMap[setting.id].b = newColor.b;
                            localStorage.setItem("tracker_settingsMap", JSON.stringify(settingsMap));
                            updatePreview();
                        });
                        document.body.appendChild(modal);
                    });

                    function updatePreview() {
                        colorPreview.style.backgroundColor = `rgb(${currentColor.r},${currentColor.g},${currentColor.b})`;
                    }

                    updatePreview();
                    function createColorPicker(initialColor, callback) {
                        // 创建弹窗容器
                        const backdrop = document.createElement('div');
                        backdrop.className = 'modal-backdrop';

                        const modal = document.createElement('div');
                        modal.className = 'color-picker-modal';

                        // 颜色预览
                        //const preview = document.createElement('div');
                        //preview.className = 'color-preview';
                        //preview.style.height = '100px';
                        // 创建SVG容器
                        const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                        preview.setAttribute("width", "200");
                        preview.setAttribute("height", "150");
                        preview.style.display = 'block';
                        // 创建抛物线路径
                        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
                        Object.assign(path.style, {
                            strokeWidth: '5px',
                            fill: 'none',
                            strokeLinecap: 'round',
                        });
                        path.setAttribute("d", "M 0 130 Q 100 0 200 130");
                        preview.appendChild(path);

                        // 颜色控制组件
                        const controls = document.createElement('div');
                        ['r', 'g', 'b'].forEach(channel => {
                            const container = document.createElement('div');
                            container.className = 'slider-container';

                            // 标签
                            const label = document.createElement('label');
                            label.textContent = channel.toUpperCase() + ':';
                            label.style.color = "white";

                            // 滑块
                            const slider = document.createElement('input');
                            slider.type = 'range';
                            slider.min = 0;
                            slider.max = 255;
                            slider.value = initialColor[channel];

                            // 输入框
                            const input = document.createElement('input');
                            input.type = 'number';
                            input.min = 0;
                            input.max = 255;
                            input.value = initialColor[channel];
                            input.style.width = '60px';

                            // 双向绑定
                            const updateChannel = (value) => {
                                value = Math.min(255, Math.max(0, parseInt(value) || 0));
                                slider.value = value;
                                input.value = value;
                                currentColor[channel] = value;
                                path.style.stroke = getColorString(currentColor);
                            };

                            slider.addEventListener('input', (e) => updateChannel(e.target.value));
                            input.addEventListener('change', (e) => updateChannel(e.target.value));

                            container.append(label, slider, input);
                            controls.append(container);
                        });

                        // 操作按钮
                        const actions = document.createElement('div');
                        actions.className = 'modal-actions';

                        const confirmBtn = document.createElement('button');
                        confirmBtn.textContent = isZH ? '确定':'OK';
                        confirmBtn.onclick = () => {
                            callback(currentColor);
                            backdrop.remove();
                        };

                        const cancelBtn = document.createElement('button');
                        cancelBtn.textContent = isZH ? '取消':'Cancel';
                        cancelBtn.onclick = () => backdrop.remove();

                        actions.append(cancelBtn, confirmBtn);

                        // 组装弹窗
                        const getColorString = (color) =>
                        `rgb(${color.r},${color.g},${color.b})`;

                        path.style.stroke = getColorString(settingsMap[setting.id]);
                        modal.append(preview, controls, actions);
                        backdrop.append(modal);

                        // 点击背景关闭
                        backdrop.addEventListener('click', (e) => {
                            if (e.target === backdrop) backdrop.remove();
                        });

                        return backdrop;
                    }
                }

                insertElem.addEventListener("change", saveSettings);
            }
        }
        setTimeout(waitForSetttins, 500);
    };
    waitForSetttins();

    function saveSettings() {
        for (const checkbox of document.querySelectorAll("div#tracker_settings input")) {
            settingsMap[checkbox.id].isTrue = checkbox.checked;
            localStorage.setItem("tracker_settingsMap", JSON.stringify(settingsMap));
        }
    }

    function readSettings() {
        const ls = localStorage.getItem("tracker_settingsMap");
        if (ls) {
            const lsObj = JSON.parse(ls);
            for (const option of Object.values(lsObj)) {
                if (settingsMap.hasOwnProperty(option.id)) {
                    settingsMap[option.id].isTrue = option.isTrue;
                    settingsMap[option.id].r = option.r;
                    settingsMap[option.id].g = option.g;
                    settingsMap[option.id].b = option.b;
                }
            }
        }
    }

    let monstersHP = [];
    let monstersMP = [];
    let playersHP = [];
    let playersMP = [];
    hookWS();

    function hookWS() {
        const dataProperty = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data");
        const oriGet = dataProperty.get;

        dataProperty.get = hookedGet;
        Object.defineProperty(MessageEvent.prototype, "data", dataProperty);

        function hookedGet() {
            const socket = this.currentTarget;
            if (!(socket instanceof WebSocket)) {
                return oriGet.call(this);
            }
            if (socket.url.indexOf("api.milkywayidle.com/ws") <= -1 && socket.url.indexOf("api-test.milkywayidle.com/ws") <= -1) {
                return oriGet.call(this);
            }

            const message = oriGet.call(this);
            Object.defineProperty(this, "data", { value: message }); // Anti-loop

            return handleMessage(message);
        }
    }

    // 动画效果
    const AnimationManager = {
        maxPaths: 50, // 最大同时存在path数
        activePaths: new Set(), // 当前活动路径集合

        canCreate() {
            // 数量检查
            return this.activePaths.size < this.maxPaths;
        },

        addPath(path) {
            this.activePaths.add(path);
        },

        removePath(path) {
            this.activePaths.delete(path);
        }
    };

    function getElementCenter(element) {
        const rect = element.getBoundingClientRect();
        if (element.innerText.trim() === '') {
            return {
                x: rect.left + rect.width/2,
                y: rect.top
            };
        }
        return {
            x: rect.left + rect.width/2,
            y: rect.top + rect.height/2
        };
    }

    function createParabolaPath(startElem, endElem, reversed = false) {
        const start = getElementCenter(startElem);
        const end = getElementCenter(endElem);

        // 弧度调整位置(修改这个数值控制弧度)
        //const curveHeight = -120; // 数值越大弧度越高(负值向上弯曲)
        const curveRatio = reversed ? 4:2.5;
        const curveHeight = -Math.abs(start.x - end.x)/curveRatio;

        const controlPoint = {
            x: (start.x + end.x) / 2,
            y: Math.min(start.y, end.y) + curveHeight // 调整这里
        };

        if (reversed) {return `M ${end.x} ${end.y} Q ${controlPoint.x} ${controlPoint.y}, ${start.x} ${start.y}`;}
        return `M ${start.x} ${start.y} Q ${controlPoint.x} ${controlPoint.y}, ${end.x} ${end.y}`;
    }

    function createEffect(startElem, endElem, hpDiff, index, reversed = false) {
        let strokeWidth = '1px';
        let filterWidth = '1px';
        if (hpDiff >= 1000){
            strokeWidth = '5px';
            filterWidth = '6px';
        } else if (hpDiff >= 700) {
            strokeWidth = '4px';
            filterWidth = '5px';
        } else if (hpDiff >= 500) {
            strokeWidth = '3px';
            filterWidth = '4px';
        } else if (hpDiff >= 300) {
            strokeWidth = '2px';
            filterWidth = '3px';
        } else if (hpDiff >= 100) {
            filterWidth = '2px';
        }
        // 尝试定位伤害数字div
        if (reversed) {
            const dmgDivs = startElem.querySelector('.CombatUnit_splatsContainer__2xcc0').querySelectorAll('div'); // 获取所有 div
            for (const div of dmgDivs) {
                if (div.innerText.trim() === '') {
                    startElem = div;
                    break;
                }
            }
        } else {
            const dmgDivs = endElem.querySelector('.CombatUnit_splatsContainer__2xcc0').querySelectorAll('div'); // 获取所有 div
            for (const div of dmgDivs) {
                if (div.innerText.trim() === '') {
                    endElem = div;
                    break;
                }
            }
        }

        const svg = document.getElementById('svg-container');
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");

        if (reversed) {index = 6;}
        const trackerSetting = settingsMap["tracker"+index];
        const lineColor = "rgba("+trackerSetting.r+", "+trackerSetting.g+", "+trackerSetting.b+", 1)";
        const filterColor = "rgba("+trackerSetting.r+", "+trackerSetting.g+", "+trackerSetting.b+", 0.8)";
        Object.assign(path.style, {
            stroke: lineColor,
            strokeWidth: strokeWidth,
            fill: 'none',
            strokeLinecap: 'round',
            filter: 'drop-shadow(0 0 '+filterWidth+' '+filterColor+')'
        });
        path.setAttribute('d', createParabolaPath(startElem, endElem, reversed));
        // 入场动画
        const length = path.getTotalLength();
        path.style.strokeDasharray = length;
        path.style.strokeDashoffset = length;

        svg.appendChild(path);
        // 注册到管理器
        AnimationManager.addPath(path);
        // 移除逻辑
        const cleanUp = () => {
            try {
                if (path.parentNode) {
                    svg.removeChild(path);
                }
                AnimationManager.removePath(path);
            } catch(e) {
                console.error('Svg path cleanup error:', e);
            }
        };
        // 绘制动画
        requestAnimationFrame(() => {
            path.style.transition = 'stroke-dashoffset 0.1s linear';
            path.style.strokeDashoffset = '0';
        });
        // 自动移除
        setTimeout(() => {
            // 1. 先重置transition
            path.style.transition = 'none';

            // 2. 重新设置dasharray实现反向动画
            requestAnimationFrame(() => {
                // 保持当前可见状态
                path.style.strokeDasharray = length;
                path.style.strokeDashoffset = '0';

                // 3. 开始消失动画
                path.style.transition = 'stroke-dashoffset 0.3s cubic-bezier(0.4, 0, 1, 1)';
                path.style.strokeDashoffset = -length;

                // 4. 动画结束后移除
                const removeElement = () => {
                    //svg.removeChild(path);
                    cleanUp();
                    path.removeEventListener('transitionend', removeElement);
                };
                path.addEventListener('transitionend', removeElement);
            });
        }, 600);
        // 强制清理保护
        const forceCleanupTimer = setTimeout(cleanUp, 5000); // 5秒后强制移除
        path.addEventListener('transitionend', () => clearTimeout(forceCleanupTimer));
        // 自动移除
        //setTimeout(() => {
        //    path.style.opacity = '0';
        //    path.style.transition = 'opacity 0.1s linear';
        //    setTimeout(() => svg.removeChild(path), 500);
        //}, 800);
    }

    // 添加窗口resize监听
    let isResizeListenerAdded = false;
    function createLine(from, to, hpDiff, reversed = false) {
        if (reversed){
            if (!settingsMap.tracker6.isTrue) {
                return null;
            }
        } else {
            if (!settingsMap["tracker"+from].isTrue) {
                return null;
            }
        }
        if (!AnimationManager.canCreate()) {
            return null; // 同时存在数量超出上限
        }
        const container = document.querySelector(".BattlePanel_playersArea__vvwlB");
        if (container && container.children.length > 0) {
            const playersContainer = container.children[0];
            const effectFrom = playersContainer.children[from];
            const monsterContainer = document.querySelector(".BattlePanel_monstersArea__2dzrY").children[0];
            const effectTo = monsterContainer.children[to];
            const svg = document.getElementById('svg-container');
            if(!svg){
                const svgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svgContainer.id = 'svg-container';
                Object.assign(svgContainer.style, {
                    position: 'fixed',
                    top: '0',
                    left: '0',
                    width: '100%',
                    height: '100%',
                    pointerEvents: 'none',
                    overflow: 'visible',
                    zIndex: '190'
                });

                // 设置SVG原生属性
                svgContainer.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`);
                svgContainer.setAttribute('preserveAspectRatio', 'none');
                // 初始化viewBox
                const updateViewBox = () => {
                    svgContainer.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`);
                };
                updateViewBox();
                //playersContainer.appendChild(svgContainer);
                document.querySelector(".GamePage_mainPanel__2njyb").appendChild(svgContainer);
                //document.body.appendChild(svgContainer);
                // 添加resize监听(确保只添加一次)
                if (!isResizeListenerAdded) {
                    window.addEventListener('resize', () => {
                        updateViewBox();
                    });
                    isResizeListenerAdded = true;
                }
            }

            if (reversed) {
                createEffect(effectFrom, effectTo, hpDiff, to, reversed);
            } else {
                createEffect(effectFrom, effectTo, hpDiff, from, reversed);
            }
        }

    }

    function handleMessage(message) {
        let obj = JSON.parse(message);
        if (obj && obj.type === "new_battle") {
            monstersHP = obj.monsters.map((monster) => monster.currentHitpoints);
            monstersMP = obj.monsters.map((monster) => monster.currentManapoints);
            playersHP = obj.players.map((player) => player.currentHitpoints);
            playersMP = obj.players.map((player) => player.currentManapoints);
        } else if (obj && obj.type === "battle_updated" && monstersHP.length) {
            const mMap = obj.mMap;
            const pMap = obj.pMap;
            const monsterIndices = Object.keys(obj.mMap);
            const playerIndices = Object.keys(obj.pMap);

            let castMonster = -1;
            monsterIndices.forEach((monsterIndex) => {
                if(mMap[monsterIndex].cMP < monstersMP[monsterIndex]){castMonster = monsterIndex;}
                monstersMP[monsterIndex] = mMap[monsterIndex].cMP;
            });
            let castPlayer = -1;
            playerIndices.forEach((userIndex) => {
                if(pMap[userIndex].cMP < playersMP[userIndex]){castPlayer = userIndex;}
                playersMP[userIndex] = pMap[userIndex].cMP;
            });

            monstersHP.forEach((mHP, mIndex) => {
                const monster = mMap[mIndex];
                if (monster) {
                    const hpDiff = mHP - monster.cHP;
                    monstersHP[mIndex] = monster.cHP;
                    if (hpDiff > 0 && playerIndices.length > 0) {
                        if (playerIndices.length > 1) {
                            playerIndices.forEach((userIndex) => {
                                if(userIndex === castPlayer) {
                                    createLine(userIndex, mIndex, hpDiff);
                                }
                            });
                        } else {
                            createLine(playerIndices[0], mIndex, hpDiff);
                        }
                    }
                }
            });

            playersHP.forEach((pHP, pIndex) => {
                const player = pMap[pIndex];
                if (player) {
                    const hpDiff = pHP - player.cHP;
                    playersHP[pIndex] = player.cHP;
                    if (hpDiff > 0 && monsterIndices.length > 0) {
                        if (monsterIndices.length > 1) {
                            monsterIndices.forEach((monsterIndex) => {
                                if(monsterIndex === castMonster) {
                                    createLine(pIndex, monsterIndex, hpDiff, true);
                                }
                            });
                        } else {
                            createLine(pIndex, monsterIndices[0], hpDiff, true);
                        }
                    }
                }
            });

        }
        return message;
    }

    const style = document.createElement('style');
    style.textContent = `
        .tracker-option {
          display: flex;
          align-items: center;
        }

        .color-preview {
            cursor: pointer;
            width: 20px;
            height: 20px;
            margin: 3px 3px;
            border: 1px solid #ccc;
            border-radius: 3px;
        }

        .color-picker-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.5);
            padding: 20px;
            border: 1px solid rgba(255, 255, 255, 0.2);
            border-radius: 8px;
            box-shadow: 0 0 20px rgba(0,0,0,0.2);
            z-index: 1000;
        }

        .modal-backdrop {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0,0,0,0.5);
            z-index: 999;
        }

        .modal-actions {
            margin-top: 20px;
            display: flex;
            gap: 10px;
            justify-content: flex-end;
        }
    `;
    document.head.appendChild(style);

})();