Greasy Fork

Greasy Fork is available in English.

MWI-Hit-Tracker

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

当前为 2025-05-08 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

})();