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