// ==UserScript==
// @name MWI-Hit-Tracker-More-Animation
// @namespace http://tampermonkey.net/
// @version 1.8.5
// @description 战斗过程中实时显示攻击命中目标,增加了更多的特效
// @author Artintel (Artintel), Yuk111
// @license MIT
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @icon https://www.milkywayidle.com/favicon.svg
// @grant none
// ==/UserScript==
// 立即执行函数,创建独立作用域,避免全局变量污染
(function () {
'use strict';
// 状态变量,存储战斗相关信息
// monstersHP: 存储怪物的当前生命值
// monstersMP: 存储怪物的当前魔法值
// playersHP: 存储玩家的当前生命值
// playersMP: 存储玩家的当前魔法值
const battleState = {
monstersHP: [],
monstersMP: [],
playersHP: [],
playersMP: []
};
// 存储是否已添加窗口大小改变监听器
let isResizeListenerAdded = false;
// 标记脚本是否暂停
let isPaused = false;
// 粒子对象池
const particlePool = [];
// 初始化函数,用于启动脚本逻辑
function init() {
// 劫持 WebSocket 消息,以便处理战斗相关的消息
hookWS();
// 添加网页可见性变化监听器,当网页从后台恢复时进行清理操作
addVisibilityChangeListener();
// 创建动画样式,用于攻击路径的闪烁效果
createAnimationStyle();
}
// 创建 lineFlash 动画样式,使攻击路径产生闪烁效果
function createAnimationStyle() {
// 创建一个 style 元素
const style = document.createElement('style');
// 设置 style 元素的文本内容为 lineFlash 动画的定义
style.textContent = `
@keyframes lineFlash {
0% {
/* 起始时路径的透明度为 0.7 */
stroke-opacity: 0.7;
}
50% {
/* 中间时路径的透明度为 0.3 */
stroke-opacity: 0.3;
}
100% {
/* 结束时路径的透明度恢复为 0.7 */
stroke-opacity: 0.7;
}
}
`;
// 将 style 元素添加到文档的头部
document.head.appendChild(style);
}
// 劫持 WebSocket 消息,拦截并处理战斗相关的消息
function hookWS() {
// 获取 MessageEvent 原型上的 data 属性描述符
const dataProperty = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data");
// 保存原始的 data 属性的 getter 函数
const oriGet = dataProperty.get;
// 将 data 属性的 getter 函数替换为自定义的 hookedGet 函数
dataProperty.get = function hookedGet() {
// 获取当前的 WebSocket 对象
const socket = this.currentTarget;
// 如果当前对象不是 WebSocket 实例,使用原始的 getter 函数获取数据
if (!(socket instanceof WebSocket)) {
return oriGet.call(this);
}
// 如果 WebSocket 的 URL 不包含指定的 API 地址,使用原始的 getter 函数获取数据
if (socket.url.indexOf("api.milkywayidle.com/ws") <= -1 && socket.url.indexOf("api-test.milkywayidle.com/ws") <= -1) {
return oriGet.call(this);
}
// 如果脚本暂停,直接返回原始消息
if (isPaused) {
return oriGet.call(this);
}
// 使用原始的 getter 函数获取消息数据
const message = oriGet.call(this);
// 重新定义 data 属性,防止循环调用
Object.defineProperty(this, "data", { value: message });
// 调用 handleMessage 函数处理消息
return handleMessage(message);
};
// 重新定义 MessageEvent 原型上的 data 属性
Object.defineProperty(MessageEvent.prototype, "data", dataProperty);
}
// 计算元素中心点坐标
function getElementCenter(element) {
// 获取元素的边界矩形信息
const rect = element.getBoundingClientRect();
// 如果元素内文本为空,将中心点的 y 坐标设置为元素顶部
if (element.innerText.trim() === '') {
return {
x: rect.left + rect.width / 2,
y: rect.top
};
}
// 否则,将中心点的 y 坐标设置为元素垂直居中位置
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
// 创建抛物线路径,用于攻击动画的路径显示
function createParabolaPath(startElem, endElem, reversed = false) {
// 获取起始元素的中心点坐标
const start = getElementCenter(startElem);
// 获取结束元素的中心点坐标
const end = getElementCenter(endElem);
// 根据是否反转调整抛物线的弧度比例
const curveRatio = reversed ? 4 : 2.5;
// 计算抛物线的高度
const curveHeight = -Math.abs(start.x - end.x) / curveRatio;
// 计算抛物线的控制点坐标
const controlPoint = {
x: (start.x + end.x) / 2,
y: Math.min(start.y, end.y) + curveHeight
};
// 如果是反转的情况,生成反转的抛物线路径
if (reversed) {
return `M ${end.x} ${end.y} Q ${controlPoint.x} ${controlPoint.y}, ${start.x} ${start.y}`;
}
// 正常情况生成正向的抛物线路径
return `M ${start.x} ${start.y} Q ${controlPoint.x} ${controlPoint.y}, ${end.x} ${end.y}`;
}
// 定义线条颜色数组,用于不同角色的攻击线条颜色
const lineColor = [
"rgba(255, 99, 132, 1)", // 浅粉色
"rgba(54, 162, 235, 1)", // 浅蓝色
"rgba(255, 206, 86, 1)", // 浅黄色
"rgba(75, 192, 192, 1)", // 浅绿色
"rgba(153, 102, 255, 1)", // 浅紫色
"rgba(255, 159, 64, 1)", // 浅橙色
"rgba(255, 0, 0, 1)" // 敌人攻击颜色
];
// 定义滤镜颜色数组,用于线条的外发光效果颜色
const filterColor = [
"rgba(255, 99, 132, 0.8)", // 浅粉色
"rgba(54, 162, 235, 0.8)", // 浅蓝色
"rgba(255, 206, 86, 0.8)", // 浅黄色
"rgba(75, 192, 192, 0.8)", // 浅绿色
"rgba(153, 102, 255, 0.8)", // 浅紫色
"rgba(255, 159, 64, 0.8)", // 浅橙色
"rgba(255, 0, 0, 0.8)" // 敌人攻击颜色
];
// 创建动画效果,包括攻击路径和伤害数字的动画
function createEffect(startElem, endElem, hpDiff, index, reversed = false) {
// 如果脚本暂停,直接返回
if (isPaused) return;
// 根据伤害值调整线条宽度和滤镜宽度
let strokeWidth = '1px';
let filterWidth = '1px';
if (hpDiff >= 1000) {
strokeWidth = '5px';
filterWidth = '6px';
} else if (hpDiff >= 700) {
strokeWidth = '4px';
filterWidth = '5px';
} else if (hpDiff >= 500) {
strokeWidth = '3px';
filterWidth = '4px';
} else if (hpDiff >= 300) {
strokeWidth = '2px';
filterWidth = '3px';
} else if (hpDiff >= 100) {
filterWidth = '2px';
}
// 查找伤害元素用于动画起始或结束位置
if (reversed) {
const dmgDivs = startElem.querySelector('.CombatUnit_splatsContainer__2xcc0')?.querySelectorAll('div') || [];
for (const div of dmgDivs) {
if (div.innerText.trim() === '') {
startElem = div;
break;
}
}
} else {
const dmgDivs = endElem.querySelector('.CombatUnit_splatsContainer__2xcc0')?.querySelectorAll('div') || [];
for (const div of dmgDivs) {
if (div.innerText.trim() === '') {
endElem = div;
break;
}
}
}
// 获取 SVG 容器,如果不存在则创建
const svg = document.getElementById('svg-container');
const frag = document.createDocumentFragment();
// 创建 SVG 路径元素
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
// 如果是反转情况,使用敌人攻击颜色
if (reversed) index = 6;
// 设置路径的样式
Object.assign(path.style, {
stroke: lineColor[index],
strokeWidth,
fill: 'none',
strokeLinecap: 'round',
filter: `drop-shadow(0 0 ${filterWidth} ${filterColor[index]})`,
willChange: 'stroke-dashoffset, opacity',
});
// 设置路径的 d 属性,即路径的形状
path.setAttribute('d', createParabolaPath(startElem, endElem, reversed));
// 获取路径的总长度
const pathLength = path.getTotalLength();
// 设置路径的虚线样式,初始为隐藏
path.style.strokeDasharray = pathLength;
path.style.strokeDashoffset = pathLength;
// 将路径添加到文档片段中
frag.appendChild(path);
// 创建 SVG 文本元素,用于显示伤害值
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.textContent = hpDiff;
// 根据伤害值计算字体大小
const baseFontSize = 5;
const fontSize = Math.floor(200 * Math.pow(hpDiff / (20000 + hpDiff), 0.45)) - baseFontSize;
text.setAttribute('font-size', fontSize);
text.setAttribute('fill', lineColor[index]);
// 设置文本的样式
Object.assign(text.style, {
opacity: 0.7,
filter: `drop-shadow(0 0 5px ${lineColor[index]})`,
transformOrigin: 'center',
fontWeight: 'bold',
willChange: 'transform, opacity',
});
// 将文本添加到文档片段中
frag.appendChild(text);
// 将文档片段添加到 SVG 容器中
svg.appendChild(frag);
// 延迟 100ms 后开始路径动画
setTimeout(() => {
requestAnimationFrame(() => {
path.style.transition = 'stroke-dashoffset 1s linear';
path.style.strokeDashoffset = '0';
});
}, 100);
// 延迟 900ms 后开始路径消失动画
setTimeout(() => {
requestAnimationFrame(() => {
path.style.transition = 'stroke-dashoffset 1s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 1s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
path.style.strokeDashoffset = -pathLength;
path.style.opacity = 0;
// 路径动画结束后移除路径元素
const removePath = () => {
path.remove();
};
path.addEventListener('transitionend', removePath, { once: true });
});
}, 900);
// 动画帧数和总时长
const numFrames = 90;
const totalDuration = 1350;
const frameDuration = totalDuration / numFrames;
let currentFrame = 0;
let lastTime = performance.now();
// 文本动画函数
function animateText(now = performance.now()) {
// 如果脚本暂停,直接返回
if (isPaused) return;
// 达到帧间隔时间,更新文本位置和透明度
if (now - lastTime >= frameDuration) {
const point = path.getPointAtLength((currentFrame / numFrames) * pathLength);
text.setAttribute('x', point.x);
text.setAttribute('y', point.y);
text.style.opacity = 0.7 + 0.3 * (currentFrame / numFrames);
// 每 3 帧创建一个粒子效果(减少创建频率)
if (currentFrame % 3 === 0) {
const particle = getParticleFromPool();
particle.setAttribute('r', '2');
particle.setAttribute('fill', lineColor[index]);
particle.setAttribute('cx', point.x + (Math.random() - 0.3) * 10);
particle.setAttribute('cy', point.y + (Math.random() - 0.3) * 10);
particle.style.opacity = 1;
particle.style.transition = 'all 0.2s ease-out';
particle.style.willChange = 'opacity, transform';
svg.appendChild(particle);
requestAnimationFrame(() => {
particle.style.opacity = 0;
particle.addEventListener('transitionend', () => {
returnParticleToPool(particle);
}, { once: true });
});
}
// 帧数加 1,更新时间
currentFrame++;
lastTime = now;
}
// 帧数未达到总帧数,继续动画
if (currentFrame < numFrames) {
requestAnimationFrame(animateText);
} else {
// 动画结束,缩放并隐藏文本
text.style.transition = 'all 0.2s ease-out';
text.style.transform = 'scale(1.5)';
text.style.opacity = 0;
// 延迟 200ms 后移除文本并创建粒子效果
setTimeout(() => {
text.remove();
createParticleEffect(text.getAttribute('x'), text.getAttribute('y'), lineColor[index]);
}, 200);
}
}
// 开始文本动画
requestAnimationFrame(animateText);
// 延迟 5000ms 后移除路径和文本元素
setTimeout(() => {
path.remove();
text.remove();
}, 5000);
}
// 从对象池获取粒子元素
function getParticleFromPool() {
if (particlePool.length > 0) {
return particlePool.pop();
}
return document.createElementNS("http://www.w3.org/2000/svg", "circle");
}
// 将粒子元素返回对象池
function returnParticleToPool(particle) {
particle.removeAttribute('r');
particle.removeAttribute('fill');
particle.removeAttribute('cx');
particle.removeAttribute('cy');
particle.style.opacity = 1;
particle.style.transform = 'none';
particle.removeEventListener('transitionend', () => {});
particlePool.push(particle);
}
// 创建粒子特效,在伤害数字消失时显示
function createParticleEffect(x, y, color) {
// 如果脚本暂停,直接返回
if (isPaused) return;
// 获取 SVG 容器
const svg = document.getElementById('svg-container');
const numParticles = 20;
const frag = document.createDocumentFragment(); // 批量插入用
// 分批创建粒子
const batchSize = 5;
let batchCount = 0;
function createBatch() {
for (let i = 0; i < batchSize && batchCount * batchSize + i < numParticles; i++) {
const particle = getParticleFromPool();
particle.setAttribute('r', '2');
particle.setAttribute('fill', color);
particle.setAttribute('cx', x);
particle.setAttribute('cy', y);
particle.style.opacity = 1;
particle.style.transformOrigin = 'center';
particle.style.willChange = 'transform, opacity'; // GPU 优化
// 计算粒子的结束位置
const angle = ((batchCount * batchSize + i) / numParticles) * 2 * Math.PI;
const distance = Math.random() * 30 + 10;
const endX = parseFloat(x) + distance * Math.cos(angle);
const endY = parseFloat(y) + distance * Math.sin(angle);
// 将粒子添加到文档片段中
frag.appendChild(particle);
// 开始粒子动画
requestAnimationFrame(() => {
particle.style.transition = 'all 0.3s ease-out';
particle.setAttribute('cx', endX);
particle.setAttribute('cy', endY);
particle.style.opacity = 0;
// 粒子动画结束后移除粒子元素
particle.addEventListener('transitionend', () => {
returnParticleToPool(particle);
}, { once: true });
// 兜底:5秒后强制移除
setTimeout(() => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
returnParticleToPool(particle);
}
}, 5000);
});
}
batchCount++;
if (batchCount * batchSize < numParticles) {
setTimeout(createBatch, 50); // 分批创建,减轻 JavaScript 负担
} else {
// 将文档片段添加到 SVG 容器中
svg.appendChild(frag); // 一次性插入所有粒子
}
}
createBatch();
}
// 创建线条动画,根据攻击信息创建攻击路径和伤害数字动画
function createLine(from, to, hpDiff, reversed = false) {
// 如果脚本暂停,直接返回
if (isPaused) return;
// 获取玩家区域、怪物区域和游戏面板元素
const playerArea = document.querySelector(".BattlePanel_playersArea__vvwlB");
const monsterArea = document.querySelector(".BattlePanel_monstersArea__2dzrY");
const gamePanel = document.querySelector(".GamePage_mainPanel__2njyb");
// 如果元素不存在,直接返回
if (!playerArea || !monsterArea || !gamePanel) return;
// 获取玩家容器和怪物容器
const playersContainer = playerArea.firstElementChild;
const monsterContainer = monsterArea.firstElementChild;
// 获取攻击起始元素和结束元素
const effectFrom = playersContainer?.children[from];
const effectTo = monsterContainer?.children[to];
// 如果元素不存在,直接返回
if (!effectFrom || !effectTo) return;
// 获取 SVG 容器,如果不存在则创建
let svgContainer = document.getElementById('svg-container');
if (!svgContainer) {
const svgNS = 'http://www.w3.org/2000/svg';
svgContainer = document.createElementNS(svgNS, 'svg');
svgContainer.id = 'svg-container';
// 设置 SVG 容器的样式
Object.assign(svgContainer.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
zIndex: '190'
});
// 设置 SVG 容器的 viewBox 属性
const setViewBox = () => {
const width = window.innerWidth;
const height = window.innerHeight;
svgContainer.setAttribute('viewBox', `0 0 ${width} ${height}`);
};
setViewBox();
svgContainer.setAttribute('preserveAspectRatio', 'none');
gamePanel.appendChild(svgContainer);
// 如果未添加窗口大小改变监听器,则添加
if (!isResizeListenerAdded) {
window.addEventListener('resize', setViewBox);
isResizeListenerAdded = true;
}
}
// 获取原始索引
const originIndex = reversed ? to : from;
// 创建动画效果
createEffect(effectFrom, effectTo, hpDiff, originIndex, reversed);
}
// 处理伤害信息,根据新旧生命值计算伤害差值并创建动画
function processDamage(oldHPArr, newMap, castIndex, attackerIndices, isReverse = false) {
// 遍历旧的生命值数组
oldHPArr.forEach((oldHP, index) => {
// 获取新的生命值信息
const entity = newMap[index];
// 如果新的生命值信息不存在,跳过
if (!entity) return;
// 计算生命值差值
const hpDiff = oldHP - entity.cHP;
// 更新旧的生命值数组
oldHPArr[index] = entity.cHP;
// 如果生命值差值大于 0 且攻击者索引数组不为空
if (hpDiff > 0 && attackerIndices.length > 0) {
// 如果攻击者索引数组长度大于 1
if (attackerIndices.length > 1) {
// 遍历攻击者索引数组
attackerIndices.forEach(attackerIndex => {
// 如果攻击者索引等于施法者索引
if (attackerIndex === castIndex) {
// 创建攻击线条动画
createLine(attackerIndex, index, hpDiff, isReverse);
}
});
} else {
// 创建攻击线条动画
createLine(attackerIndices[0], index, hpDiff, isReverse);
}
}
});
}
// 检测施法者,通过比较新旧魔法值找出施法者索引
function detectCaster(oldMPArr, newMap) {
let casterIndex = -1;
// 遍历新的魔法值映射
Object.keys(newMap).forEach(index => {
// 获取新的魔法值
const newMP = newMap[index].cMP;
// 如果新的魔法值小于旧的魔法值
if (newMP < oldMPArr[index]) {
// 记录施法者索引
casterIndex = index;
}
// 更新旧的魔法值数组
oldMPArr[index] = newMP;
});
return casterIndex;
}
// 处理 WebSocket 消息,根据消息类型更新战斗状态并创建攻击动画
function handleMessage(message) {
// 如果脚本暂停,直接返回原始消息
if (isPaused) {
return message;
}
let obj;
try {
// 将 JSON 字符串解析为 JavaScript 对象
obj = JSON.parse(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
return message;
}
// 如果消息类型是新战斗开始
if (obj && obj.type === "new_battle") {
console.log('Received new_battle message');
// 初始化怪物的生命值数组
battleState.monstersHP = obj.monsters.map((monster) => monster.currentHitpoints);
// 初始化怪物的魔法值数组
battleState.monstersMP = obj.monsters.map((monster) => monster.currentManapoints);
// 初始化玩家的生命值数组
battleState.playersHP = obj.players.map((player) => player.currentHitpoints);
// 初始化玩家的魔法值数组
battleState.playersMP = obj.players.map((player) => player.currentManapoints);
// 移除SVG容器内所有元素(包括路径、伤害数字、粒子拖尾和击中粒子特效)
const svg = document.getElementById('svg-container');
if (svg) {
// 递归清空所有子节点
while (svg.firstChild) {
svg.removeChild(svg.firstChild);
}
}
// 清空粒子池
particlePool.length = 0;
} else if (obj && obj.type === "battle_updated" && battleState.monstersHP.length) {
// 获取怪物信息的映射
const mMap = obj.mMap;
// 获取玩家信息的映射
const pMap = obj.pMap;
// 获取怪物的索引数组
const monsterIndices = Object.keys(obj.mMap);
// 获取玩家的索引数组
const playerIndices = Object.keys(obj.pMap);
// 标记释放技能的怪物索引
const castMonster = detectCaster(battleState.monstersMP, mMap);
// 标记释放技能的玩家索引
const castPlayer = detectCaster(battleState.playersMP, pMap);
// 遍历怪物的生命值数组
processDamage(battleState.monstersHP, mMap, castPlayer, playerIndices, false);
// 遍历玩家的生命值数组
processDamage(battleState.playersHP, pMap, castMonster, monsterIndices, true);
}
// 返回原始消息
return message;
}
// 检测网页是否从后台恢复,当网页从后台恢复时清理 SVG 容器中的元素
function addVisibilityChangeListener() {
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') {
// 网页隐藏时,暂停脚本
isPaused = true;
} else if (document.visibilityState === 'visible') {
// 网页从后台恢复时,解除暂停
isPaused = false;
// 移除SVG容器内所有元素(包括路径、伤害数字、粒子拖尾和击中粒子特效)
const svg = document.getElementById('svg-container');
if (svg) {
// 递归清空所有子节点
while (svg.firstChild) {
svg.removeChild(svg.firstChild);
}
}
// 额外清理可能残留的未被SVG容器包含的元素(防御性清理)
document.querySelectorAll('[id^="mwi-hit-tracker-"]').forEach(el => {
if (el) {
el.remove();
}
});
// 清理可能存在的粒子拖尾和击中粒子特效元素
document.querySelectorAll('circle[fill^="rgba"]').forEach(el => {
if (el.parentNode === svg) {
el.parentNode.removeChild(el);
}
});
}
});
}
// 启动初始化函数
init();
})();