MWI-Hit-Tracker-More-Animation

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

目前為 2025-05-09 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         MWI-Hit-Tracker-More-Animation
// @namespace    http://tampermonkey.net/
// @version      1.8.6
// @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';

    // 状态变量,存储战斗相关信息
    const battleState = {
        monstersHP: [],
        monstersMP: [],
        playersHP: [],
        playersMP: []
    };

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

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

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

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

    // 创建动画样式,包括路径闪烁和目标震动效果
    function createAnimationStyle() {
        const style = document.createElement('style');
        style.textContent = `
            @keyframes lineFlash {
                0% { stroke-opacity: 0.7; }
                50% { stroke-opacity: 0.3; }
                100% { stroke-opacity: 0.7; }
            }

            @keyframes shake {
                0%, 100% { transform: translateX(0); }
                50% { transform: translateX(-1px); } /* 减小震动幅度 */
            }

            .mwht-shake {
                animation: shake 0.2s cubic-bezier(.36,.07,.19,.97) forwards; /* 固定0.2秒持续时间 */
                transform-origin: center;
                position: relative;
                z-index: 200;
            }
        `;
        document.head.appendChild(style);
    }

    // 劫持 WebSocket 消息,拦截并处理战斗相关的消息
    function hookWS() {
        const dataProperty = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data");
        const oriGet = dataProperty.get;

        dataProperty.get = 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);
            }

            if (isPaused) {
                return oriGet.call(this);
            }

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

            return handleMessage(message);
        };

        Object.defineProperty(MessageEvent.prototype, "data", dataProperty);
    }

    // 计算元素中心点坐标
    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 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 shakeTarget(element) {
        if (!element || isPaused) return;

        // 向上查找第三个父级元素(用于实际震动)
        let shakeElement = element;
        for (let i = 0; i < 3 && shakeElement; i++) {
            shakeElement = shakeElement.parentElement;
        }

        // 向上查找第五个父级元素(用于判断震动方向)
        let directionElement = element;
        for (let i = 0; i < 5 && directionElement; i++) {
            directionElement = directionElement.parentElement;
        }

        // 如果找到了相应的父级元素,应用震动效果
        if (shakeElement && directionElement) {
            const className = directionElement.className;
            let transformValue = 'translate(0, 0)';

            // 根据第五个父级元素的类名决定震动方向
            if (className.includes('playersArea')) {
                transformValue = 'translate(-2px, 2px)';
            } else if (className.includes('monstersArea')) {
                transformValue = 'translate(2px, 2px)';
            }

            // 添加震动类并设置动画
            shakeElement.classList.add('mwht-shake');

            // 使用自定义动画实现不同方向的震动
            shakeElement.style.animation = `customShake 0.2s cubic-bezier(.36,.07,.19,.97) forwards`;
            shakeElement.style.transformOrigin = 'center';
            shakeElement.style.willChange = 'transform';

            // 存储原始transform值,动画结束后恢复
            const originalTransform = shakeElement.style.transform;

            // 动画帧函数
            let startTime = null;
            const duration = 200; // 200ms = 0.2s

            function animate(currentTime) {
                if (isPaused) return;

                if (!startTime) startTime = currentTime;
                const elapsed = currentTime - startTime;
                const progress = Math.min(elapsed / duration, 1);

                // 计算动画曲线
                const easeOut = 1 - Math.pow(1 - progress, 3);

                // 应用变换
                if (progress < 0.5) {
                    // 前半段:从0到目标偏移
                    const scale = easeOut * 2;
                    shakeElement.style.transform = `translate(${parseFloat(transformValue.split('(')[1]) * scale}px, ${parseFloat(transformValue.split(',')[1]) * scale}px)`;
                } else {
                    // 后半段:从目标偏移回到0
                    const scale = 2 - (easeOut * 2);
                    shakeElement.style.transform = `translate(${parseFloat(transformValue.split('(')[1]) * scale}px, ${parseFloat(transformValue.split(',')[1]) * scale}px)`;
                }

                if (progress < 1) {
                    requestAnimationFrame(animate);
                } else {
                    // 动画结束,恢复原始transform
                    shakeElement.style.transform = originalTransform;
                    shakeElement.classList.remove('mwht-shake');
                    shakeElement.style.animation = '';
                }
            }

            // 启动动画
            requestAnimationFrame(animate);
        }
    }

    // 创建动画效果,包括攻击路径和伤害数字的动画
    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;
                }
            }
        }

        const svg = document.getElementById('svg-container');
        const frag = document.createDocumentFragment();

        // 存储被隐藏的元素,用于后续恢复
        const hiddenElements = [];

        // 根据reversed参数决定目标元素
        const targetElem = reversed ? startElem : endElem;
        //console.log(`目标元素的值: ${targetElem ? targetElem.innerText.trim() : '无目标元素'}`);
        //console.log(`伤害的值: ${hpDiff}`);
        if (targetElem && targetElem.parentNode) {
            // 隐藏目标元素的所有同级元素
            const siblings = Array.from(targetElem.parentNode.children);
            for (const sibling of siblings) {
                if (sibling !== targetElem && sibling.style.visibility !== 'hidden') {
                    sibling.style.visibility = 'hidden';
                    hiddenElements.push(sibling);
                }
            }
        }

        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',
        });
        path.setAttribute('d', createParabolaPath(startElem, endElem, reversed));
        const pathLength = path.getTotalLength();
        path.style.strokeDasharray = pathLength;
        path.style.strokeDashoffset = pathLength;

        frag.appendChild(path);

        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,
            filter: `drop-shadow(0 0 5px ${lineColor[index]})`,
            transformOrigin: 'center',
            fontWeight: 'bold',
            willChange: 'transform, opacity, x, y',
        });
        frag.appendChild(text);

        svg.appendChild(frag);

        setTimeout(() => {
            requestAnimationFrame(() => {
                path.style.transition = 'stroke-dashoffset 1s linear';
                path.style.strokeDashoffset = '0';

                animateText(path, text, pathLength, lineColor[index], () => {
                    // 伤害数字动画结束后触发震动效果
                    shakeTarget(targetElem);

                    // 动画结束后恢复所有隐藏的元素
                    setTimeout(() => {
                        if (isPaused) return;

                        for (const element of hiddenElements) {
                            element.style.visibility = ''; // 恢复默认值
                        }
                    }, 200); // 延迟恢复,确保震动效果完成
                });
            });
        }, 100);

        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);
    }

    // 从对象池获取粒子元素
    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;

        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';

                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 });

                    setTimeout(() => {
                        if (particle.parentNode) {
                            particle.parentNode.removeChild(particle);
                            returnParticleToPool(particle);
                        }
                    }, 5000);
                });
            }
            batchCount++;
            if (batchCount * batchSize < numParticles) {
                setTimeout(createBatch, 50);
            } else {
                svg.appendChild(frag);
            }
        }
        createBatch();
    }

    // 文本动画函数 - 使用 requestAnimationFrame 实现更流畅的动画
    function animateText(path, text, pathLength, color, onComplete) {
        const animationConfig = {
            duration: 1350,
            fadeInStart: 0.0,
            fadeInEnd: 0.3,
            particleInterval: 3
        };

        let startTime = null;
        let lastParticleFrame = 0;

        function animate(currentTime) {
            if (isPaused) return;

            if (!startTime) startTime = currentTime;

            const elapsed = currentTime - startTime;
            const progress = Math.min(elapsed / animationConfig.duration, 1);

            const point = path.getPointAtLength(progress * pathLength);

            text.setAttribute('x', point.x);
            text.setAttribute('y', point.y);

            let opacity = 1;
            if (progress < animationConfig.fadeInStart) {
                opacity = 0;
            } else if (progress < animationConfig.fadeInEnd) {
                opacity = 0.7 + 0.3 * ((progress - animationConfig.fadeInStart) / (animationConfig.fadeInEnd - animationConfig.fadeInStart));
            }
            text.style.opacity = opacity;

            if (Math.floor(progress * 100) % animationConfig.particleInterval === 0 && lastParticleFrame !== Math.floor(progress * 100)) {
                lastParticleFrame = Math.floor(progress * 100);

                const particle = getParticleFromPool();
                particle.setAttribute('r', '2');
                particle.setAttribute('fill', color);
                particle.setAttribute('cx', point.x + (Math.random() - 0.5) * 10);
                particle.setAttribute('cy', point.y + (Math.random() - 0.5) * 10);
                particle.style.opacity = 1;
                particle.style.transition = 'all 0.2s ease-out';
                particle.style.willChange = 'opacity, transform';

                const svg = document.getElementById('svg-container');
                svg.appendChild(particle);

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

            if (progress < 1) {
                requestAnimationFrame(animate);
            } else {
                text.style.transition = 'all 0.2s ease-out';
                text.style.transform = 'scale(1.5)';
                text.style.opacity = 0;

                setTimeout(() => {
                    text.remove();
                    createParticleEffect(text.getAttribute('x'), text.getAttribute('y'), color);

                    // 调用回调函数触发震动和恢复可见性
                    if (typeof onComplete === 'function') {
                        onComplete();
                    }
                }, 100);
            }
        }

        requestAnimationFrame(animate);
    }

    // 创建线条动画,根据攻击信息创建攻击路径和伤害数字动画
    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;

        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';

            Object.assign(svgContainer.style, {
                position: 'fixed',
                top: '0',
                left: '0',
                width: '100%',
                height: '100%',
                pointerEvents: 'none',
                overflow: 'visible',
                zIndex: '190'
            });

            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;

            if (hpDiff > 0 && attackerIndices.length > 0) {
                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 {
            obj = JSON.parse(message);
        } catch (error) {
            console.error('Failed to parse WebSocket message:', error);
            return message;
        }
        if (obj && obj.type === "new_battle") {
            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);

            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;
                const svg = document.getElementById('svg-container');
                if (svg) {
                    while (svg.firstChild) {
                        svg.removeChild(svg.firstChild);
                    }
                }
                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();

})();