MWI-Hit-Tracker-More-Animation

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

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

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

})();