MWI-Hit-Tracker-change

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

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

// ==UserScript==
// @name         MWI-Hit-Tracker-change
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  战斗过程中实时显示攻击命中目标
// @author       Artintel (Artintel), Yuk111
// @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';

    // 存储怪物当前的生命值
    let monstersHP = [];
    // 存储怪物当前的魔法值
    let monstersMP = [];
    // 存储玩家当前的生命值
    let playersHP = [];
    // 存储玩家当前的魔法值
    let playersMP = [];
    // 调用 hookWS 函数,用于劫持 WebSocket 消息
    hookWS();

    // 创建 lineFlash 动画样式,用于路径闪烁效果
    const style = document.createElement('style');
    style.textContent = `
        @keyframes lineFlash {
            0% {
                stroke-opacity: 1; // 起始时路径的透明度为 1
            }
            50% {
                stroke-opacity: 0.3; // 中间时路径的透明度为 0.3
            }
            100% {
                stroke-opacity: 1; // 结束时路径的透明度恢复为 1
            }
        }
    `;
    document.head.appendChild(style);

    // 劫持 WebSocket 消息的函数,用于拦截和处理战斗相关的消息
    function hookWS() {
        // 获取 MessageEvent 原型上的 data 属性描述符
        const dataProperty = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data");
        // 保存原始的 data 属性的 getter 函数
        const oriGet = dataProperty.get;

        // 将 data 属性的 getter 函数替换为自定义的 hookedGet 函数
        dataProperty.get = hookedGet;
        // 重新定义 MessageEvent 原型上的 data 属性
        Object.defineProperty(MessageEvent.prototype, "data", dataProperty);

        // 自定义的 data 属性 getter 函数
        function hookedGet() {
            // 获取当前的 WebSocket 对象
            const socket = this.currentTarget;
            // 如果当前对象不是 WebSocket 实例,使用原始的 getter 函数获取数据
            if (!(socket instanceof WebSocket)) {
                return oriGet.call(this);
            }
            // 如果 WebSocket 的 URL 不包含指定的 API 地址,使用原始的 getter 函数获取数据
            if (socket.url.indexOf("api.milkywayidle.com/ws") <= -1 && socket.url.indexOf("api-test.milkywayidle.com/ws") <= -1) {
                return oriGet.call(this);
            }

            // 使用原始的 getter 函数获取消息数据
            const message = oriGet.call(this);
            // 重新定义 data 属性,防止循环调用
            Object.defineProperty(this, "data", { value: message });

            // 调用 handleMessage 函数处理消息
            return handleMessage(message);
        }
    }

    // 计算元素中心点坐标的函数
    function getElementCenter(element) {
        // 获取元素的边界矩形信息
        const rect = element.getBoundingClientRect();
        // 如果元素内文本为空,将中心点的 y 坐标设置为元素顶部
        if (element.innerText.trim() === '') {
            return {
                x: rect.left + rect.width / 2,
                y: rect.top
            };
        }
        // 否则,将中心点的 y 坐标设置为元素垂直居中位置
        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 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}`;
    }

    // 定义线条颜色数组,用于不同角色的攻击线条颜色
    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)", // 敌人攻击颜色
    ];
    // 创建动画效果的函数
    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');
            for (const div of dmgDivs) {
                if (div.innerText.trim() === '') {
                    startElem = div;
                    break;
                }
            }
        } else {
            const dmgDivs = endElem.querySelector('.CombatUnit_splatsContainer__2xcc0').querySelectorAll('div');
            for (const div of dmgDivs) {
                if (div.innerText.trim() === '') {
                    endElem = div;
                    break;
                }
            }
        }

        // 获取 SVG 容器元素
        const svg = document.getElementById('svg-container');
        // 创建一个 SVG 路径元素
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");

        // 如果是反转的情况,使用敌人攻击颜色的索引
        if (reversed) {
            index = 6;
        }
        // 设置路径元素的样式
        Object.assign(path.style, {
            stroke: lineColor[index],
            strokeWidth: strokeWidth,
            fill: 'none',
            strokeLinecap: 'round',
            filter: 'drop-shadow(0 0 ' + filterWidth + ' ' + filterColor[index] + ')',
            animation: 'lineFlash 0.6s linear'
        });
        // 设置路径元素的 d 属性,即路径的形状
        path.setAttribute('d', createParabolaPath(startElem, endElem, reversed));
        // 计算路径的总长度
        const length = path.getTotalLength();
        // 设置路径的虚线样式,使其初始不可见
        path.style.strokeDasharray = length;
        path.style.strokeDashoffset = length;

        // 将路径元素添加到 SVG 容器中
        svg.appendChild(path);

        // 请求下一帧动画时执行以下操作
        requestAnimationFrame(() => {
            // 设置路径的过渡效果,使其在 0.1 秒内以线性方式显示
            path.style.transition = 'stroke-dashoffset 0.1s linear';
            // 使路径逐渐显示
            path.style.strokeDashoffset = '0';
        });
        // 0.6 秒后执行以下操作
        setTimeout(() => {
            // 先重置路径的过渡效果
            path.style.transition = 'none';

            // 请求下一帧动画时执行以下操作
            requestAnimationFrame(() => {
                // 保持路径当前的可见状态
                path.style.strokeDasharray = length;
                path.style.strokeDashoffset = '0';

                // 设置路径的过渡效果,使其在 0.3 秒内以 cubic-bezier 方式消失
                path.style.transition = 'stroke-dashoffset 0.3s cubic-bezier(0.4, 0, 1, 1)';
                // 使路径逐渐消失
                path.style.strokeDashoffset = -length;

                // 定义路径动画结束后移除元素的函数
                const removeElement = () => {
                    // 从 SVG 容器中移除路径元素
                    if (path.parentNode) {
                        path.parentNode.removeChild(path);
                    }
                    // 移除过渡结束事件的监听器
                    path.removeEventListener('transitionend', removeElement);
                };
                // 监听路径的过渡结束事件,触发移除元素的函数
                path.addEventListener('transitionend', removeElement);
            });
        }, 600);

        // 创建伤害数字元素
        const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
        // 设置伤害数字元素的文本内容为伤害值
        text.textContent = hpDiff;
        // 定义基础字号
        const baseFontSize = 20;
        // 根据伤害值计算字号的增量
        const fontSizeIncrement = Math.floor(hpDiff / 100);
        // 计算最终的字号
        const fontSize = baseFontSize + fontSizeIncrement;
        // 设置伤害数字元素的字号
        text.setAttribute('font-size', fontSize);
        // 设置伤害数字元素的填充颜色
        text.setAttribute('fill', lineColor[index]);
        // 初始时伤害数字元素完全透明
        text.style.opacity = 0.7;
        // 为伤害数字元素添加外发光特效
        text.style.filter = `drop-shadow(0 0 5px ${lineColor[index]})`;
        // 设置伤害数字元素的变换原点为中心
        text.style.transformOrigin = 'center';
        // 设置伤害数字元素的字体加粗
        text.style.fontWeight = 'bold';
        // 将伤害数字元素添加到 SVG 容器中
        svg.appendChild(text);

        // 定义伤害数字动画的总帧数
        const numFrames = 60;
        // 定义伤害数字动画的总时长为 0.8 秒
        const totalDuration = 800;
        // 计算每帧的时间间隔
        const frameDuration = totalDuration / numFrames;
        // 初始化当前帧数为 0
        let currentFrame = 0;
        // 获取路径的总长度
        const pathLength = path.getTotalLength();

        // 定义伤害数字动画函数
        const animateText = () => {
            // 检查当前帧是否小于总帧数
            if (currentFrame < numFrames) {
                // 根据当前帧计算在路径上的位置
                const point = path.getPointAtLength((currentFrame / numFrames) * pathLength);
                // 设置伤害数字元素的 x 坐标为路径上当前点的 x 坐标
                text.setAttribute('x', point.x);
                // 设置伤害数字元素的 y 坐标为路径上当前点的 y 坐标
                text.setAttribute('y', point.y);
                // 根据当前帧计算伤害数字元素的透明度,使其逐渐显示
                text.style.opacity = 0.7 + 0.3 * (currentFrame / numFrames);
                // 当前帧序号加 1
                currentFrame++;
                // 递归调用 animateText 函数,在指定的帧间隔时间后执行
                setTimeout(animateText, frameDuration);
            } else {
                // 当动画结束后,开始执行魔法击中的消失动画
                text.style.transition = 'all 0.3s ease-out';
                text.style.transform = 'scale(1.5)';
                text.style.opacity = 0;

                setTimeout(() => {
                    if (text.parentNode) {
                        text.parentNode.removeChild(text);
                    }
                    // 添加粒子特效
                    createParticleEffect(text.getAttribute('x'), text.getAttribute('y'), lineColor[index]);
                }, 300);
            }
        };
        // 调用动画函数开始执行伤害数字动画
        animateText();
    }

    // 创建粒子特效的函数
    function createParticleEffect(x, y, color) {
        // 获取 SVG 容器元素
        const svg = document.getElementById('svg-container');
        // 定义粒子的数量
        const numParticles = 25;
        // 循环创建粒子
        for (let i = 0; i < numParticles; i++) {
            // 创建一个 SVG 圆形元素作为粒子
            const particle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
            // 设置粒子的半径
            particle.setAttribute('r', '2');
            // 设置粒子的填充颜色
            particle.setAttribute('fill', color);
            // 初始时粒子完全不透明
            particle.style.opacity = 1;
            // 设置粒子的变换原点为中心
            particle.style.transformOrigin = 'center';

            // 计算粒子的角度
            const angle = (i / numParticles) * 2 * Math.PI;
            // 随机生成粒子的移动距离
            const distance = Math.random() * 30 + 10;
            // 计算粒子的结束位置的 x 坐标
            const endX = parseFloat(x) + distance * Math.cos(angle);
            // 计算粒子的结束位置的 y 坐标
            const endY = parseFloat(y) + distance * Math.sin(angle);

            // 设置粒子的初始 x 坐标
            particle.setAttribute('cx', x);
            // 设置粒子的初始 y 坐标
            particle.setAttribute('cy', y);
            // 将粒子添加到 SVG 容器中
            svg.appendChild(particle);

            // 请求下一帧动画时执行以下操作
            requestAnimationFrame(() => {
                // 设置粒子的过渡效果,使其在 0.3 秒内以 ease-out 方式移动和消失
                particle.style.transition = 'all 0.3s ease-out';
                // 设置粒子的结束位置的 x 坐标
                particle.setAttribute('cx', endX);
                // 设置粒子的结束位置的 y 坐标
                particle.setAttribute('cy', endY);
                // 使粒子逐渐消失
                particle.style.opacity = 0;

                // 定义粒子动画结束后移除元素的函数
                const removeParticle = () => {
                    // 从 SVG 容器中移除粒子元素
                    if (particle.parentNode) {
                        particle.parentNode.removeChild(particle);
                    }
                    // 移除过渡结束事件的监听器
                    particle.removeEventListener('transitionend', removeParticle);
                };
                // 监听粒子的过渡结束事件,触发移除元素的函数
                particle.addEventListener('transitionend', removeParticle);
            });
        }
    }

    // 标记是否已经添加了窗口大小改变的监听器
    let isResizeListenerAdded = false;
    // 创建线条动画的函数
    function createLine(from, to, hpDiff, reversed = false) {
        // 获取玩家区域的容器元素
        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];
            // 获取 SVG 容器元素
            const svg = document.getElementById('svg-container');
            // 如果 SVG 容器元素不存在
            if (!svg) {
                // 创建一个 SVG 容器元素
                const svgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                // 设置 SVG 容器元素的 ID
                svgContainer.id = 'svg-container';
                // 设置 SVG 容器元素的样式
                Object.assign(svgContainer.style, {
                    position: 'fixed',
                    top: '0',
                    left: '0',
                    width: '100%',
                    height: '100%',
                    pointerEvents: 'none',
                    overflow: 'visible',
                    zIndex: '190'
                });

                // 设置 SVG 容器元素的 viewBox 属性
                svgContainer.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`);
                // 设置 SVG 容器元素的 preserveAspectRatio 属性
                svgContainer.setAttribute('preserveAspectRatio', 'none');
                // 定义更新 viewBox 的函数
                const updateViewBox = () => {
                    svgContainer.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`);
                };
                // 初始化 viewBox
                updateViewBox();
                // 将 SVG 容器元素添加到游戏主面板中
                document.querySelector(".GamePage_mainPanel__2njyb").appendChild(svgContainer);
                // 如果还没有添加窗口大小改变的监听器
                if (!isResizeListenerAdded) {
                    // 监听窗口大小改变事件,触发更新 viewBox 的函数
                    window.addEventListener('resize', () => {
                        updateViewBox();
                    });
                    // 标记已经添加了监听器
                    isResizeListenerAdded = true;
                }
            }

            // 如果是反转的情况,调用 createEffect 函数创建反转的动画效果
            if (reversed) {
                createEffect(effectFrom, effectTo, hpDiff, to, reversed);
            } else {
                // 正常情况调用 createEffect 函数创建正向的动画效果
                createEffect(effectFrom, effectTo, hpDiff, from, reversed);
            }
        }
    }

    // 处理 WebSocket 消息的函数
    function handleMessage(message) {
        // 将 JSON 字符串解析为 JavaScript 对象
        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 函数创建攻击动画
                                    createLine(userIndex, mIndex, hpDiff);
                                }
                            });
                        } else {
                            // 如果只有一个玩家,调用 createLine 函数创建攻击动画
                            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 函数创建攻击动画(反转)
                                    createLine(pIndex, monsterIndex, hpDiff, true);
                                }
                            });
                        } else {
                            // 如果只有一个怪物,调用 createLine 函数创建攻击动画(反转)
                            createLine(pIndex, monsterIndices[0], hpDiff, true);
                        }
                    }
                }
            });
        }
        // 返回原始消息
        return message;
    }

})();