// ==UserScript==
// @name MWI-Hit-Tracker-change
// @namespace http://tampermonkey.net/
// @version 1.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';
// 存储怪物当前的生命值
let monstersHP = [];
// 存储怪物当前的魔法值
let monstersMP = [];
// 存储玩家当前的生命值
let playersHP = [];
// 存储玩家当前的魔法值
let playersMP = [];
// 调用 hookWS 函数,用于劫持 WebSocket 消息
hookWS();
// 创建 lineFlash 动画样式,用于路径闪烁效果
const style = document.createElement('style');
style.textContent = `
@keyframes lineFlash {
0% {
stroke-opacity: 1; // 起始时路径的透明度为 1
}
50% {
stroke-opacity: 0.3; // 中间时路径的透明度为 0.3
}
100% {
stroke-opacity: 1; // 结束时路径的透明度恢复为 1
}
}
`;
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 = hookedGet;
// 重新定义 MessageEvent 原型上的 data 属性
Object.defineProperty(MessageEvent.prototype, "data", dataProperty);
// 自定义的 data 属性 getter 函数
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);
}
// 使用原始的 getter 函数获取消息数据
const message = oriGet.call(this);
// 重新定义 data 属性,防止循环调用
Object.defineProperty(this, "data", { value: message });
// 调用 handleMessage 函数处理消息
return handleMessage(message);
}
}
// 计算元素中心点坐标的函数
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) {
// 初始化线条的宽度
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 = 20;
// 根据伤害值计算字号的增量
const fontSizeIncrement = Math.floor(hpDiff / 100);
// 计算最终的字号
const fontSize = baseFontSize + fontSizeIncrement;
// 设置伤害数字元素的字号
text.setAttribute('font-size', fontSize);
// 设置伤害数字元素的填充颜色
text.setAttribute('fill', lineColor[index]);
// 初始时伤害数字元素完全透明
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 (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);
// 当前帧序号加 1
currentFrame++;
// 递归调用 animateText 函数,在指定的帧间隔时间后执行
setTimeout(animateText, frameDuration);
} else {
// 当动画结束后,开始执行魔法击中的消失动画
text.style.transition = 'all 0.3s 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]);
}, 300);
}
};
// 调用动画函数开始执行伤害数字动画
animateText();
}
// 创建粒子特效的函数
function createParticleEffect(x, y, color) {
// 获取 SVG 容器元素
const svg = document.getElementById('svg-container');
// 定义粒子的数量
const numParticles = 25;
// 循环创建粒子
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 = () => {
// 从 SVG 容器中移除粒子元素
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
}
// 移除过渡结束事件的监听器
particle.removeEventListener('transitionend', removeParticle);
};
// 监听粒子的过渡结束事件,触发移除元素的函数
particle.addEventListener('transitionend', removeParticle);
});
}
}
// 标记是否已经添加了窗口大小改变的监听器
let isResizeListenerAdded = false;
// 创建线条动画的函数
function createLine(from, to, hpDiff, reversed = false) {
// 获取玩家区域的容器元素
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) {
// 将 JSON 字符串解析为 JavaScript 对象
let obj = JSON.parse(message);
// 如果消息类型是新战斗开始
if (obj && obj.type === "new_battle") {
// 初始化怪物的生命值数组
monstersHP = obj.monsters.map((monster) => monster.currentHitpoints);
// 初始化怪物的魔法值数组
monstersMP = obj.monsters.map((monster) => monster.currentManapoints);
// 初始化玩家的生命值数组
playersHP = obj.players.map((player) => player.currentHitpoints);
// 初始化玩家的魔法值数组
playersMP = obj.players.map((player) => player.currentManapoints);
} else if (obj && obj.type === "battle_updated" && 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 < monstersMP[monsterIndex]) {
castMonster = monsterIndex;
}
// 更新怪物的当前魔法值
monstersMP[monsterIndex] = mMap[monsterIndex].cMP;
});
// 标记释放技能的玩家索引
let castPlayer = -1;
// 遍历玩家索引
playerIndices.forEach((userIndex) => {
// 如果玩家的当前魔法值小于之前记录的魔法值,标记该玩家为释放技能的玩家
if (pMap[userIndex].cMP < playersMP[userIndex]) {
castPlayer = userIndex;
}
// 更新玩家的当前魔法值
playersMP[userIndex] = pMap[userIndex].cMP;
});
// 遍历怪物的生命值数组
monstersHP.forEach((mHP, mIndex) => {
// 获取当前怪物的信息
const monster = mMap[mIndex];
// 如果怪物信息存在
if (monster) {
// 计算怪物失去的生命值
const hpDiff = mHP - monster.cHP;
// 更新怪物的当前生命值
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);
}
}
}
});
// 遍历玩家的生命值数组
playersHP.forEach((pHP, pIndex) => {
// 获取当前玩家的信息
const player = pMap[pIndex];
// 如果玩家信息存在
if (player) {
// 计算玩家失去的生命值
const hpDiff = pHP - player.cHP;
// 更新玩家的当前生命值
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;
}
})();