MWI-Hit-Tracker-More-Animation

战斗过程中实时显示攻击命中目标,增加了更多的特效

当前为 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-More-Animation
// @namespace    http://tampermonkey.net/
// @version      1.8.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';

    // 状态变量,存储战斗相关信息
    // monstersHP: 存储怪物的当前生命值
    // monstersMP: 存储怪物的当前魔法值
    // playersHP: 存储玩家的当前生命值
    // playersMP: 存储玩家的当前魔法值
    const battleState = {
        monstersHP: [],
        monstersMP: [],
        playersHP: [],
        playersMP: []
    };

    // 存储是否已添加窗口大小改变监听器
    let isResizeListenerAdded = false;

    // 标记脚本是否暂停
    let isPaused = false;

    // 粒子对象池
    const particlePool = [];

    // 初始化函数,用于启动脚本逻辑
    function init() {
        // 劫持 WebSocket 消息,以便处理战斗相关的消息
        hookWS();
        // 添加网页可见性变化监听器,当网页从后台恢复时进行清理操作
        addVisibilityChangeListener();
        // 创建动画样式,用于攻击路径的闪烁效果
        createAnimationStyle();
    }

    // 创建 lineFlash 动画样式,使攻击路径产生闪烁效果
    function createAnimationStyle() {
        // 创建一个 style 元素
        const style = document.createElement('style');
        // 设置 style 元素的文本内容为 lineFlash 动画的定义
        style.textContent = `
            @keyframes lineFlash {
                0% {
                    /* 起始时路径的透明度为 0.7 */
                    stroke-opacity: 0.7;
                }
                50% {
                    /* 中间时路径的透明度为 0.3 */
                    stroke-opacity: 0.3;
                }
                100% {
                    /* 结束时路径的透明度恢复为 0.7 */
                    stroke-opacity: 0.7;
                }
            }
        `;
        // 将 style 元素添加到文档的头部
        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 = 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);
            }

            // 如果脚本暂停,直接返回原始消息
            if (isPaused) {
                return oriGet.call(this);
            }

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

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

        // 重新定义 MessageEvent 原型上的 data 属性
        Object.defineProperty(MessageEvent.prototype, "data", dataProperty);
    }

    // 计算元素中心点坐标
    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) {
        // 如果脚本暂停,直接返回
        if (isPaused) return;

        // 根据伤害值调整线条宽度和滤镜宽度
        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';
        }

        // 查找伤害元素用于动画起始或结束位置
        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');
        const frag = document.createDocumentFragment();

        // 创建 SVG 路径元素
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        // 如果是反转情况,使用敌人攻击颜色
        if (reversed) index = 6;
        // 设置路径的样式
        Object.assign(path.style, {
            stroke: lineColor[index],
            strokeWidth,
            fill: 'none',
            strokeLinecap: 'round',
            filter: `drop-shadow(0 0 ${filterWidth} ${filterColor[index]})`,
            willChange: 'stroke-dashoffset, opacity',
        });
        // 设置路径的 d 属性,即路径的形状
        path.setAttribute('d', createParabolaPath(startElem, endElem, reversed));
        // 获取路径的总长度
        const pathLength = path.getTotalLength();
        // 设置路径的虚线样式,初始为隐藏
        path.style.strokeDasharray = pathLength;
        path.style.strokeDashoffset = pathLength;

        // 将路径添加到文档片段中
        frag.appendChild(path);

        // 创建 SVG 文本元素,用于显示伤害值
        const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
        text.textContent = hpDiff;
        // 根据伤害值计算字体大小
        const baseFontSize = 5;
        const fontSize = Math.floor(200 * Math.pow(hpDiff / (20000 + hpDiff), 0.45)) - baseFontSize;
        text.setAttribute('font-size', fontSize);
        text.setAttribute('fill', lineColor[index]);
        // 设置文本的样式
        Object.assign(text.style, {
            opacity: 0.7,
            filter: `drop-shadow(0 0 5px ${lineColor[index]})`,
            transformOrigin: 'center',
            fontWeight: 'bold',
            willChange: 'transform, opacity',
        });
        // 将文本添加到文档片段中
        frag.appendChild(text);

        // 将文档片段添加到 SVG 容器中
        svg.appendChild(frag);

        // 延迟 100ms 后开始路径动画
        setTimeout(() => {
            requestAnimationFrame(() => {
                path.style.transition = 'stroke-dashoffset 1s linear';
                path.style.strokeDashoffset = '0';
            });
        }, 100);

        // 延迟 900ms 后开始路径消失动画
        setTimeout(() => {
            requestAnimationFrame(() => {
                path.style.transition = 'stroke-dashoffset 1s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 1s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
                path.style.strokeDashoffset = -pathLength;
                path.style.opacity = 0;

                // 路径动画结束后移除路径元素
                const removePath = () => {
                    path.remove();
                };
                path.addEventListener('transitionend', removePath, { once: true });
            });
        }, 900);

        // 动画帧数和总时长
        const numFrames = 90;
        const totalDuration = 1350;
        const frameDuration = totalDuration / numFrames;
        let currentFrame = 0;
        let lastTime = performance.now();

        // 文本动画函数
        function animateText(now = performance.now()) {
            // 如果脚本暂停,直接返回
            if (isPaused) return;

            // 达到帧间隔时间,更新文本位置和透明度
            if (now - lastTime >= frameDuration) {
                const point = path.getPointAtLength((currentFrame / numFrames) * pathLength);
                text.setAttribute('x', point.x);
                text.setAttribute('y', point.y);
                text.style.opacity = 0.7 + 0.3 * (currentFrame / numFrames);

                // 每 3 帧创建一个粒子效果(减少创建频率)
                if (currentFrame % 3 === 0) {
                    const particle = getParticleFromPool();
                    particle.setAttribute('r', '2');
                    particle.setAttribute('fill', lineColor[index]);
                    particle.setAttribute('cx', point.x + (Math.random() - 0.3) * 10);
                    particle.setAttribute('cy', point.y + (Math.random() - 0.3) * 10);
                    particle.style.opacity = 1;
                    particle.style.transition = 'all 0.2s ease-out';
                    particle.style.willChange = 'opacity, transform';

                    svg.appendChild(particle);
                    requestAnimationFrame(() => {
                        particle.style.opacity = 0;
                        particle.addEventListener('transitionend', () => {
                            returnParticleToPool(particle);
                        }, { once: true });
                    });
                }

                // 帧数加 1,更新时间
                currentFrame++;
                lastTime = now;
            }

            // 帧数未达到总帧数,继续动画
            if (currentFrame < numFrames) {
                requestAnimationFrame(animateText);
            } else {
                // 动画结束,缩放并隐藏文本
                text.style.transition = 'all 0.2s ease-out';
                text.style.transform = 'scale(1.5)';
                text.style.opacity = 0;

                // 延迟 200ms 后移除文本并创建粒子效果
                setTimeout(() => {
                    text.remove();
                    createParticleEffect(text.getAttribute('x'), text.getAttribute('y'), lineColor[index]);
                }, 200);
            }
        }

        // 开始文本动画
        requestAnimationFrame(animateText);

        // 延迟 5000ms 后移除路径和文本元素
        setTimeout(() => {
            path.remove();
            text.remove();
        }, 5000);
    }

    // 从对象池获取粒子元素
    function getParticleFromPool() {
        if (particlePool.length > 0) {
            return particlePool.pop();
        }
        return document.createElementNS("http://www.w3.org/2000/svg", "circle");
    }

    // 将粒子元素返回对象池
    function returnParticleToPool(particle) {
        particle.removeAttribute('r');
        particle.removeAttribute('fill');
        particle.removeAttribute('cx');
        particle.removeAttribute('cy');
        particle.style.opacity = 1;
        particle.style.transform = 'none';
        particle.removeEventListener('transitionend', () => {});
        particlePool.push(particle);
    }

    // 创建粒子特效,在伤害数字消失时显示
    function createParticleEffect(x, y, color) {
        // 如果脚本暂停,直接返回
        if (isPaused) return;

        // 获取 SVG 容器
        const svg = document.getElementById('svg-container');
        const numParticles = 20;
        const frag = document.createDocumentFragment(); // 批量插入用

        // 分批创建粒子
        const batchSize = 5;
        let batchCount = 0;
        function createBatch() {
            for (let i = 0; i < batchSize && batchCount * batchSize + i < numParticles; i++) {
                const particle = getParticleFromPool();
                particle.setAttribute('r', '2');
                particle.setAttribute('fill', color);
                particle.setAttribute('cx', x);
                particle.setAttribute('cy', y);
                particle.style.opacity = 1;
                particle.style.transformOrigin = 'center';
                particle.style.willChange = 'transform, opacity'; // GPU 优化

                // 计算粒子的结束位置
                const angle = ((batchCount * batchSize + i) / numParticles) * 2 * Math.PI;
                const distance = Math.random() * 30 + 10;
                const endX = parseFloat(x) + distance * Math.cos(angle);
                const endY = parseFloat(y) + distance * Math.sin(angle);

                // 将粒子添加到文档片段中
                frag.appendChild(particle);

                // 开始粒子动画
                requestAnimationFrame(() => {
                    particle.style.transition = 'all 0.3s ease-out';
                    particle.setAttribute('cx', endX);
                    particle.setAttribute('cy', endY);
                    particle.style.opacity = 0;

                    // 粒子动画结束后移除粒子元素
                    particle.addEventListener('transitionend', () => {
                        returnParticleToPool(particle);
                    }, { once: true });

                    // 兜底:5秒后强制移除
                    setTimeout(() => {
                        if (particle.parentNode) {
                            particle.parentNode.removeChild(particle);
                            returnParticleToPool(particle);
                        }
                    }, 5000);
                });
            }
            batchCount++;
            if (batchCount * batchSize < numParticles) {
                setTimeout(createBatch, 50); // 分批创建,减轻 JavaScript 负担
            } else {
                // 将文档片段添加到 SVG 容器中
                svg.appendChild(frag); // 一次性插入所有粒子
            }
        }
        createBatch();
    }

    // 创建线条动画,根据攻击信息创建攻击路径和伤害数字动画
    function createLine(from, to, hpDiff, reversed = false) {
        // 如果脚本暂停,直接返回
        if (isPaused) return;

        // 获取玩家区域、怪物区域和游戏面板元素
        const playerArea = document.querySelector(".BattlePanel_playersArea__vvwlB");
        const monsterArea = document.querySelector(".BattlePanel_monstersArea__2dzrY");
        const gamePanel = document.querySelector(".GamePage_mainPanel__2njyb");

        // 如果元素不存在,直接返回
        if (!playerArea || !monsterArea || !gamePanel) return;

        // 获取玩家容器和怪物容器
        const playersContainer = playerArea.firstElementChild;
        const monsterContainer = monsterArea.firstElementChild;

        // 获取攻击起始元素和结束元素
        const effectFrom = playersContainer?.children[from];
        const effectTo = monsterContainer?.children[to];

        // 如果元素不存在,直接返回
        if (!effectFrom || !effectTo) return;

        // 获取 SVG 容器,如果不存在则创建
        let svgContainer = document.getElementById('svg-container');

        if (!svgContainer) {
            const svgNS = 'http://www.w3.org/2000/svg';
            svgContainer = document.createElementNS(svgNS, 'svg');
            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 属性
            const setViewBox = () => {
                const width = window.innerWidth;
                const height = window.innerHeight;
                svgContainer.setAttribute('viewBox', `0 0 ${width} ${height}`);
            };

            setViewBox();
            svgContainer.setAttribute('preserveAspectRatio', 'none');
            gamePanel.appendChild(svgContainer);

            // 如果未添加窗口大小改变监听器,则添加
            if (!isResizeListenerAdded) {
                window.addEventListener('resize', setViewBox);
                isResizeListenerAdded = true;
            }
        }

        // 获取原始索引
        const originIndex = reversed ? to : from;
        // 创建动画效果
        createEffect(effectFrom, effectTo, hpDiff, originIndex, reversed);
    }

    // 处理伤害信息,根据新旧生命值计算伤害差值并创建动画
    function processDamage(oldHPArr, newMap, castIndex, attackerIndices, isReverse = false) {
        // 遍历旧的生命值数组
        oldHPArr.forEach((oldHP, index) => {
            // 获取新的生命值信息
            const entity = newMap[index];
            // 如果新的生命值信息不存在,跳过
            if (!entity) return;

            // 计算生命值差值
            const hpDiff = oldHP - entity.cHP;
            // 更新旧的生命值数组
            oldHPArr[index] = entity.cHP;

            // 如果生命值差值大于 0 且攻击者索引数组不为空
            if (hpDiff > 0 && attackerIndices.length > 0) {
                // 如果攻击者索引数组长度大于 1
                if (attackerIndices.length > 1) {
                    // 遍历攻击者索引数组
                    attackerIndices.forEach(attackerIndex => {
                        // 如果攻击者索引等于施法者索引
                        if (attackerIndex === castIndex) {
                            // 创建攻击线条动画
                            createLine(attackerIndex, index, hpDiff, isReverse);
                        }
                    });
                } else {
                    // 创建攻击线条动画
                    createLine(attackerIndices[0], index, hpDiff, isReverse);
                }
            }
        });
    }

    // 检测施法者,通过比较新旧魔法值找出施法者索引
    function detectCaster(oldMPArr, newMap) {
        let casterIndex = -1;
        // 遍历新的魔法值映射
        Object.keys(newMap).forEach(index => {
            // 获取新的魔法值
            const newMP = newMap[index].cMP;
            // 如果新的魔法值小于旧的魔法值
            if (newMP < oldMPArr[index]) {
                // 记录施法者索引
                casterIndex = index;
            }
            // 更新旧的魔法值数组
            oldMPArr[index] = newMP;
        });
        return casterIndex;
    }

    // 处理 WebSocket 消息,根据消息类型更新战斗状态并创建攻击动画
    function handleMessage(message) {
        // 如果脚本暂停,直接返回原始消息
        if (isPaused) {
            return message;
        }

        let obj;
        try {
            // 将 JSON 字符串解析为 JavaScript 对象
            obj = JSON.parse(message);
        } catch (error) {
            console.error('Failed to parse WebSocket message:', error);
            return message;
        }
        // 如果消息类型是新战斗开始
        if (obj && obj.type === "new_battle") {
            console.log('Received new_battle message');
            // 初始化怪物的生命值数组
            battleState.monstersHP = obj.monsters.map((monster) => monster.currentHitpoints);
            // 初始化怪物的魔法值数组
            battleState.monstersMP = obj.monsters.map((monster) => monster.currentManapoints);
            // 初始化玩家的生命值数组
            battleState.playersHP = obj.players.map((player) => player.currentHitpoints);
            // 初始化玩家的魔法值数组
            battleState.playersMP = obj.players.map((player) => player.currentManapoints);
            // 移除SVG容器内所有元素(包括路径、伤害数字、粒子拖尾和击中粒子特效)
            const svg = document.getElementById('svg-container');
            if (svg) {
                // 递归清空所有子节点
                while (svg.firstChild) {
                    svg.removeChild(svg.firstChild);
                }
            }
            // 清空粒子池
            particlePool.length = 0;
        } else if (obj && obj.type === "battle_updated" && battleState.monstersHP.length) {
            // 获取怪物信息的映射
            const mMap = obj.mMap;
            // 获取玩家信息的映射
            const pMap = obj.pMap;
            // 获取怪物的索引数组
            const monsterIndices = Object.keys(obj.mMap);
            // 获取玩家的索引数组
            const playerIndices = Object.keys(obj.pMap);

            // 标记释放技能的怪物索引
            const castMonster = detectCaster(battleState.monstersMP, mMap);
            // 标记释放技能的玩家索引
            const castPlayer = detectCaster(battleState.playersMP, pMap);

            // 遍历怪物的生命值数组
            processDamage(battleState.monstersHP, mMap, castPlayer, playerIndices, false);

            // 遍历玩家的生命值数组
            processDamage(battleState.playersHP, pMap, castMonster, monsterIndices, true);
        }
        // 返回原始消息
        return message;
    }

    // 检测网页是否从后台恢复,当网页从后台恢复时清理 SVG 容器中的元素
    function addVisibilityChangeListener() {
        document.addEventListener('visibilitychange', function () {
            if (document.visibilityState === 'hidden') {
                // 网页隐藏时,暂停脚本
                isPaused = true;
            } else if (document.visibilityState === 'visible') {
                // 网页从后台恢复时,解除暂停
                isPaused = false;
                // 移除SVG容器内所有元素(包括路径、伤害数字、粒子拖尾和击中粒子特效)
                const svg = document.getElementById('svg-container');
                if (svg) {
                    // 递归清空所有子节点
                    while (svg.firstChild) {
                        svg.removeChild(svg.firstChild);
                    }
                }
                // 额外清理可能残留的未被SVG容器包含的元素(防御性清理)
                document.querySelectorAll('[id^="mwi-hit-tracker-"]').forEach(el => {
                    if (el) {
                        el.remove();
                    }
                });
                // 清理可能存在的粒子拖尾和击中粒子特效元素
                document.querySelectorAll('circle[fill^="rgba"]').forEach(el => {
                    if (el.parentNode === svg) {
                        el.parentNode.removeChild(el);
                    }
                });
            }
        });
    }

    // 启动初始化函数
    init();

})();