// ==UserScript==
// @name MWI-Hit-Tracker-More-Animation
// @namespace http://tampermonkey.net/
// @version 1.9.9
// @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';
// 状态变量,存储战斗相关信息
const battleState = {
monstersHP: [],
monstersMP: [],
playersHP: [],
playersMP: []
};
// 存储是否已添加窗口大小改变监听器
let isResizeListenerAdded = false;
// 标记脚本是否暂停
let isPaused = false;
// 粒子对象池
const particlePool = [];
// 标记按钮是否已添加
let isCustomColorButtonAdded = false;
// 颜色配置 - 使用单一来源的颜色定义
const colorConfig = {
// 定义基础颜色,这是唯一需要修改的地方
baseColors: [
"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, 0, 0, 1)" // 红色 - 敌人攻击颜色
],
// 获取线条颜色
getLineColors() {
return [...this.baseColors];
},
// 获取滤镜颜色,自动从基础颜色生成
getFilterColors() {
return this.baseColors.map(color => color.replace('1)', '0.8)'));
},
// 重置颜色数组
resetColors(lineColorArray, filterColorArray) {
lineColorArray.splice(0, lineColorArray.length, ...this.getLineColors());
filterColorArray.splice(0, filterColorArray.length, ...this.getFilterColors());
}
};
// 存储每个玩家的勾选状态,默认全部勾选
const playerDrawEnabled = new Array(6).fill(true);
// 存储特效的勾选状态,默认全勾选
// 索引含义:0-伤害数字,1-线条绘制,2-粒子拖尾,3-击中特效,4-震动特效
const effectDrawEnabled = new Array(5).fill(true);
// 定义线条颜色数组,用于不同角色的攻击线条颜色
const lineColor = colorConfig.getLineColors();
// 定义滤镜颜色数组,用于线条的外发光效果颜色
const filterColor = colorConfig.getFilterColors();
// 从 localStorage 加载保存的设置
function readSettings() {
const ls = localStorage.getItem("MWI_Hit_Tracker_Settings");
if (ls) {
try {
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);
} catch (e) {
console.error('解析基本设置失败:', e);
// 如果解析失败,使用colorConfig重置颜色
colorConfig.resetColors(lineColor, filterColor);
}
}
// 读取特效设置
const effectLs = localStorage.getItem("MWI_Hit_Tracker_Effect_Settings");
if (effectLs) {
try {
const effectLsObj = JSON.parse(effectLs);
effectDrawEnabled.splice(0, effectDrawEnabled.length, ...effectLsObj.effectDrawEnabled);
} catch (e) {
console.error('解析特效设置失败:', e);
}
}
}
// 保存设置到 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;
}
.inner-expandable-section {
transition: background-color 0.3s;
}
.inner-expandable-section:hover {
background-color: #d8d8d8;
}
.param-slider-container {
margin-bottom: 12px;
}
input[type="range"] {
height: 5px;
border-radius: 5px;
background: #d3d3d3;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #546ddb;
cursor: pointer;
}
`;
// 将样式元素添加到文档头部
document.head.appendChild(style);
// 为自定义颜色设置按钮添加点击事件监听器
customColorButton.addEventListener('click', () => {
// 检查文档中是否已存在类名为 "自定义菜单" 的元素,如果有则销毁它
const customMenu = document.querySelector('.自定义菜单');
const customMask = document.querySelector('.自定义菜单遮罩');
if (customMenu) {
if (customMask) document.body.removeChild(customMask);
document.body.removeChild(customMenu);
return;
}
// ===== 新增:创建遮罩层 =====
const mask = document.createElement('div');
mask.className = '自定义菜单遮罩';
mask.style.position = 'fixed';
mask.style.top = '0';
mask.style.left = '0';
mask.style.width = '100vw';
mask.style.height = '100vh';
mask.style.background = 'rgba(0,0,0,0)'; // 完全透明
mask.style.zIndex = '9998';
document.body.appendChild(mask);
// 创建弹出窗口元素并添加类名 "自定义菜单"
const popup = document.createElement('div');
popup.classList.add('自定义菜单');
// 设置弹出窗口的定位方式为固定定位
popup.style.position = 'fixed';
// 始终居中显示菜单
popup.style.left = `calc(50% - 200px)`; // 假设宽度约400px
popup.style.top = `calc(50% - 200px)`; // 假设高度约400px
// 设置弹出窗口的背景颜色
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';
// ====== 新增:菜单标题(风格与 MWI-Hit-Tracker-More-Animation.js 一致)======
const titleBar = document.createElement('div');
titleBar.style.position = 'absolute';
titleBar.style.top = '0';
titleBar.style.left = '0';
titleBar.style.right = '0';
titleBar.style.height = '36px';
titleBar.style.backgroundColor = '#546ddb';
titleBar.style.borderTopLeftRadius = '8px';
titleBar.style.borderTopRightRadius = '8px';
titleBar.style.cursor = 'move';
titleBar.style.userSelect = 'none';
titleBar.style.display = 'flex';
titleBar.style.alignItems = 'center';
titleBar.style.justifyContent = 'center';
titleBar.style.padding = '0 10px';
titleBar.style.color = 'white';
titleBar.style.fontWeight = 'bold';
titleBar.style.fontSize = '20px';
titleBar.textContent = customColorButton.textContent;
// 调整弹出窗口的内边距,为标题栏留出空间
popup.style.paddingTop = '46px';
// 将标题栏添加到弹出窗口
popup.insertBefore(titleBar, popup.firstChild);
// ====== 新增结束 ======
// 使弹出窗口可拖动,并支持记忆位置
let isDragging = false;
let startX, startY, initialLeft, initialTop, offsetToCenterX, offsetToCenterY;
// 判断是否为可拖动区域(标题栏或背景)
function isDraggableArea(target) {
// 标题栏或弹窗本身允许拖动
if (target.classList.contains('expandable-section') || target === popup) return true;
// 交互元素禁止拖动
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'BUTTON' || tag === 'SELECT' || tag === 'LABEL') return false;
// 滑块等自定义控件可加 class 判断
if (target.classList.contains('no-drag')) return false;
return true;
}
// 鼠标按下事件
popup.addEventListener('mousedown', (e) => {
if (!isDraggableArea(e.target)) return;
isDragging = true;
// 鼠标到菜单中心的偏移
const rect = popup.getBoundingClientRect();
offsetToCenterX = e.clientX - (rect.left + rect.width / 2);
offsetToCenterY = e.clientY - (rect.top + rect.height / 2);
startX = e.clientX;
startY = e.clientY;
initialLeft = popup.offsetLeft;
initialTop = popup.offsetTop;
document.body.style.userSelect = 'none';
});
// 鼠标移动事件
document.addEventListener('mousemove', (e) => {
if (isDragging) {
e.preventDefault();
// 以菜单中心为基准计算新位置
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
// 限制不超出窗口
const maxLeft = window.innerWidth - popup.offsetWidth;
const maxTop = window.innerHeight - popup.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
popup.style.left = newLeft + 'px';
popup.style.top = newTop + 'px';
}
});
// 鼠标释放事件
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
// 保存当前位置
localStorage.setItem('MWI_Hit_Tracker_Menu_Pos', JSON.stringify({
left: popup.offsetLeft,
top: popup.offsetTop
}));
}
});
// 鼠标离开窗口也结束拖动
document.addEventListener('mouseleave', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
localStorage.setItem('MWI_Hit_Tracker_Menu_Pos', JSON.stringify({
left: popup.offsetLeft,
top: popup.offsetTop
}));
}
});
// 封装创建可展开部分的函数
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);
});
// 添加重置按钮
const resetButton = document.createElement('button');
resetButton.textContent = '重置为默认颜色';
resetButton.style.marginTop = '15px';
resetButton.style.padding = '5px 10px';
resetButton.style.backgroundColor = '#546ddb';
resetButton.style.color = 'white';
resetButton.style.border = 'none';
resetButton.style.borderRadius = '4px';
resetButton.style.cursor = 'pointer';
resetButton.addEventListener('click', () => {
// 使用colorConfig重置颜色数组,只重置颜色相关的设置
colorConfig.resetColors(lineColor, filterColor);
// 重置玩家显示状态数组,全部设置为勾选状态
playerDrawEnabled.fill(true);
// 保存重置后的设置
saveSettings();
// 更新颜色选择器和预览
// 获取玩家和颜色可展开内容中的所有颜色选择器元素
const colorInputs = expandableContent.querySelectorAll('input[type="color"]');
// 获取玩家和颜色可展开内容中的所有颜色预览元素
const previews = expandableContent.querySelectorAll('div:last-child');
// 遍历颜色选择器和预览元素,更新其值和背景颜色
colorInputs.forEach((input, index) => {
input.value = lineColor[index];
previews[index].style.backgroundColor = lineColor[index];
});
// 更新玩家勾选框状态
const playerCheckboxes = expandableContent.querySelectorAll('input[type="checkbox"]');
playerCheckboxes.forEach(checkbox => {
checkbox.checked = true;
});
});
expandableContent.appendChild(resetButton);
}
// 生成特效自定义部分内容的函数
function generateEffectCustomContent(expandableContent) {
// 定义特效名称集合,方便后期维护(调整顺序,线条绘制移到第一位)
const effectNames = [
'线条绘制',
'伤害数字',
'击中特效',
'震动'
];
// 定义子菜单配置
const subMenuConfig = {
'伤害数字': ['粒子拖尾'] // 粒子拖尾作为伤害数字的子菜单
};
// 封装创建勾选框和标签的通用函数
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);
}
// 创建特效勾选框的函数
function createEffectCheckbox(effectName, index) {
const container = document.createElement('div');
container.style.marginBottom = '15px';
container.style.display = 'flex';
container.style.alignItems = 'center';
// 创建勾选框和标签
createCheckboxAndLabel(container, effectName, effectDrawEnabled[index], (e) => {
effectDrawEnabled[index] = e.target.checked;
saveSettings(); // 保存设置
// 同步更新参数菜单中的特效显示状态
updateEffectParamSection(index, e.target.checked);
});
return container;
}
// 更新参数菜单中特效显示状态的函数
function updateEffectParamSection(effectIndex, isEnabled) {
// 获取参数菜单的展开内容
const paramsContent = document.querySelector('.expandable-content:nth-of-type(6)');
if (!paramsContent) return;
// 查找对应特效的参数区域
const effectSection = document.getElementById(`effect-param-section-${effectIndex}`);
if (isEnabled) {
// 如果启用特效,但参数区域不存在,则重新生成参数菜单
if (!effectSection) {
// 清空参数菜单内容并重新生成
paramsContent.innerHTML = '';
generateEffectParamsContent(paramsContent);
}
} else {
// 如果禁用特效,且参数区域存在,则移除参数区域
if (effectSection) {
effectSection.remove();
}
}
}
// 创建线条绘制特效勾选框
const lineDrawContainer = createEffectCheckbox('线条绘制', 1);
expandableContent.appendChild(lineDrawContainer);
// 创建伤害数字特效勾选框
const damageNumberContainer = createEffectCheckbox('伤害数字', 0);
expandableContent.appendChild(damageNumberContainer);
// 创建粒子拖尾特效勾选框(作为伤害数字的子菜单)
const particleTrailContainer = createEffectCheckbox('粒子拖尾', 2);
particleTrailContainer.style.marginLeft = '30px'; // 缩进,表示是子菜单
expandableContent.appendChild(particleTrailContainer);
// 创建击中特效勾选框
const hitEffectContainer = createEffectCheckbox('击中特效', 3);
expandableContent.appendChild(hitEffectContainer);
// 创建震动特效勾选框
const shakeEffectContainer = createEffectCheckbox('震动', 4);
expandableContent.appendChild(shakeEffectContainer);
}
// 生成特效参数自定义部分内容的函数
function generateEffectParamsContent(expandableContent) {
// 定义特效参数配置
const effectParams = {
'击中特效': [
{ name: '粒子数量', id: 'particleCount', min: 5, max: 50, value: 20, step: 1 },
{ name: '粒子半径', id: 'particleRadius', min: 1, max: 5, value: 2, step: 0.5 },
{ name: '最大距离', id: 'maxDistance', min: 5, max: 40, value: 20, step: 1 },
{ name: '批次数量', id: 'batchCount', min: 1, max: 8, value: 4, step: 1 }
],
'粒子拖尾': [
{ name: '粒子数量', id: 'trailParticleCount', min: 3, max: 20, value: 6, step: 1 },
{ name: '持续时间(ms)', id: 'trailDuration', min: 100, max: 1000, value: 500, step: 50 },
{ name: '扩散范围', id: 'trailSpreadRange', min: 5, max: 30, value: 8, step: 1 }
],
'伤害数字': [
{ name: '显示时间(ms)', id: 'damageDisplayDuration', min: 500, max: 5000, value: 1350, step: 50 },
{ name: '动画帧数', id: 'damageFrames', min: 10, max: 120, value: 30, step: 5 },
{ name: '描边宽度', id: 'damageStrokeWidth', min: 0, max: 5, value: 1.5, step: 0.5 },
{ name: '描边颜色', id: 'damageStrokeColor', type: 'color', value: '#FFFFFF' },
{ name: '结束缩放比例', id: 'damageEndScale', min: 1, max: 3, value: 1.5, step: 0.1 },
{ name: '淡出时间(ms)', id: 'damageEndFadeDuration', min: 100, max: 1000, value: 200, step: 50 }
],
'震动': [
{ name: '震动强度', id: 'shakeIntensity', min: 1, max: 10, value: 5, step: 1 },
{ name: '震动时间(ms)', id: 'shakeDuration', min: 100, max: 500, value: 300, step: 50 }
]
};
// 从localStorage读取已保存的参数设置
const savedParams = localStorage.getItem('MWI_Hit_Tracker_Effect_Params');
let effectParamsValues = {};
if (savedParams) {
try {
effectParamsValues = JSON.parse(savedParams);
} catch (e) {
console.error('解析保存的特效参数失败:', e);
effectParamsValues = {};
}
}
// 全局参数对象,用于存储当前参数值
window.hitTrackerEffectParams = window.hitTrackerEffectParams || {};
// 创建内部可展开区域的函数
function createInnerExpandableSection(title, isEnabled, effectIndex) {
const section = document.createElement('div');
section.className = 'inner-expandable-section';
section.style.backgroundColor = '#e8e8e8';
section.style.padding = '8px';
section.style.borderRadius = '4px';
section.style.marginBottom = '10px';
section.style.cursor = 'pointer';
section.dataset.effectIndex = effectIndex;
const headerDiv = document.createElement('div');
headerDiv.style.display = 'flex';
headerDiv.style.justifyContent = 'space-between';
headerDiv.style.alignItems = 'center';
const titleSpan = document.createElement('span');
titleSpan.textContent = `${title}参数`;
titleSpan.style.fontWeight = 'bold';
const arrowSpan = document.createElement('span');
arrowSpan.textContent = '▶';
arrowSpan.style.transition = 'transform 0.3s';
headerDiv.appendChild(titleSpan);
headerDiv.appendChild(arrowSpan);
section.appendChild(headerDiv);
const content = document.createElement('div');
content.className = 'inner-expandable-content';
content.style.display = 'none';
content.style.marginTop = '10px';
content.style.paddingLeft = '10px';
section.appendChild(content);
// 点击展开/收起
section.addEventListener('click', (e) => {
// 防止点击内部控件时触发展开/收起
if (e.target.tagName === 'INPUT' ||
e.target.closest('.param-slider-container') !== null) {
return;
}
const isExpanded = content.style.display !== 'none';
content.style.display = isExpanded ? 'none' : 'block';
arrowSpan.textContent = isExpanded ? '▶' : '▼';
arrowSpan.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(90deg)';
});
return { section, content };
}
// 为每个特效创建参数调整区域
Object.keys(effectParams).forEach(effectName => {
// 检查特效是否启用
let effectEnabled = false;
let effectIndex = -1;
if (effectName === '击中特效') {
effectEnabled = effectDrawEnabled[3];
effectIndex = 3;
} else if (effectName === '粒子拖尾') {
effectEnabled = effectDrawEnabled[2];
effectIndex = 2;
} else if (effectName === '伤害数字') {
effectEnabled = effectDrawEnabled[0];
effectIndex = 0;
} else if (effectName === '震动') {
effectEnabled = effectDrawEnabled[4];
effectIndex = 4;
}
// 如果特效被禁用,则不显示该特效的参数区域
if (!effectEnabled) {
return;
}
// 创建特效参数组
const { section, content } = createInnerExpandableSection(effectName, effectEnabled, effectIndex);
section.id = `effect-param-section-${effectIndex}`;
expandableContent.appendChild(section);
// 为每个参数创建滑块控制
effectParams[effectName].forEach(param => {
// 从保存的设置中读取值,如果没有则使用默认值
const savedValue = effectParamsValues[param.id];
param.value = savedValue !== undefined ? savedValue : param.value;
// 保存到全局参数对象
window.hitTrackerEffectParams[param.id] = param.value;
// 创建参数容器
const paramContainer = document.createElement('div');
paramContainer.className = 'param-slider-container';
paramContainer.style.marginBottom = '10px';
// 创建参数名称和当前值显示
const paramLabel = document.createElement('div');
paramLabel.style.display = 'flex';
paramLabel.style.justifyContent = 'space-between';
paramLabel.style.marginBottom = '5px';
const nameSpan = document.createElement('span');
nameSpan.textContent = param.name;
paramLabel.appendChild(nameSpan);
const valueSpan = document.createElement('span');
if (param.type !== 'color') {
valueSpan.textContent = param.value;
} else {
valueSpan.textContent = '';
}
valueSpan.id = `${param.id}-value`;
paramLabel.appendChild(valueSpan);
paramContainer.appendChild(paramLabel);
// 根据参数类型创建不同的控件
if (param.type === 'color') {
// 创建颜色选择器
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = param.value;
colorInput.style.width = '100%';
colorInput.className = 'no-drag';
// 添加颜色选择器事件监听
colorInput.addEventListener('input', (e) => {
const newValue = e.target.value;
window.hitTrackerEffectParams[param.id] = newValue;
// 保存参数设置
saveEffectParams();
});
paramContainer.appendChild(colorInput);
} else {
// 创建滑块控制容器,用于布局滑块和上下箭头
const sliderControlContainer = document.createElement('div');
sliderControlContainer.style.display = 'flex';
sliderControlContainer.style.alignItems = 'center';
sliderControlContainer.style.width = '100%';
// 创建滑块
const slider = document.createElement('input');
slider.type = 'range';
slider.min = param.min;
slider.max = param.max;
slider.step = param.step;
slider.value = param.value;
slider.style.flex = '1'; // 让滑块占据主要空间
slider.style.marginRight = '10px'; // 与微调按钮保持一定距离
slider.className = 'no-drag'; // 防止拖动滑块时拖动整个菜单
// 创建微调按钮容器
const adjustButtonsContainer = document.createElement('div');
adjustButtonsContainer.style.display = 'flex';
adjustButtonsContainer.style.flexDirection = 'column';
adjustButtonsContainer.style.width = '20px';
// 创建上箭头按钮
const upButton = document.createElement('button');
upButton.innerHTML = '▲';
upButton.style.fontSize = '10px';
upButton.style.padding = '0';
upButton.style.height = '16px';
upButton.style.border = '1px solid #999';
upButton.style.borderRadius = '3px';
upButton.style.backgroundColor = '#546ddb'; // 使用脚本中已有的主题蓝色
upButton.style.color = 'white'; // 白色文字
upButton.style.cursor = 'pointer';
upButton.style.marginBottom = '2px';
upButton.style.boxShadow = '0 1px 2px rgba(0,0,0,0.1)';
upButton.className = 'no-drag';
// 添加悬停效果
upButton.addEventListener('mouseover', () => {
upButton.style.backgroundColor = '#4056a1'; // 深一点的蓝色
});
upButton.addEventListener('mouseout', () => {
upButton.style.backgroundColor = '#546ddb';
});
// 创建下箭头按钮
const downButton = document.createElement('button');
downButton.innerHTML = '▼';
downButton.style.fontSize = '10px';
downButton.style.padding = '0';
downButton.style.height = '16px';
downButton.style.border = '1px solid #999';
downButton.style.borderRadius = '3px';
downButton.style.backgroundColor = '#546ddb'; // 使用脚本中已有的主题蓝色
downButton.style.color = 'white'; // 白色文字
downButton.style.cursor = 'pointer';
downButton.style.boxShadow = '0 1px 2px rgba(0,0,0,0.1)';
downButton.className = 'no-drag';
// 添加悬停效果
downButton.addEventListener('mouseover', () => {
downButton.style.backgroundColor = '#4056a1'; // 深一点的蓝色
});
downButton.addEventListener('mouseout', () => {
downButton.style.backgroundColor = '#546ddb';
});
// 添加箭头按钮功能
function adjustValue(increment) {
// 获取当前值
let currentValue = parseFloat(slider.value);
// 根据步长调整值
let step = parseFloat(param.step);
// 计算新值
let newValue = increment ? currentValue + step : currentValue - step;
// 确保新值在范围内
newValue = Math.max(parseFloat(param.min), Math.min(parseFloat(param.max), newValue));
// 更新滑块值
slider.value = newValue;
// 更新显示值
valueSpan.textContent = newValue;
// 更新存储的参数值
window.hitTrackerEffectParams[param.id] = newValue;
// 保存参数设置
saveEffectParams();
}
// 点击上箭头增加值
upButton.addEventListener('click', (e) => {
e.stopPropagation(); // 防止触发父元素点击事件
adjustValue(true);
});
// 点击下箭头减少值
downButton.addEventListener('click', (e) => {
e.stopPropagation(); // 防止触发父元素点击事件
adjustValue(false);
});
// 添加到微调按钮容器
adjustButtonsContainer.appendChild(upButton);
adjustButtonsContainer.appendChild(downButton);
// 添加滑块事件监听
slider.addEventListener('input', (e) => {
const newValue = parseFloat(e.target.value);
valueSpan.textContent = newValue;
window.hitTrackerEffectParams[param.id] = newValue;
// 保存参数设置
saveEffectParams();
});
// 组装控件
sliderControlContainer.appendChild(slider);
sliderControlContainer.appendChild(adjustButtonsContainer);
paramContainer.appendChild(sliderControlContainer);
}
content.appendChild(paramContainer);
});
});
// 添加重置按钮
const resetButton = document.createElement('button');
resetButton.textContent = '重置为默认值';
resetButton.style.marginTop = '15px';
resetButton.style.padding = '5px 10px';
resetButton.style.backgroundColor = '#546ddb';
resetButton.style.color = 'white';
resetButton.style.border = 'none';
resetButton.style.borderRadius = '4px';
resetButton.style.cursor = 'pointer';
resetButton.addEventListener('click', () => {
// 重置所有参数为默认值
Object.keys(effectParams).forEach(effectName => {
effectParams[effectName].forEach(param => {
const defaultValue = param.defaultValue || param.value; // 使用预定义的默认值
window.hitTrackerEffectParams[param.id] = defaultValue;
// 更新UI
const valueSpan = document.getElementById(`${param.id}-value`);
if (valueSpan && param.type !== 'color') {
valueSpan.textContent = defaultValue;
}
// 更新控件值
if (param.type === 'color') {
// 更新颜色选择器
const colorInput = document.querySelector(`input[type="color"][value="${window.hitTrackerEffectParams[param.id]}"]`);
if (colorInput) colorInput.value = defaultValue;
} else {
// 更新滑块
const paramLabel = document.getElementById(`${param.id}-value`);
if (paramLabel) {
const sliderContainer = paramLabel.closest('.param-slider-container');
if (sliderContainer) {
const slider = sliderContainer.querySelector('input[type="range"]');
if (slider) slider.value = defaultValue;
}
}
}
});
});
// 保存参数设置
saveEffectParams();
});
expandableContent.appendChild(resetButton);
}
// 保存特效参数设置
function saveEffectParams() {
localStorage.setItem('MWI_Hit_Tracker_Effect_Params', JSON.stringify(window.hitTrackerEffectParams));
}
// 创建玩家和颜色可展开部分
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('特效参数', generateEffectParamsContent);
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;
}
// 使用colorConfig重置颜色数组
colorConfig.resetColors(lineColor, filterColor);
// 重置玩家显示状态数组,全部设置为勾选状态
playerDrawEnabled.fill(true);
// 重置特效选项数组,全部设置为勾选状态
effectDrawEnabled.fill(true);
// 保存重置后的设置
saveSettings();
// 清空特效参数,强制重新使用默认值
localStorage.removeItem('MWI_Hit_Tracker_Effect_Params');
window.hitTrackerEffectParams = {};
// 更新颜色选择器和预览
// 获取玩家和颜色可展开内容中的所有颜色选择器元素
const colorInputs = playerColorContent.querySelectorAll('input[type="color"]');
// 获取玩家和颜色可展开内容中的所有颜色预览元素
const previews = playerColorContent.querySelectorAll('div:last-child');
// 遍历颜色选择器和预览元素,更新其值和背景颜色
colorInputs.forEach((input, index) => {
input.value = lineColor[index];
previews[index].style.backgroundColor = lineColor[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 mask = document.querySelector('.自定义菜单遮罩');
if (mask) document.body.removeChild(mask);
});
// 创建按钮容器元素
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);
// 遮罩点击事件:保存设置并关闭菜单
mask.addEventListener('click', () => {
saveSettings();
document.body.removeChild(popup);
document.body.removeChild(mask);
});
});
// 标记自定义颜色设置按钮已添加
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;
// 获取参数,如果未设置则使用默认值
const params = window.hitTrackerEffectParams || {};
const intensity = params.shakeIntensity || 5;
const duration = params.shakeDuration || 200;
// 向上查找第三个父级元素(用于实际震动)
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;
// 根据震动强度计算位移值
const pixelIntensity = intensity * 0.4; // 将强度值转换为像素值
let transformValue = 'translate(0, 0)';
// 根据第五个父级元素的类名决定震动方向
if (className.includes('playersArea')) {
transformValue = `translate(-${pixelIntensity}px, ${pixelIntensity}px)`;
} else if (className.includes('monstersArea')) {
transformValue = `translate(${pixelIntensity}px, ${pixelIntensity}px)`;
}
// 添加震动类并设置动画
shakeElement.classList.add('mwht-shake');
// 使用自定义动画实现不同方向的震动
shakeElement.style.animation = `customShake ${duration/1000}s cubic-bezier(.36,.07,.19,.97) forwards`;
shakeElement.style.transformOrigin = 'center';
shakeElement.style.willChange = 'transform';
// 存储原始transform值,动画结束后恢复
const originalTransform = shakeElement.style.transform;
// 动画帧函数
let startTime = null;
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 = 5;
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]);
// 获取参数,如果未设置则使用默认值
const params = window.hitTrackerEffectParams || {};
const strokeWidth = params.damageStrokeWidth || 0.5;
const strokeColor = params.damageStrokeColor || '#000000';
// 添加描边效果
if (strokeWidth > 0) {
text.setAttribute('stroke', strokeColor);
text.setAttribute('stroke-width', strokeWidth);
text.setAttribute('paint-order', 'stroke fill');
}
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) {
// 获取自定义显示时间参数
const params = window.hitTrackerEffectParams || {};
const displayDuration = params.damageDisplayDuration || 1350;
// 如果同时有路径和文本,让文本沿着路径移动
if (path) {
setTimeout(() => {
requestAnimationFrame(() => {
// 传递显示时间参数给动画函数
animateText(path, text, pathLength, lineColor[index], targetElem, {
duration: displayDuration
});
});
}, 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, {
duration: displayDuration
});
}
}
}
// 从对象池获取粒子元素
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);
}
// 创建击中粒子特效,在伤害数字消失时显示
/**
* 创建粒子爆炸特效
* @param {number} x - 粒子爆炸的x坐标
* @param {number} y - 粒子爆炸的y坐标
* @param {string} color - 粒子的颜色
*/
function createParticleEffect(x, y, color) {
// 如果动画暂停则直接返回
if (isPaused) return;
// 如果击中特效未启用,则直接返回
if (!effectDrawEnabled[3]) return;
// 获取SVG容器
const svg = document.getElementById('svg-container');
if (!svg) return;
// 获取参数,如果未设置则使用默认值
const params = window.hitTrackerEffectParams || {};
const numParticles = params.particleCount || 20;
const batchCount = params.batchCount || 4;
const particleRadius = params.particleRadius || 2;
const maxDistance = params.maxDistance || 20;
// 创建文档片段用于批量添加粒子
const frag = document.createDocumentFragment();
// 计算每批创建的粒子数量(向上取整确保所有粒子都被创建)
const batchSize = Math.ceil(numParticles / batchCount);
// 批次计数器
let currentBatch = 0;
/**
* 分批创建粒子
*/
function createBatch() {
// 计算当前批次应创建的粒子起始索引和结束索引
const startIndex = currentBatch * batchSize;
const endIndex = Math.min(startIndex + batchSize, numParticles);
// 遍历创建当前批次的粒子
for (let i = startIndex; i < endIndex; i++) {
// 从对象池获取粒子
const particle = getParticleFromPool();
// 设置粒子基本属性
particle.setAttribute('r', particleRadius.toString());
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';
// 计算粒子的运动轨迹
// 随机生成0~2π之间的角度
const angle = Math.random() * 2 * Math.PI;
// 使用更强的随机数生成方式计算距离
let distance;
if (window.crypto && window.crypto.getRandomValues) {
const array = new Uint32Array(1);
window.crypto.getRandomValues(array);
distance = (array[0] / 0xffffffff) * (maxDistance - 10) + 10;
} else {
// 添加额外的随机性扰动
for (let j = 0; j < (Date.now() % 5) + 1; j++) {
Math.random();
}
distance = Math.random() * (maxDistance - 10) + 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;
// 设置安全清理定时器
setTimeout(() => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
returnParticleToPool(particle);
}
}, 500); // 减少回收时间,提高性能
});
}
// 增加批次计数
currentBatch++;
// 如果还有未创建的批次,继续创建下一批
if (currentBatch < batchCount) {
setTimeout(createBatch, 50);
} else {
// 所有粒子创建完成,将文档片段添加到SVG容器
svg.appendChild(frag);
}
}
// 开始创建第一批粒子
createBatch();
}
/**
* 创建粒子拖尾效果
* @param {number} x - 粒子起始x坐标
* @param {number} y - 粒子起始y坐标
* @param {string} color - 粒子颜色
*/
function createParticleTrail(x, y, color) {
// 如果动画暂停则直接返回
if (isPaused) return;
// 如果粒子拖尾特效未启用,则直接返回
if (!effectDrawEnabled[2]) return;
// 获取SVG容器
const svg = document.getElementById('svg-container');
if (!svg) return;
// 获取参数,如果未设置则使用默认值
const params = window.hitTrackerEffectParams || {};
const particleCount = params.trailParticleCount || 2;
const duration = params.trailDuration || 500;
const spreadRange = params.trailSpreadRange || 10;
// 创建文档片段用于批量添加粒子
const frag = document.createDocumentFragment();
// 创建指定数量的粒子
for (let i = 0; i < particleCount; i++) {
// 从对象池获取粒子或创建新粒子
let particle;
if (particlePool.length > 0) {
particle = particlePool.pop();
// 重置粒子属性
particle.style.opacity = 1;
} else {
particle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
particle.setAttribute('r', '2');
}
// 设置粒子颜色
particle.setAttribute('fill', color);
// 计算粒子的随机位置偏移
const randomSpread = Math.random() * spreadRange;
const angle = Math.random() * 2 * Math.PI;
const offsetX = randomSpread * Math.cos(angle);
const offsetY = randomSpread * Math.sin(angle);
// 设置粒子位置
particle.setAttribute('cx', parseFloat(x) + offsetX);
particle.setAttribute('cy', parseFloat(y) + offsetY);
// 添加到文档片段
frag.appendChild(particle);
// 设置粒子动画
const fadeOutDuration = duration + Math.random() * 200; // 添加一些随机性
// 使用CSS过渡动画
particle.style.transition = `opacity ${fadeOutDuration}ms ease-out`;
// 延迟一帧开始动画,确保过渡效果生效
requestAnimationFrame(() => {
particle.style.opacity = 0;
// 动画结束后回收粒子
setTimeout(() => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
particlePool.push(particle);
}
}, fadeOutDuration);
});
}
// 将所有粒子一次性添加到SVG
svg.appendChild(frag);
}
// 文本动画函数 - 使用 requestAnimationFrame 实现更流畅的动画
/**
* 执行文本动画,让文本沿着指定路径移动,并在移动过程中产生粒子效果。
*
* @param {SVGPathElement} path - 文本移动的路径元素。
* @param {SVGTextElement} text - 要进行动画的文本元素。
* @param {number} pathLength - 路径的总长度。
* @param {string} color - 文本和粒子的颜色。
* @param {HTMLElement} targetElem - 目标元素,用于震动效果。
*/
function animateText(path, text, pathLength, color, targetElem, config = {}) {
// 动画配置对象,包含动画持续时间、淡入开始和结束时间、粒子生成间隔
const animationConfig = {
duration: config.duration || 1350, // 动画总持续时间,单位为毫秒
fadeInStart: config.fadeInStart || 0.0, // 淡入开始的进度比例
fadeInEnd: config.fadeInEnd || 0.3, // 淡入结束的进度比例
particleInterval: config.particleInterval || 3, // 粒子生成的间隔百分比
frameRate: config.frameRate || 60, // 动画帧率
endScale: config.endScale || 1.5, // 结束时的缩放比例
endFadeDuration: config.endFadeDuration || 200, // 结束淡出动画持续时间
particleSize: config.particleSize || 2, // 粒子大小
particleSpread: config.particleSpread || 10 // 粒子扩散范围
};
// 获取用户在参数菜单中配置的伤害数字参数
const params = window.hitTrackerEffectParams || {};
if (params.damageDisplayDuration) {
animationConfig.duration = params.damageDisplayDuration;
}
// 添加动画结束时的缩放比例和淡出时间配置
if (params.damageEndScale) {
animationConfig.endScale = params.damageEndScale;
}
if (params.damageEndFadeDuration) {
animationConfig.endFadeDuration = params.damageEndFadeDuration;
}
let startTime = null; // 动画开始的时间戳
let lastParticleFrame = 0; // 上一次生成粒子时的进度百分比
let lastFrameTime = 0; // 上一帧的时间戳
/**
* 动画循环函数,使用 requestAnimationFrame 不断更新文本和粒子的状态。
*
* @param {number} currentTime - 当前的时间戳。
*/
function animate(currentTime) {
// 如果脚本处于暂停状态,则停止动画
if (isPaused) return;
// 如果动画还未开始,记录当前时间为开始时间
if (!startTime) startTime = currentTime;
// 计算帧间隔
const frameInterval = 1000 / animationConfig.frameRate;
// 如果距离上一帧的时间间隔小于目标帧间隔,跳过这一帧
if (currentTime - lastFrameTime < frameInterval) {
requestAnimationFrame(animate);
return;
}
// 更新上一帧时间戳
lastFrameTime = 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);
}
// 如果动画进度小于 1,继续请求下一帧动画
if (progress < 1) {
requestAnimationFrame(animate);
// 只在粒子拖尾特效启用时才创建粒子
if (effectDrawEnabled[2]) {
createParticleTrail(point.x, point.y, color);
}
}
// 动画进度达到 1,执行结束动画
else {
text.style.transition = `all ${animationConfig.endFadeDuration/1000}s ease-out`;
text.style.transform = `scale(${animationConfig.endScale})`;
text.style.opacity = 0;
const finalX = text.getAttribute('x');
const finalY = text.getAttribute('y');
setTimeout(() => {
text.remove();
if (effectDrawEnabled[3]) {
createParticleEffect(finalX, finalY, color);
}
if (effectDrawEnabled[4]) {
shakeTarget(targetElem);
}
}, animationConfig.endFadeDuration);
}
}
// 启动动画循环
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();
})();