// ==UserScript== // @name MWI-Hit-Tracker-More-Animation // @namespace http://tampermonkey.net/ // @version 1.8.2 // @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 // @downloadURL none // ==/UserScript== // 立即执行函数,创建独立作用域,避免全局变量污染 (function () { 'use strict'; // 状态变量,存储战斗相关信息 // monstersHP: 存储怪物的当前生命值 // monstersMP: 存储怪物的当前魔法值 // playersHP: 存储玩家的当前生命值 // playersMP: 存储玩家的当前魔法值 const battleState = { monstersHP: [], monstersMP: [], playersHP: [], playersMP: [] }; // 存储是否已添加窗口大小改变监听器 let isResizeListenerAdded = false; // 标记脚本是否暂停 let isPaused = false; // 初始化函数,用于启动脚本逻辑 function init() { // 劫持 WebSocket 消息,以便处理战斗相关的消息 hookWS(); // 添加网页可见性变化监听器,当网页从后台恢复时进行清理操作 addVisibilityChangeListener(); // 创建动画样式,用于攻击路径的闪烁效果 createAnimationStyle(); } // 创建 lineFlash 动画样式,使攻击路径产生闪烁效果 function createAnimationStyle() { // 创建一个 style 元素 const style = document.createElement('style'); // 设置 style 元素的文本内容为 lineFlash 动画的定义 style.textContent = ` @keyframes lineFlash { 0% { /* 起始时路径的透明度为 1 */ stroke-opacity: 1; } 50% { /* 中间时路径的透明度为 0.3 */ stroke-opacity: 0.3; } 100% { /* 结束时路径的透明度恢复为 1 */ stroke-opacity: 1; } } `; // 将 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'; } // 尝试定位伤害数字 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 = 5; // 根据伤害值计算字号的增量 const fontSizeIncrement = Math.floor(200 * Math.pow(hpDiff / (20000 + hpDiff), 0.45)); // 计算最终的字号 const fontSize = fontSizeIncrement - baseFontSize; // 设置伤害数字元素的字号 text.setAttribute('font-size', fontSize); // 设置伤害数字元素的填充颜色 text.setAttribute('fill', lineColor[index]); // 初始时伤害数字元素透明度0.7 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 (isPaused) return; // 检查当前帧是否小于总帧数 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); // 生成粒子拖尾 const numParticles = 2; // 粒子数量 for (let i = 0; i < numParticles; i++) { const particle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); particle.setAttribute('r', '2'); particle.setAttribute('fill', lineColor[index]); particle.style.opacity = 1; particle.style.transformOrigin = 'center'; // 随机偏移粒子位置 const offsetX = (Math.random() - 0.4) * 10; const offsetY = (Math.random() - 0.4) * 10; particle.setAttribute('cx', parseFloat(point.x) + offsetX); particle.setAttribute('cy', parseFloat(point.y) + offsetY); svg.appendChild(particle); setTimeout(() => { particle.style.transition = 'all 0.2s ease-out'; particle.style.opacity = 0; const removeParticle = () => { if (particle.parentNode) { particle.parentNode.removeChild(particle); } particle.removeEventListener('transitionend', removeParticle); }; particle.addEventListener('transitionend', removeParticle); }, 0); } // 当前帧序号加 1 currentFrame++; // 递归调用 animateText 函数,在指定的帧间隔时间后执行 setTimeout(animateText, frameDuration); } else { // 当动画结束后,开始执行魔法击中的消失动画 text.style.transition = 'all 0.2s 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]); }, 200); // 减少等待时间 } }; // 调用动画函数开始执行伤害数字动画 animateText(); // 设置5秒后强制移除元素 const removeAfter5Seconds = () => { if (path.parentNode) { path.parentNode.removeChild(path); } if (text.parentNode) { text.parentNode.removeChild(text); } }; setTimeout(removeAfter5Seconds, 5000); } // 创建粒子特效,在伤害数字消失时显示 function createParticleEffect(x, y, color) { // 如果脚本暂停,不创建粒子特效 if (isPaused) return; // 获取 SVG 容器元素 const svg = document.getElementById('svg-container'); // 定义粒子的数量 const numParticles = 20; // 减少粒子数量 // 循环创建粒子 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 = () => { if (particle.parentNode) { particle.parentNode.removeChild(particle); } particle.removeEventListener('transitionend', removeParticle); }; particle.addEventListener('transitionend', removeParticle); }); // 设置5秒后强制移除粒子 const removeParticleAfter5Seconds = () => { if (particle.parentNode) { particle.parentNode.removeChild(particle); } }; setTimeout(removeParticleAfter5Seconds, 5000); } } // 创建线条动画,根据攻击信息创建攻击路径和伤害数字动画 function createLine(from, to, hpDiff, reversed = false) { // 如果脚本暂停,不创建线条动画 if (isPaused) return; // 获取玩家区域的容器元素 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) { // 如果脚本暂停,直接返回原始消息 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") { // 初始化怪物的生命值数组 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); } 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); // 标记释放技能的怪物索引 let castMonster = -1; // 遍历怪物索引 monsterIndices.forEach((monsterIndex) => { // 如果怪物的当前魔法值小于之前记录的魔法值,标记该怪物为释放技能的怪物 if (mMap[monsterIndex].cMP < battleState.monstersMP[monsterIndex]) { castMonster = monsterIndex; } // 更新怪物的当前魔法值 battleState.monstersMP[monsterIndex] = mMap[monsterIndex].cMP; }); // 标记释放技能的玩家索引 let castPlayer = -1; // 遍历玩家索引 playerIndices.forEach((userIndex) => { // 如果玩家的当前魔法值小于之前记录的魔法值,标记该玩家为释放技能的玩家 if (pMap[userIndex].cMP < battleState.playersMP[userIndex]) { castPlayer = userIndex; } // 更新玩家的当前魔法值 battleState.playersMP[userIndex] = pMap[userIndex].cMP; }); // 遍历怪物的生命值数组 battleState.monstersHP.forEach((mHP, mIndex) => { // 获取当前怪物的信息 const monster = mMap[mIndex]; // 如果怪物信息存在 if (monster) { // 计算怪物失去的生命值 const hpDiff = mHP - monster.cHP; // 更新怪物的当前生命值 battleState.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); } } } }); // 遍历玩家的生命值数组 battleState.playersHP.forEach((pHP, pIndex) => { // 获取当前玩家的信息 const player = pMap[pIndex]; // 如果玩家信息存在 if (player) { // 计算玩家失去的生命值 const hpDiff = pHP - player.cHP; // 更新玩家的当前生命值 battleState.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; } // 检测网页是否从后台恢复,当网页从后台恢复时清理 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 => el.remove()); // 清理可能存在的粒子拖尾和击中粒子特效元素 document.querySelectorAll('circle[fill^="rgba"]').forEach(el => { if (el.parentNode === svg) { el.parentNode.removeChild(el); } }); } }); } // 启动初始化函数 init(); })();