// ==UserScript== // @name MWI-Hit-Tracker-More-Animation // @namespace http://tampermonkey.net/ // @version 1.9.1 // @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'; // 状态变量,存储战斗相关信息 const battleState = { monstersHP: [], monstersMP: [], playersHP: [], playersMP: [] }; // 存储是否已添加窗口大小改变监听器 let isResizeListenerAdded = false; // 标记脚本是否暂停 let isPaused = false; // 粒子对象池 const particlePool = []; // 标记按钮是否已添加 let isCustomColorButtonAdded = false; // 保存初始颜色 const initialLineColor = [ "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 initialFilterColor = [ "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)" // 敌人攻击颜色 ]; // 存储每个玩家的勾选状态,默认全部勾选 const playerDrawEnabled = new Array(7).fill(true); //存储特效的勾选状态,默认全勾选 // 索引含义:0-伤害数字,1-线条绘制,2-粒子拖尾,3-击中特效,4-震动特效 const effectDrawEnabled = new Array(5).fill(true); // 定义线条颜色数组,用于不同角色的攻击线条颜色 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)" // 敌人攻击颜色 ]; // 从 localStorage 加载保存的设置 function readSettings() { const ls = localStorage.getItem("MWI_Hit_Tracker_Settings"); if (ls) { const lsObj = JSON.parse(ls); lineColor.splice(0, lineColor.length, ...lsObj.lineColor); filterColor.splice(0, filterColor.length, ...lsObj.filterColor); playerDrawEnabled.splice(0, playerDrawEnabled.length, ...lsObj.playerDrawEnabled); } // 读取特效设置 const effectLs = localStorage.getItem("MWI_Hit_Tracker_Effect_Settings"); if (effectLs) { const effectLsObj = JSON.parse(effectLs); effectDrawEnabled.splice(0, effectDrawEnabled.length, ...effectLsObj.effectDrawEnabled); } } // 保存设置到 localStorage function saveSettings() { const settings = { lineColor: lineColor, filterColor: filterColor, playerDrawEnabled: playerDrawEnabled }; localStorage.setItem("MWI_Hit_Tracker_Settings", JSON.stringify(settings)); // 保存特效设置 const effectSettings = { effectDrawEnabled: effectDrawEnabled }; localStorage.setItem("MWI_Hit_Tracker_Effect_Settings", JSON.stringify(effectSettings)); } // 在初始化时加载设置 readSettings(); // 创建自定义颜色按钮 /** * 创建自定义颜色设置按钮,用于打开设置弹出窗口,可设置玩家攻击线条颜色和显示状态。 */ function createCustomColorButton() { // 使用选择器,查找按钮的父元素 const tabsContainer = document.querySelector("#root > div > div > div.GamePage_gamePanel__3uNKN > div.GamePage_contentPanel__Zx4FH > div.GamePage_middlePanel__uDts7 > div.GamePage_mainPanel__2njyb > div > div:nth-child(1) > div > div > div > div.TabsComponent_tabsContainer__3BDUp > div > div > div"); // 获取参考标签,如果 tabsContainer 存在,则取其第二个子元素 const referenceTab = tabsContainer ? tabsContainer.children[1] : null; // 检查是否找到目标元素,如果未找到则输出提示信息并返回 if (!tabsContainer || !referenceTab) { console.log('未找到目标元素,请检查选择器是否正确。'); return; } // 检查是否已经存在自定义颜色按钮,如果存在则返回 if (tabsContainer.querySelector('.Button_customColor__custom')) return; // 创建自定义颜色设置按钮 const customColorButton = document.createElement('button'); // 为按钮设置自定义类名 customColorButton.className = 'Button_customColor__custom css-1q2h7u5'; // 设置按钮的显示文本 customColorButton.textContent = 'Hit自定义设置'; // 获取标签容器中的最后一个标签 const lastTab = tabsContainer.children[tabsContainer.children.length - 1]; // 遍历标签容器中的所有标签,检查是否存在文本内容为"商品列表"的标签,如果存在则返回 for (let i = 0; i < tabsContainer.children.length; i++) { if (tabsContainer.children[i].textContent === "商品列表") { return; } } // 将自定义颜色设置按钮插入到最后一个标签之后 lastTab.insertAdjacentElement('afterend', customColorButton); // 创建样式元素,用于设置按钮和弹出窗口相关的样式 const style = document.createElement('style'); // 设置样式内容 style.innerHTML = ` .Button_customColor__custom { background-color: #546ddb; color: white; border-radius: 5px; padding: 5px 10px; cursor: pointer; transition: background-color 0.3s; } .Button_customColor__custom:hover { background-color: #131419; } .expandable-section { cursor: pointer; padding: 10px; background-color: #e0e0e0; margin-bottom: 10px; border-radius: 5px; } .expandable-content { display: none; padding-left: 20px; } .expandable-content.show { display: block; } .draggable { cursor: move; } `; // 将样式元素添加到文档头部 document.head.appendChild(style); // 为自定义颜色设置按钮添加点击事件监听器 customColorButton.addEventListener('click', () => { // 检查文档中是否已存在类名为 "自定义菜单" 的元素,如果有则销毁它 const customMenu = document.querySelector('.自定义菜单'); if (customMenu) { document.body.removeChild(customMenu); return; } // 创建弹出窗口元素并添加类名 "自定义菜单" const popup = document.createElement('div'); popup.classList.add('自定义菜单'); // 设置弹出窗口的定位方式为固定定位 popup.style.position = 'fixed'; // 设置弹出窗口的垂直居中位置 popup.style.top = '50%'; // 设置弹出窗口的水平居中位置 popup.style.left = '50%'; // 调整弹出窗口的位置,使其完全居中 //popup.style.transform = 'translate(-50%, -50%)'; // 设置弹出窗口的背景颜色 popup.style.backgroundColor = '#f9f9f9'; // 设置弹出窗口的内边距 popup.style.padding = '30px'; // 设置弹出窗口的边框样式 popup.style.border = '2px solid #ddd'; // 设置弹出窗口的边框圆角 popup.style.borderRadius = '10px'; // 设置弹出窗口的阴影效果 popup.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)'; // 设置弹出窗口的层级,确保其显示在最上层 popup.style.zIndex = '9999'; // 设置弹出窗口的最小宽度 popup.style.minWidth = '300px'; // 使弹出窗口可拖动 let isDragging = false; let startX, startY, initialX, initialY; // 鼠标按下事件,开始拖动 popup.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; initialX = popup.offsetLeft; initialY = popup.offsetTop; // 移除居中 transform 样式 //popup.style.transform = 'none'; }); // 鼠标移动事件,处理拖动 document.addEventListener('mousemove', (e) => { if (isDragging) { e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; popup.style.left = initialX + dx + 'px'; popup.style.top = initialY + dy + 'px'; } }); // 鼠标释放事件,结束拖动 document.addEventListener('mouseup', () => { isDragging = false; }); // 封装创建可展开部分的函数 function createExpandableSection(title, contentGenerator = null) { // 创建可展开区域元素 const expandableSection = document.createElement('div'); // 为可展开区域元素设置类名 expandableSection.className = 'expandable-section draggable'; // 初始化展开状态为未展开 let isExpanded = false; // 根据展开状态设置可展开区域的显示文本 function updateExpandableSectionText() { expandableSection.textContent = isExpanded ? `${title} ▼` : `${title} ▶`; } // 初始设置文本 updateExpandableSectionText(); // 创建可展开内容元素 const expandableContent = document.createElement('div'); // 为可展开内容元素设置类名 expandableContent.className = 'expandable-content'; // 如果有内容生成函数,则调用该函数生成内容 if (contentGenerator) { contentGenerator(expandableContent); } // 修改点击事件监听器,更新展开状态并更新文本,同时切换可展开内容的显示状态 expandableSection.addEventListener('click', () => { isExpanded = !isExpanded; expandableContent.classList.toggle('show'); updateExpandableSectionText(); }); return { expandableSection, expandableContent }; } // 生成玩家和颜色部分内容的函数 function generatePlayerColorContent(expandableContent) { // 定义玩家名称数组 const players = ['玩家一', '玩家二', '玩家三', '玩家四', '玩家五', '待定', '敌人']; // 封装创建勾选框和标签的通用函数 function createCheckboxAndLabel(container, labelText, checked, changeHandler) { // 创建勾选框元素 const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = checked; checkbox.addEventListener('change', changeHandler); container.appendChild(checkbox); // 创建标签元素 const label = document.createElement('span'); label.textContent = labelText; label.style.flex = '1'; label.style.fontSize = '14px'; label.style.marginLeft = '10px'; container.appendChild(label); } // 遍历玩家名称数组,为每个玩家创建颜色选择器和预览 players.forEach((player, index) => { // 创建容器元素,用于包裹勾选框、标签、颜色选择器和预览 const container = document.createElement('div'); container.style.marginBottom = '15px'; container.style.display = 'flex'; container.style.alignItems = 'center'; // 创建勾选框和标签 createCheckboxAndLabel(container, `${player}: `, playerDrawEnabled[index], (e) => { playerDrawEnabled[index] = e.target.checked; }); // 创建颜色选择器元素 const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = lineColor[index]; colorInput.addEventListener('input', (e) => { if (playerDrawEnabled[index]) { lineColor[index] = e.target.value; filterColor[index] = e.target.value.replace('1)', '0.8)'); saveSettings(); // 保存设置 } }); colorInput.style.marginRight = '10px'; // 创建颜色预览元素 const preview = document.createElement('div'); preview.style.width = '30px'; preview.style.height = '30px'; preview.style.border = '1px solid #ccc'; preview.style.borderRadius = '4px'; preview.style.backgroundColor = lineColor[index]; colorInput.addEventListener('input', (e) => { preview.style.backgroundColor = e.target.value; }); // 将颜色选择器和预览元素添加到容器元素中 container.appendChild(colorInput); container.appendChild(preview); // 将容器元素添加到可展开内容元素中 expandableContent.appendChild(container); }); } // 生成特效自定义部分内容的函数 function generateEffectCustomContent(expandableContent) { // 定义特效名称集合,方便后期维护(调整顺序,线条绘制移到第一位) const effectNames = [ '线条绘制', '伤害数字', '击中特效', '震动' ]; // 定义子菜单配置 const subMenuConfig = { '伤害数字': ['粒子拖尾'] // 粒子拖尾作为伤害数字的子菜单 }; // 封装创建勾选框和标签的通用函数 function createCheckboxAndLabel(container, labelText, checked, changeHandler, indent = false) { // 创建勾选框元素 const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = checked; checkbox.addEventListener('change', changeHandler); // 如果需要缩进(用于子菜单项) if (indent) { const indentSpan = document.createElement('span'); indentSpan.innerHTML = '    '; container.appendChild(indentSpan); } container.appendChild(checkbox); // 创建标签元素 const label = document.createElement('span'); label.textContent = labelText; label.style.flex = '1'; label.style.fontSize = '14px'; label.style.marginLeft = '10px'; container.appendChild(label); return checkbox; } // 保存子菜单的勾选框引用,用于控制可见性和勾选状态 const subMenuCheckboxes = {}; // 处理父菜单项的变更事件,控制子菜单项 function handleParentMenuChange(parentName, isChecked) { if (subMenuConfig[parentName]) { subMenuConfig[parentName].forEach(subItem => { if (subMenuCheckboxes[`${parentName}_${subItem}`]) { // 当父菜单取消勾选时,禁用子菜单项 subMenuCheckboxes[`${parentName}_${subItem}`].disabled = !isChecked; if (!isChecked) { // 当父菜单取消勾选时,也取消子菜单的勾选 subMenuCheckboxes[`${parentName}_${subItem}`].checked = false; // 更新对应的特效状态 if (subItem === '粒子拖尾') { effectDrawEnabled[2] = false; } } } }); } saveSettings(); } // 遍历特效名称数组,为每个特效创建勾选框和标签 effectNames.forEach((effect, index) => { // 创建容器元素,用于包裹勾选框和标签 const container = document.createElement('div'); container.style.marginBottom = '15px'; container.style.display = 'flex'; container.style.alignItems = 'center'; // 使线条绘制对应index=1,伤害数字对应index=0,击中特效对应index=3,震动对应index=4 let effectIndex; if (effect === '线条绘制') effectIndex = 1; else if (effect === '伤害数字') effectIndex = 0; else if (effect === '击中特效') effectIndex = 3; else if (effect === '震动') effectIndex = 4; // 创建勾选框和标签 const checkbox = createCheckboxAndLabel(container, effect, effectDrawEnabled[effectIndex], (e) => { // 更新全局变量effectDrawEnabled的状态 effectDrawEnabled[effectIndex] = e.target.checked; // 如果这个特效有子菜单,处理子菜单的可见性和状态 handleParentMenuChange(effect, e.target.checked); // 保存设置 saveSettings(); }); // 将容器元素添加到可展开内容元素中 expandableContent.appendChild(container); // 处理子菜单 if (subMenuConfig[effect]) { subMenuConfig[effect].forEach(subItem => { // 创建子菜单项容器 const subContainer = document.createElement('div'); subContainer.style.marginBottom = '10px'; subContainer.style.marginLeft = '20px'; subContainer.style.display = 'flex'; subContainer.style.alignItems = 'center'; let subEffectIndex; if (subItem === '粒子拖尾') subEffectIndex = 2; // 创建子菜单项勾选框和标签 const subCheckbox = createCheckboxAndLabel(subContainer, subItem, effectDrawEnabled[subEffectIndex], (e) => { // 更新全局变量effectDrawEnabled的状态 effectDrawEnabled[subEffectIndex] = e.target.checked; // 保存设置 saveSettings(); }, true); // 如果父菜单未勾选,禁用子菜单项 subCheckbox.disabled = !effectDrawEnabled[effectIndex]; // 保存子菜单勾选框引用 subMenuCheckboxes[`${effect}_${subItem}`] = subCheckbox; // 将子菜单容器添加到可展开内容元素中 expandableContent.appendChild(subContainer); }); } }); } // 创建玩家和颜色可展开部分 const { expandableSection: playerColorSection, expandableContent: playerColorContent } = createExpandableSection('玩家和颜色', generatePlayerColorContent); popup.appendChild(playerColorSection); popup.appendChild(playerColorContent); // 创建特效自定义可展开部分 const { expandableSection: effectCustomSection, expandableContent: effectCustomContent } = createExpandableSection('特效自定义', generateEffectCustomContent); popup.appendChild(effectCustomSection); popup.appendChild(effectCustomContent); // 创建特效参数可展开部分 const { expandableSection: effectParamsSection, expandableContent: effectParamsContent } = createExpandableSection('特效参数'); popup.appendChild(effectParamsSection); popup.appendChild(effectParamsContent); // 创建重置按钮元素 const resetButton = document.createElement('button'); // 设置重置按钮的显示文本 resetButton.textContent = '重置'; // 设置重置按钮的背景颜色 resetButton.style.backgroundColor = '#ff4444'; // 设置重置按钮的文本颜色 resetButton.style.color = 'white'; // 设置重置按钮无边框 resetButton.style.border = 'none'; // 设置重置按钮的边框圆角 resetButton.style.borderRadius = '4px'; // 设置重置按钮的内边距 resetButton.style.padding = '8px 15px'; // 设置重置按钮的右外边距 resetButton.style.marginRight = '10px'; // 设置重置按钮的鼠标指针样式 resetButton.style.cursor = 'pointer'; // 为重置按钮添加点击事件监听器,点击时重置所有设置 resetButton.addEventListener('click', () => { // 添加确认对话框 const confirmReset = confirm('确定要重置所有设置吗?这将恢复所有默认值。'); // 如果用户取消,则不执行重置操作 if (!confirmReset) { return; } // 重置线条颜色数组 lineColor.splice(0, lineColor.length, ...initialLineColor); // 重置滤镜颜色数组 filterColor.splice(0, filterColor.length, ...initialFilterColor); // 重置玩家显示状态数组,全部设置为勾选状态 playerDrawEnabled.fill(true); // 重置特效选项数组,全部设置为勾选状态 effectDrawEnabled.fill(true); // 保存重置后的设置 saveSettings(); // 更新颜色选择器和预览 // 获取玩家和颜色可展开内容中的所有颜色选择器元素 const colorInputs = playerColorContent.querySelectorAll('input[type="color"]'); // 获取玩家和颜色可展开内容中的所有颜色预览元素 const previews = playerColorContent.querySelectorAll('div:last-child'); // 遍历颜色选择器和预览元素,更新其值和背景颜色 colorInputs.forEach((input, index) => { input.value = initialLineColor[index]; previews[index].style.backgroundColor = initialLineColor[index]; }); // 更新特效选项的勾选状态 // 获取所有效果设置部分的勾选框 const effectCheckboxes = effectCustomContent.querySelectorAll('input[type="checkbox"]'); // 遍历所有勾选框,将其设置为勾选状态 effectCheckboxes.forEach(checkbox => { checkbox.checked = true; // 如果这是子菜单项,确保它是启用的 if (checkbox.disabled) { checkbox.disabled = false; } }); // 更新玩家勾选框状态 const playerCheckboxes = playerColorContent.querySelectorAll('input[type="checkbox"]'); playerCheckboxes.forEach(checkbox => { checkbox.checked = true; }); //关闭菜单后再打开新的菜单 document.body.removeChild(popup); //点击customColorButton customColorButton.click(); }); // 创建保存按钮元素 const closeButton = document.createElement('button'); // 设置保存按钮的显示文本 closeButton.textContent = '保存'; // 设置保存按钮的背景颜色 closeButton.style.backgroundColor = '#2196F3'; // 设置保存按钮的文本颜色 closeButton.style.color = 'white'; // 设置保存按钮无边框 closeButton.style.border = 'none'; // 设置保存按钮的边框圆角 closeButton.style.borderRadius = '4px'; // 设置保存按钮的内边距 closeButton.style.padding = '8px 15px'; // 设置保存按钮的鼠标指针样式 closeButton.style.cursor = 'pointer'; // 为保存按钮添加点击事件监听器,点击时保存设置并关闭弹出窗口 closeButton.addEventListener('click', () => { saveSettings(); document.body.removeChild(popup); }); // 创建按钮容器元素 const buttonContainer = document.createElement('div'); // 设置按钮容器元素的顶部外边距 buttonContainer.style.marginTop = '20px'; // 设置按钮容器元素的显示方式为弹性布局 buttonContainer.style.display = 'flex'; // 设置按钮容器元素内子元素的水平靠右对齐 buttonContainer.style.justifyContent = 'flex-end'; // 将重置按钮添加到按钮容器元素中 buttonContainer.appendChild(resetButton); // 将保存按钮添加到按钮容器元素中 buttonContainer.appendChild(closeButton); // 将按钮容器元素添加到弹出窗口中 popup.appendChild(buttonContainer); // 将弹出窗口添加到文档主体中 document.body.appendChild(popup); }); // 标记自定义颜色设置按钮已添加 isCustomColorButtonAdded = true; // 输出提示信息,表示自定义颜色按钮已成功添加 console.log('自定义颜色按钮已成功添加。'); } // 循环检查按钮是否创建成功 function checkAndCreateButton() { const created = createCustomColorButton(); if (!created) { setTimeout(checkAndCreateButton, 500); // 每 500 毫秒检查一次 } } // 修改初始化函数,添加对自定义颜色按钮的调用 function init() { console.log('初始化函数已调用。'); // 先加载设置 readSettings(); // 劫持 WebSocket 消息,以便处理战斗相关的消息 hookWS(); // 添加网页可见性变化监听器,当网页从后台恢复时进行清理操作 addVisibilityChangeListener(); // 创建动画样式,用于攻击路径的闪烁效果和目标震动效果 createAnimationStyle(); // 调用循环检查函数 checkAndCreateButton(); } // 创建动画样式,包括路径闪烁和目标震动效果 function createAnimationStyle() { // console.log('动画样式函数已调用。'); 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() { // console.log('劫持函数已调用。'); 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}`; } // 为目标元素的第三个父级元素添加震动效果,根据第五个父级元素决定震动方向 function shakeTarget(element) { if (!element || isPaused) return; // 检查震动特效是否启用 if (!effectDrawEnabled[4]) 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; // 检查玩家是否被勾选,如果未勾选则不绘制 if (!playerDrawEnabled[index]) return; 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(); // 根据reversed参数决定目标元素 const targetElem = reversed ? startElem : endElem; // 存储需要在结束时触发击中特效的位置 const effectPosition = { x: 0, y: 0 }; let path = null; let text = null; let pathLength = 0; let hasTextOrPath = false; // 只有当线条绘制特效启用时才创建路径 if (effectDrawEnabled[1]) { hasTextOrPath = true; 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'; } 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)); pathLength = path.getTotalLength(); path.style.strokeDasharray = pathLength; path.style.strokeDashoffset = pathLength; // 计算路径终点位置,用于后续触发击中特效 const endPoint = path.getPointAtLength(pathLength); effectPosition.x = endPoint.x; effectPosition.y = endPoint.y; frag.appendChild(path); } // 只有当伤害数字特效启用时才创建文本 if (effectDrawEnabled[0]) { hasTextOrPath = true; 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); } // 如果没有任何可视化效果但仍然需要处理伤害,直接触发震动和击中特效 if (!hasTextOrPath) { // 获取目标元素的位置用于粒子效果 const targetCenter = getElementCenter(targetElem); effectPosition.x = targetCenter.x; effectPosition.y = targetCenter.y; // 独立触发震动和击中特效 if (effectDrawEnabled[3]) { createParticleEffect(effectPosition.x, effectPosition.y, lineColor[index]); } if (effectDrawEnabled[4]) { shakeTarget(targetElem); } return; } svg.appendChild(frag); // 如果创建了路径,设置路径动画 if (path) { setTimeout(() => { requestAnimationFrame(() => { path.style.transition = 'stroke-dashoffset 1s linear'; path.style.strokeDashoffset = '0'; }); }, 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(); // 如果没有启用伤害数字特效,在路径移除后触发击中特效和震动 if (!text) { if (effectDrawEnabled[3]) { createParticleEffect(effectPosition.x, effectPosition.y, lineColor[index]); } if (effectDrawEnabled[4]) { shakeTarget(targetElem); } } }; path.addEventListener('transitionend', removePath, { once: true }); }); }, 900); } // 如果创建了文本,设置文本动画 if (text) { // 如果同时有路径和文本,让文本沿着路径移动 if (path) { setTimeout(() => { requestAnimationFrame(() => { animateText(path, text, pathLength, lineColor[index], targetElem); }); }, 100); } else { // 如果只有文本没有路径,创建一个虚拟路径用于文本动画 const virtualPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); virtualPath.setAttribute('d', createParabolaPath(startElem, endElem, reversed)); const virtualPathLength = virtualPath.getTotalLength(); // 计算虚拟路径终点位置,用于后续触发击中特效 const endPoint = virtualPath.getPointAtLength(virtualPathLength); effectPosition.x = endPoint.x; effectPosition.y = endPoint.y; // 直接设置文本动画,不添加虚拟路径到DOM animateText(virtualPath, text, virtualPathLength, lineColor[index], targetElem); } } } // 从对象池获取粒子元素 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; // 击中特效的启用状态已在调用前检查,不需要再检查 // if (!effectDrawEnabled[3]) 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 实现更流畅的动画 /** * 执行文本动画,让文本沿着指定路径移动,并在移动过程中产生粒子效果。 * * @param {SVGPathElement} path - 文本移动的路径元素。 * @param {SVGTextElement} text - 要进行动画的文本元素。 * @param {number} pathLength - 路径的总长度。 * @param {string} color - 文本和粒子的颜色。 * @param {HTMLElement} targetElem - 目标元素,用于震动效果。 */ function animateText(path, text, pathLength, color, targetElem) { // 动画配置对象,包含动画持续时间、淡入开始和结束时间、粒子生成间隔 const animationConfig = { duration: 1350, // 动画总持续时间,单位为毫秒 fadeInStart: 0.0, // 淡入开始的进度比例 fadeInEnd: 0.3, // 淡入结束的进度比例 particleInterval: 3 // 粒子生成的间隔百分比 }; let startTime = null; // 动画开始的时间戳 let lastParticleFrame = 0; // 上一次生成粒子时的进度百分比 /** * 动画循环函数,使用 requestAnimationFrame 不断更新文本和粒子的状态。 * * @param {number} currentTime - 当前的时间戳。 */ function animate(currentTime) { // 如果脚本处于暂停状态,则停止动画 if (isPaused) return; // 如果动画还未开始,记录当前时间为开始时间 if (!startTime) startTime = currentTime; // 计算从动画开始到现在经过的时间 const elapsed = currentTime - startTime; // 计算动画的进度,取值范围为 0 到 1 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); } /** * 创建粒子拖尾效果 * @param {Object} point - 粒子的起始坐标,包含 x 和 y 属性 * @param {string} color - 粒子的填充颜色 */ function createParticleTrail(point, color) { // 粒子拖尾特效的启用状态已在调用前检查,不需要再检查 // if (!effectDrawEnabled[2]) return; // 从粒子对象池中获取一个粒子 const particle = getParticleFromPool(); // 设置粒子的半径 particle.setAttribute('r', '2'); // 设置粒子的填充颜色 particle.setAttribute('fill', color); // 设置粒子的 x 坐标,添加随机偏移 particle.setAttribute('cx', point.x + (Math.random() - 0.5) * 10); // 设置粒子的 y 坐标,添加随机偏移 particle.setAttribute('cy', point.y + (Math.random() - 0.5) * 10); // 设置粒子的初始透明度为 1 particle.style.opacity = 1; // 设置粒子的过渡效果 particle.style.transition = 'all 0.2s ease-out'; // 告诉浏览器哪些属性会发生变化,优化性能 particle.style.willChange = 'opacity, transform'; // 获取 SVG 容器元素 const svg = document.getElementById('svg-container'); // 将粒子添加到 SVG 容器中 svg.appendChild(particle); // 在下一帧请求动画,开始粒子的淡出效果 requestAnimationFrame(() => { particle.style.opacity = 0; // 监听粒子过渡结束事件,过渡结束后将粒子放回对象池 particle.addEventListener('transitionend', () => { returnParticleToPool(particle); }, { once: true }); }); } // 如果动画进度小于 1,继续请求下一帧动画 if (progress < 1) { requestAnimationFrame(animate); // 只在粒子拖尾特效启用时才创建粒子 if (effectDrawEnabled[2]) { // 调用粒子拖尾效果函数,传入粒子的起始坐标和颜色 createParticleTrail(point, color); } } // 动画进度达到 1,执行结束动画 else { // 设置文本的过渡效果 text.style.transition = 'all 0.2s ease-out'; // 放大文本 text.style.transform = 'scale(1.5)'; // 让文本透明 text.style.opacity = 0; // 保存文本最终位置,用于后续触发击中特效 const finalX = text.getAttribute('x'); const finalY = text.getAttribute('y'); // 延迟 100 毫秒后执行后续操作 setTimeout(() => { // 移除文本元素 text.remove(); // 独立触发击中特效和震动 if (effectDrawEnabled[3]) { // 在文本的位置创建粒子效果 createParticleEffect(finalX, finalY, color); } if (effectDrawEnabled[4]) { // 触发震动效果 shakeTarget(targetElem); } }, 100); } } // 启动动画循环 requestAnimationFrame(animate); } // 创建线条动画,根据攻击信息创建攻击路径和伤害数字动画 /** * 创建从一个角色到另一个角色的攻击线条,并触发相应的特效。 * * @param {number} from - 攻击发起者的索引。 * @param {number} to - 攻击目标的索引。 * @param {number} hpDiff - 伤害值,即生命值的差值。 * @param {boolean} [reversed=false] - 指示攻击是否是反向的,默认为 false。 */ 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'); // 如果 SVG 容器元素不存在,则创建一个新的 SVG 容器 if (!svgContainer) { // 定义 SVG 的命名空间 const svgNS = 'http://www.w3.org/2000/svg'; // 创建 SVG 元素 svgContainer = document.createElementNS(svgNS, '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 属性 const setViewBox = () => { // 获取窗口的宽度 const width = window.innerWidth; // 获取窗口的高度 const height = window.innerHeight; // 设置 SVG 的 viewBox 属性 svgContainer.setAttribute('viewBox', `0 0 ${width} ${height}`); }; // 首次调用 setViewBox 函数,设置 SVG 的 viewBox 属性 setViewBox(); // 设置 SVG 的 preserveAspectRatio 属性 svgContainer.setAttribute('preserveAspectRatio', 'none'); // 将 SVG 元素添加到游戏主面板中 gamePanel.appendChild(svgContainer); // 如果窗口大小改变监听器还未添加,则添加该监听器 if (!isResizeListenerAdded) { // 监听窗口大小改变事件,当窗口大小改变时调用 setViewBox 函数 window.addEventListener('resize', setViewBox); // 标记窗口大小改变监听器已添加 isResizeListenerAdded = true; } } // 根据攻击是否反向,确定攻击发起者的索引 const originIndex = reversed ? to : from; // 调用 createEffect 函数,创建攻击特效 createEffect(effectFrom, effectTo, hpDiff, originIndex, reversed); } // 处理伤害信息,根据新旧生命值计算伤害差值并创建动画 /** * 处理伤害数据,根据旧的生命值数组和新的实体映射,计算生命值差值,并在满足条件时创建攻击线条。 * * @param {Array} oldHPArr - 旧的生命值数组,存储每个实体的旧生命值。 * @param {Object} newMap - 新的实体映射,键为实体索引,值为包含当前生命值(cHP)的实体对象。 * @param {number} castIndex - 施法者的索引。 * @param {Array} attackerIndices - 攻击者的索引数组。 * @param {boolean} [isReverse=false] - 可选参数,指示攻击方向是否反转。 */ 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 函数创建攻击线条 createLine(attackerIndex, index, hpDiff, isReverse); } }); } else { // 如果攻击者索引数组长度为 1,直接调用 createLine 函数创建攻击线条 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(); })();