// ==UserScript==
// @name idlepoe战斗分析
// @namespace
// @version 1.028
// @description 点击按钮打开模态框 只能分析统计中获取的数据 切换角色, 刷新页面会丢失数据(或者你想清理数据就可以这么做)
// @description 目前功能
// @description 1. 战斗失败日志 回放最后一幕
// @description 2. 人物使用技能统计,伤害, 次数, dps
// @description 3. 玩家受到伤害统计
// @description 4. 遇怪分析, 怪物血量,护盾, 战斗时长等
// @author 群友
// @run-at document-idle
// @match *://*.idlepoe.com/*
// @match *://idlepoe.com/*
// @match *://poe.faith.wang/*
// @icon https://www.google.com/s2/favicons?domain=idlepoe.com
// @grant unsafeWindow
// @license MIT
// @namespace https://idlepoe.com
// ==/UserScript==
(async function() {
'use strict';
//获取 当前玩家token
const token = localStorage.getItem("token");
const classEmojis = {
2: "👑",
3: "🪄",
4: "🏹",
7: "🗡️",
1: "🪓",
5: "⚔️",
6: "🛡️"
};``
let playerClassEmj = "🦸♂️"; // 默认 emoji
let playerId = "xxx"; // 应该会读取真正的id
const playerInfoUrl = "https://poe.faith.wang/api/character";
//获取玩家职业
async function getPlayerInfo() {
const classElements = document.getElementsByClassName("class");
const data = await fetchPlayerInfo();
playerId = data.data.name;
let key = data.data.class;
playerClassEmj = classEmojis[key];
console.log(playerClassEmj + playerId);
}
async function fetchPlayerInfo() {
const res = await fetch(playerInfoUrl, {
headers: {
'Authorization': 'Bearer ' + token
}
});
return await res.json();
}
await getPlayerInfo();
// 统计基础类,管理 min, max, count, total
class StatTracker {
constructor() {
this.min = Infinity;
this.max = -Infinity;
this.count = 0;
this.total = 0;
}
// 更新统计数据
update(value) {
if (typeof value !== 'number' || isNaN(value)) {
// 非法值直接忽略
return;
}
this.total += value;
this.count++;
if (value < this.min) this.min = value;
if (value > this.max) this.max = value;
}
// 计算平均值
getAvg() {
return this.count === 0 ? 0 : this.total / this.count;
}
toString() {
if (this.count === 0) return "🚫 无数据";
const avg = this.total / this.count;
return `⚖️ 均值: ${formatNumberSmart(avg)} | 🔽 最小: ${formatNumberSmart(this.min)} | 🔼 最大: ${formatNumberSmart(this.max)} | 🔁 次数: ${this.count} | 🌙 累计: ${formatNumberSmart(this.total)}`;
}
}
//根据key 分类统计
class StatTrackerByKey {
constructor(keyNames = []) {
this.map = {};
this.keyNames = keyNames; // 用于 toString 排序和显示
}
update(key, value) {
// 自动初始化,如果 key 不在 keyNames 中也能统计,但 getAllStats()/toString() 不会显示
if (!this.map[key]) {
this.map[key] = new StatTracker();
}
this.map[key].update(value);
}
getStats(key) {
return this.map[key] || new StatTracker();
}
getAllStats() {
return this.keyNames.map(key => ({ key, stats: this.getStats(key) }));
}
toString() {
return this.keyNames.map(key =>
`${key}: ${this.getStats(key).toString()}`
).join('\n');
}
}
//分稀有度的
const RARITY_NORMAL = 1;
const RARITY_MAGIC = 2;
const RARITY_RARE = 3;
const RARITY_BOSS = 4;
const rarityLabels = {
[RARITY_NORMAL]: "⚪ 普通 ",
[RARITY_MAGIC]: "🔵 魔法 ",
[RARITY_RARE]: "🟡 稀有 ",
[RARITY_BOSS]: "🔴 头目 "
};
//根据稀有度分类统计
class StatTrackerByRarity extends StatTrackerByKey {
constructor() {
super([RARITY_NORMAL, RARITY_MAGIC, RARITY_RARE, RARITY_BOSS]);
}
toString() {
return this.keyNames.map(key =>
` ${rarityLabels[key]}: ${this.getStats(key).toString()}`
).join('\n');
}
}
//根据伤害类型分类统计
const DAMAGE_TYPE_INDEX_PHYSICAL = 1;
const DAMAGE_TYPE_INDEX_FIRE = 2;
const DAMAGE_TYPE_INDEX_COLD = 3;
const DAMAGE_TYPE_INDEX_LIGHTNING = 4;
const DAMAGE_TYPE_INDEX_CHAOS = 5;
const damageLabels = {
[DAMAGE_TYPE_INDEX_PHYSICAL]: "⚔️ 物理",
[DAMAGE_TYPE_INDEX_FIRE]: "🔥 火焰",
[DAMAGE_TYPE_INDEX_COLD]: "❄️ 冰霜",
[DAMAGE_TYPE_INDEX_LIGHTNING]: "⚡ 闪电",
[DAMAGE_TYPE_INDEX_CHAOS]: "☠️ 混沌"
};
class StatTrackerByType extends StatTrackerByKey {
constructor() {
super([
DAMAGE_TYPE_INDEX_PHYSICAL,
DAMAGE_TYPE_INDEX_FIRE,
DAMAGE_TYPE_INDEX_COLD,
DAMAGE_TYPE_INDEX_LIGHTNING,
DAMAGE_TYPE_INDEX_CHAOS
]);
}
toString() {
const lines = this.keyNames
.filter(key => this.getStats(key).count > 0)
.map(key =>
` ${damageLabels[key]}: ${this.getStats(key).toString()}`
);
if (lines.length === 0) {
return ' 🚫 无数据';
}
return lines.join('\n');
}
}
//根据技能分类统计
class SkillStat {
constructor(name,stoneId) {
this.name = name;
this.stoneId = stoneId;
this.totalTracker = new StatTracker(); // 总伤害
this.typeTracker = new StatTrackerByType(); // 按类型的伤害
this.speedTracker = new StatTracker(); // 释放间隔
this.critCount = 0; // 暴击次数
this.nonCritCount = 0; // 非暴击次数
this.miss = 0; // 未命中次数
}
updateDamage(totalDamage = 0, damageByType = {}, isCrit = false) {
this.totalTracker.update(totalDamage);
for (const type in damageByType) {
const dmg = damageByType[type];
if (typeof dmg === "number" && !isNaN(dmg)) {
this.typeTracker.update(parseInt(type), dmg);
}
}
if (isCrit) {
this.critCount++;
} else {
this.nonCritCount++;
}
if (totalDamage === 0){
this.miss ++;
}
}
updateInterval(number,interval){
this.speedTracker.update(interval);
}
getCritRate() {
const total = this.critCount + this.nonCritCount - this.miss;
return total > 0 ? formatNumberSmart(this.critCount / total * 100) : "N/A";
}
getAverageDPS(){
let totalDamage = this.totalTracker.total;
let totalTime = this.speedTracker.total;
return formatNumberSmart(totalDamage/totalTime);
}
getAverageHitDPS(){
let averageDamage = this.totalTracker.getAvg();
let averageTime = this.speedTracker.getAvg();
return formatNumberSmart(averageDamage/averageTime);
}
getHitRate(){
let miss = this.miss;
let total = this.critCount + this.nonCritCount;
return total > 0 ? formatNumberSmart((1 - miss / total) * 100) : "N/A";
}
toString() {
return `🧪 技能: ${this.name} (🆔 ${this.stoneId}) \n
💥 伤害: ${this.totalTracker.toString()} \n
🗡️ 单体击中DPS: ${this.getAverageHitDPS()} | ⚔️ 实际释放DPS: ${this.getAverageDPS()} | ⏱️ 平均释放间隔: ${this.speedTracker.count > 0 ? formatNumberSmart(this.speedTracker.getAvg()) + " 秒" : "无"} \n
🧿 命中率: ${this.getHitRate()}% | 💫 暴击率: ${this.getCritRate()}% | 🎯 暴击: ${this.critCount} 次 | 🟢 普通命中: ${this.nonCritCount - this.miss} 次 | ❌ 未命中: ${this.miss} 次 \n
📊 按类型伤害:\n${this.typeTracker.toString()}`;
}
}
// 记录上条json时间 主要为了计算技能释放间隔, 在前面的init部分初始化
let lastTime = 0;
// 最近的战斗记录
let lastBattle = {};
//失败的战斗记录
let lossBattle = {};
//总战斗次数 好像没啥用
let totalBattles = 0;
//是否是连接后的第一场战斗 (去除不完整部分)
let firstBattle = true;
// 处理 battle searching 的时间统计
const searchTime = new StatTracker();
let totalWins = 0; // 胜利总次数
let totalLosses = 0; // 失败总次数
//战斗时间数据统计
const battleTimeStat = new StatTracker();
// 战斗时长记录 根据稀有度分类
const battleTimeStatByRarity = new StatTrackerByRarity();
//经验值数据统计
const expStats = new StatTracker();
// 经验值分稀有度(好像没办法获取单个怪物经验)
// 敌人数 生命值 es 统计
const enemyCount = new StatTracker();
const enemyHp = new StatTracker();
const enemyEs = new StatTracker();
// 敌人数按稀有度分类
const enemyCountByRarity = new StatTrackerByRarity();
const enemyHpByRarity = new StatTrackerByRarity();
const enemyEsByRarity = new StatTrackerByRarity();
const healStatsBySkill = {}; // 治疗技能统计
const playerSkillStats = {}; // 所有玩家技能
const enemySkillStats = {}; //敌人所有技能
const totalDamageByType = new StatTrackerByType(); // 所有技能总的按类型统计
const takenDamageByType = new StatTrackerByType(); // 受到伤害按类型统计
const absorbedDamage = new StatTracker();
let extraSearchingTime = 5000;
function connectSSEWithAuth({url, token, onMessage, onError, retryInterval = 3000}) {
let controller = null;
let isStopped = false;
let heartbeatTimer = null;
let lastMessageTime = Date.now();
const heartbeatInterval = 5000;
const maxSilentTime = 5000;
let hasConnectedOnce = false; // ✅ 新增:是否曾经成功连接过
let hasConnectedThisTime = false; // ✅ 当前连接是否已成功
function clearHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function start() {
controller = new AbortController();
lastMessageTime = Date.now();
hasConnectedThisTime = false; // ✅ 重置本轮连接状态
fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "text/event-stream"
},
signal: controller.signal
}).then(response => {
if (response.status === 401) {
myLog.error("❌ ERROR: 授权失败(401 Unauthorized)");
onError?.(new Error("Unauthorized"));
updateBtnStatus("disconnected");
return;
}
if (!response.ok) {
myLog.error(`❌ ERROR: HTTP状态错误 ${response.status}`);
onError?.(new Error(`Connection failed: ${response.status}`));
updateBtnStatus("disconnected");
retry();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
function read() {
reader.read().then(({done, value}) => {
if (done) {
myLog.warn("⚠️ DISCONNECTED (auto): 服务器主动关闭连接,准备重连...");
updateBtnStatus("disconnected");
clearHeartbeat();
retry();
return;
}
const text = decoder.decode(value, {stream: true});
lastMessageTime = Date.now();
// ✅ 第一次收到消息,认为连接已成功
if (!hasConnectedThisTime) {
hasConnectedThisTime = true;
if (!hasConnectedOnce) {
myLog.log("✅ 连接成功, 可点击统计分析获取数据");
} else {
myLog.log("🔁 重连成功");
}
hasConnectedOnce = true;
updateBtnStatus("connected");
}
onMessage?.({event: "message", data: text.trim()});
read(); // 继续监听
}).catch(err => {
if (err.name === 'AbortError') return;
myLog.error(`❌ ERROR: 数据读取失败: ${err.message}`);
onError?.(err);
clearHeartbeat();
retry();
});
}
// 启动静默检测
clearHeartbeat();
heartbeatTimer = setInterval(() => {
const silentFor = Date.now() - lastMessageTime;
if (silentFor > maxSilentTime + extraSearchingTime) {
myLog.warn(`⚠️ ${silentFor / 1000}s 未收到消息,尝试重连...`);
controller?.abort(); // 触发 catch 或 retry
clearHeartbeat();
retry();
}
}, heartbeatInterval);
read();
}).catch(err => {
myLog.error(`❌ ERROR: 请求连接失败 - ${err.message}`);
onError?.(err);
clearHeartbeat();
retry();
});
}
function retry() {
if (isStopped) return;
myLog.log(`🔁 ${retryInterval / 1000}s 后重试连接...`);
updateBtnStatus("connecting");
setTimeout(start, retryInterval);
}
start();
return {
stop() {
isStopped = true;
controller?.abort();
clearHeartbeat();
updateBtnStatus("disconnected");
myLog.log("🛑 DISCONNECTED (manual): 手动断开连接");
}
};
}
let sseConnection = null;
function startSSE() {
if (sseConnection) {
myLog.warn("⚠️ 已经建立连接,无需重复连接!");
return;
}
myLog.log("🔌 正在尝试连接战斗数据服务中,请稍候...");
updateBtnStatus("connecting");
firstBattle = true;
const timeoutMs = 10_000;
let hasConnected = false;
const timeoutId = setTimeout(() => {
if (!hasConnected) {
myLog.error(`⏱️ 连接超时!${timeoutMs / 1000} 秒未收到响应`);
stopSSE(); // 确保断开连接
}
}, timeoutMs);
sseConnection = connectSSEWithAuth({
url: "https://poe.faith.wang/api/battle/sse",
token,
onMessage: ({event, data}) => {
if (!hasConnected) {
hasConnected = true;
clearTimeout(timeoutId);
// ✅ 不再重复 updateBtnStatus,这个已在 connectSSEWithAuth 中处理
}
try {
const {dataList} = parseSSEChunk(data);
dataList.forEach(json => analyzeBattleType(json));
} catch (e) {
myLog.error(`❌ 单条数据解析错误:${e}`);
}
},
onError: (err) => {
myLog.error(`[${new Date().toLocaleString()}] ❌ SSE连接失败:\n`, err);
updateBtnStatus("disconnected");
}
});
}
function stopSSE() {
if (sseConnection) {
sseConnection.stop();
sseConnection = null;
myLog.log("🛑 已断开连接");
updateBtnStatus("disconnected");
} else {
myLog.warn("⚠️ 当前没有活动连接");
}
}
//ssechunk 解析工具
function parseSSEChunk(chunkText) {
const lines = chunkText.split("\n");
let event = "message"; // 默认事件名
let dataLines = [];
for (const line of lines) {
if (line.startsWith("event:")) {
event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trim());
}
// 忽略其他行(如id:、retry:)
}
const dataStr = dataLines.join("\n");
const parsedObjects = extractJSONObjects(dataStr);
return {event, dataList: parsedObjects, rawData: dataStr};
}
//处理可能包含的多个json对象
function extractJSONObjects(text) {
const objects = [];
let depth = 0;
let start = -1;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') {
if (depth === 0) start = i;
depth++;
} else if (text[i] === '}') {
depth--;
if (depth === 0 && start !== -1) {
const jsonStr = text.slice(start, i + 1);
try {
objects.push(JSON.parse(jsonStr));
} catch (e) {
myLog.warn("⚠️ JSON 解析失败:", jsonStr);
}
start = -1;
}
}
}
return objects;
}
/**
* 根据传入的战斗相关 JSON 数据类型,调用对应的分析函数。
* 用于区分:寻路、初始化、战斗事件、结算等不同阶段的数据。
*
* @param {Object} json - 一条战斗相关的数据记录(来源于日志)
* 可包含字段:
* - battleId : 标识战斗阶段(没有时代表寻路)
* - isWin : 战斗结算标志(true/false)
* - targets : 战斗过程中的目标数据(攻击对象等)
*/
function analyzeBattleType(json) {
//全部的log日志
/* if (json.battleId === undefined){
if (!first){
myLog.groupEnd()
}else {
first = !first;
}
myLog.group("寻路中, 准备战斗")
}
myLog.log(json)*/
// return;
// 配置参数
const MAX_LOSS_BATTLE_RECORDS = 20; // 最多保存的失败战斗记录数
const MAX_LAST_BATTLE_TIME = 6 * 60 * 1000; // 6分钟未结算的战斗自动清理
// 定时清理函数
function setupBattleCleanup() {
// 每5分钟清理一次未结算的战斗记录
setInterval(() => {
const now = Date.now();
for (const battleId in lastBattle) {
const battle = lastBattle[battleId];
// 清理超过6分钟未结算的战斗
if (now - battle.startTime > MAX_LAST_BATTLE_TIME) {
myLog.warn(`自动清理战斗详情: ${battleId}`);
delete lastBattle[battleId];
}
}
}, MAX_LAST_BATTLE_TIME); // 6分钟检查一次
// 每30分钟清理一次旧失败记录
setInterval(() => {
cleanupLossBattle();
}, 30 * 60 * 1000); // 30分钟清理一次
}
// 清理旧失败记录
function cleanupLossBattle() {
const keys = Object.keys(lossBattle);
if (keys.length > MAX_LOSS_BATTLE_RECORDS) {
// 按时间排序(最早的在前面)
keys.sort((a, b) => lossBattle[a].endTime - lossBattle[b].endTime);
// 计算需要删除的数量
const deleteCount = keys.length - MAX_LOSS_BATTLE_RECORDS;
for (let i = 0; i < deleteCount; i++) {
const id = keys[i];
//myLog.log(`清理旧失败记录: ${id}`);
delete lossBattle[id];
}
}
}
// 在程序初始化时调用
setupBattleCleanup();
// 修改后的核心逻辑
if (json.battleId === undefined) {
analyzeBattleSearching(json);
let searchTime = json.time;
extraSearchingTime = searchTime;
lastTime = 0;
}
else if (firstBattle) {
// 跳过处理
}
else if (json.totalTime !== undefined) {
const battleId = json.battleId;
// 战斗结算处理
analyzeBattleResult(json);
// 战斗结算处理, 统计遇怪时间
let rightTeam = lastBattle[battleId][0].rightTeam;
let rarity = new Set(rightTeam.map(one => one.rarity));
rarity.forEach(r => battleTimeStatByRarity.update(r, json.totalTime));
if (lastBattle[battleId]) {
// 添加结算事件
lastBattle[battleId].push(json);
// 记录结束时间(用于后续清理)
lastBattle[battleId].endTime = Date.now();
if (!json.isWin) {
// 记录失败战斗
myLog.group("战斗失败! 详细战斗日志:");
_analyzeLoss(lastBattle[battleId]);
myLog.groupEnd();
// 保存失败记录
lossBattle[battleId] = lastBattle[battleId];
// 记录结束时间(用于后续清理)
lossBattle[battleId].endTime = Date.now();
// 检查是否需要清理旧记录
if (Object.keys(lossBattle).length > MAX_LOSS_BATTLE_RECORDS) {
cleanupLossBattle();
}
}
}
// 无论胜败,清理战斗记录
delete lastBattle[battleId];
}
else if (json.targets !== undefined) {
// 战斗事件处理
analyzeBattleEvent(json);
const battleId = json.battleId;
if (lastBattle[battleId]) {
lastBattle[battleId].push(json);
}
}
else {
// 战斗初始化
const battleId = json.battleId;
// 清理可能存在的旧记录
if (lastBattle[battleId]) {
delete lastBattle[battleId];
}
// 创建新战斗记录
lastBattle[battleId] = [json];
// 记录开始时间(用于超时清理)
lastBattle[battleId].startTime = Date.now();
analyzeBattleInit(json);
}
}
/**
* 分析一次战斗搜索所用时间,并更新平均值、最小值和最大值
* @param {Object} json - 包含搜索时间的对象,需包含 json.time 字段
*/
function analyzeBattleSearching(json) {
searchTime.update(json.time / 1000)
// 增加总战斗数
totalBattles++;
firstBattle = false;
}
function analyzeBattleInit(json) {
//获取敌人数组
const enemies = json.rightTeam;
//更新敌人数量
enemyCount.update(enemies.length);
//新建数据 根据稀有度统计数量
let enemyCountArray = [0,0,0,0,0];
//遍历敌人, 获取其生命,es信息
enemies.forEach(enemy => {
const { hpMax, esMax, rarity } = enemy;
// 统计 ES 值(有esMax即视为存在ES)
if (esMax !== undefined) {
enemyEs.update(esMax);
enemyEsByRarity.update(rarity,esMax)
}
// 统计 HP 值
enemyHp.update(hpMax);
enemyHpByRarity.update(rarity,hpMax);
enemyCountArray[rarity] += 1;
});
//统计数量
for (let i = 0; i < enemyCountArray.length; i++) {
enemyCountByRarity.update(i,enemyCountArray[i])
}
}
// 处理 battle result(战斗结果)的时间、胜负、经验统计
/**
* 分析单场战斗的结果并更新统计信息
* @param {Object} json - 包含战斗数据的 JSON 对象
* json.isWin - 是否胜利(true/false)
* json.totalTime - 战斗时长
* json.trophy.exp - 战斗获得的经验值(数值)
*/
function analyzeBattleResult(json) {
// 判断胜负
if (json.isWin) {
totalWins++;
} else {
totalLosses++;
}
// 读取战斗时长与经验值
battleTimeStat.update(json.totalTime);
expStats.update(json.trophy?.exp ?? 0) // 安全读取 trophy.exp,默认值为 0
//todo 分析掉落
}
//分析造成伤害
function _analyzeDamage(target, skillName, stoneId = skillName) {
// 生成唯一键,避免同名技能冲突
const key = `${skillName}::${stoneId}`;
if (!playerSkillStats[key]) {
playerSkillStats[key] = new SkillStat(skillName,stoneId);
}
const isCrit = !!target.isCritical;
const totalDamage = target.totalDamage ?? 0;
const damageByType = target.damages ?? {};
playerSkillStats[key].updateDamage(totalDamage, damageByType, isCrit);
// 更新总类型统计
for (const type in damageByType) {
totalDamageByType.update(parseInt(type), damageByType[type]);
}
}
//分析玩家技能
function _analyzeSkill(targets, skillName, stoneId = skillName, interval){
// 生成唯一键,避免同名技能冲突
const key = `${skillName}::${stoneId}`;
if (!playerSkillStats[key]) {
playerSkillStats[key] = new SkillStat(skillName,stoneId);
}
playerSkillStats[key].updateInterval(targets.length, interval);
}
//分析治疗
function _analyzeHeal(target, skillName) {
if (!healStatsBySkill[skillName]) {
healStatsBySkill[skillName] = new StatTracker();
}
healStatsBySkill[skillName].update(target.totalHeal);
}
//分析承受伤害
function _analyzeTaken(target,skillName){
if (!enemySkillStats[skillName]) {
enemySkillStats[skillName] = new SkillStat(skillName);
}
const isCrit = !!target.isCritical; // 转换为布尔值,确保可靠
enemySkillStats[skillName].updateDamage(target.totalDamage, target.damages, isCrit);
//计算被吸收的伤害
if (target.absorbed !== undefined){
absorbedDamage.update(target.absorbed);
}
// 总体统计也记录
for (const type in target.damages) {
takenDamageByType.update(parseInt(type), target.damages[type]);
}
}
//分析战斗事件
function analyzeBattleEvent(json) {
//没有攻击来源的伤害
if (json.actor === undefined) {
// DOT
json.targets.forEach(target => {
if (target.name !== playerId) {
_analyzeDamage(target, target.reason);
}else if (target.damages !== undefined && target.damages !== null){
_analyzeTaken(target, target.reason);
}else {
//todo 奉献地面治疗效果等
}
});
} else if (json.actor.name === playerId) {
// 玩家造成
const skillName = json.skill.name;
const skillId = json.skill.stoneId;
_analyzeSkill(json.targets,skillName,skillId,json.time-lastTime);
lastTime = json.time;
json.targets.forEach(target => {
if (target.name === playerId) {
// 治疗自己
if (target.totalHeal !== undefined) {
_analyzeHeal(target, skillName);
}
} else {
//玩家使用伤害技能
_analyzeDamage(target, skillName,skillId);
}
});
} else {
// 敌人造成伤害
if (json.targets[0].name === playerId){
_analyzeTaken(json.targets[0],json.skill.name)
}
}
}
//展示数据
function showData() {
if (totalBattles <= 1){
myLog.log("🔮 目前数据不足, 需获取数据且等待至少一场战斗结束...");
return
}
const size = Object.keys(lossBattle).reduce((total, key) => {
return total + lossBattle[key].length;
}, 0);
if (size > 100000) {
myLog.log(`🛰️ 数据量极多,正在分析海量战斗记录 (可能需要十五分钟或更久)`);
} else if (size > 10000) {
myLog.log(`🧬 数据量较大,正在分析与渲染,喝口水缓一缓 ☕(可能需要数分钟)`);
} else if (size > 1000) {
myLog.log(`🔍 正在分析与渲染数据中, 请稍等...`);
} else if (size > 100) {
myLog.log(`⌛ 快速分析中`);
}
myLog.group(`📊 基本统计(🕒 ${new Date().toLocaleString()})=================`);
const total = totalWins + totalLosses;
const winRate = total > 0 ? (totalWins / total * 100).toFixed(2) : "N/A";
myLog.log(`${playerClassEmj} 玩家: ${playerId}`);
myLog.log(`🎖️ 总战斗场数: ${total}`);
myLog.log(`✅ 胜利: ${totalWins} 次 | ❌ 失败: ${totalLosses} 次 | 🏆 胜率: ${winRate}%`);
myLog.log(`⏳ 寻路时间: ${searchTime.toString()}`);
myLog.log(`🧭 战斗时间: ${battleTimeStat.toString()}`);
myLog.log(`⭐ 获取经验: ${expStats.toString()}`);
myLog.groupEnd();
myLog.group("死亡回放");
Object.values(lossBattle).forEach(battle => {
_analyzeLoss(battle);
});
myLog.groupEnd();
myLog.group("=== 玩家造成伤害 ============================================================================");
myLog.group("▶ 技能统计");
for (const [name, stat] of Object.entries(playerSkillStats)) {
myLog.log(stat.toString());
myLog.log("\n");
}
myLog.groupEnd();
myLog.group("▶ 造成伤害分类型统计--------------------------------------");
myLog.log(totalDamageByType.toString());
myLog.groupEnd();
myLog.groupEnd();
myLog.group("=== 玩家承受伤害 ============================================================================");
myLog.group("▶ 承受伤害分类型统计");
myLog.log(takenDamageByType.toString());
myLog.groupEnd();
myLog.groupEnd();
myLog.group("=== 敌人技能伤害 ============================================================================");
for (const [name, stat] of Object.entries(enemySkillStats)) {
myLog.log(stat.toString());
}
myLog.groupEnd();
myLog.group("=== 敌人信息统计 ============================================================================");
myLog.group("▶ 数量--------------------------------------------------");
myLog.log(enemyCountByRarity.toString());
myLog.groupEnd();
myLog.group("▶ 生命值统计(HP)-----------------------------------------");
myLog.log(enemyHpByRarity.toString());
myLog.groupEnd();
myLog.group("▶ 能量护盾统计(ES)----------------------------------------");
myLog.log(enemyEsByRarity.toString());
myLog.groupEnd();
myLog.group("▶ 战斗时长统计--------------------------------------------");
myLog.log(battleTimeStatByRarity.toString());
myLog.groupEnd();
myLog.groupEnd();
myLog.log("\n\n");
}
//死亡回放显示
function _analyzeLoss(lossJsonArray) {
myLog.array(lossJsonArray);
const finalScene = lossJsonArray[lossJsonArray.length - 2];
myLog.group(`📉 失败战斗分析 (🆔 battleId: ${finalScene.battleId})`);
myLog.logWithScroll(`🎭 最后行动角色: ${finalScene.actor?finalScene.actor.name:null}`);
myLog.logWithScroll(`🧪 使用技能: ${finalScene.skill?finalScene.skill.name:null}`);
//展示最后一击
const targets = finalScene.targets;
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
myLog.group(`🎯 技能目标: ${target.name}`);
if (target.reason !== undefined && target.reason !== null && target.reason !== ''){
myLog.logWithScroll(`❓ 原因: ${target.reason}`)
}
const isCrit = !!target.isCritical;
const critText = isCrit ? " 💥暴击" : " ❌未暴击";
if (target.damages !== undefined && target.damages !== null) {
const finalDamageStat = new StatTrackerByType();
for (const type in target.damages) {
const dmg = target.damages[type];
if (typeof dmg === "number" && !isNaN(dmg)) {
finalDamageStat.update(parseInt(type), dmg);
}
}
myLog.logWithScroll(`🧨 技能${critText},造成的伤害如下:\n${finalDamageStat.toString()}`);
} else {
myLog.logWithScroll("⚠️ 未记录伤害数据");
}
myLog.groupEnd();
}
//展示敌方队伍 rightTeam 是敌人 数组
myLog.group(`👹 敌方队伍详细信息`)
const rightTeam = finalScene.rightTeam;
rightTeam.forEach(enemy => {
const rarityEmoji = rarityLabels[enemy.rarity] || "❓ 未知";
const isDead = enemy.hp === 0;
const deathEmoji = isDead ? "🪦" : "";
const name = enemy.name || "(无名)";
const nameWithDeath = `${deathEmoji}${name}`;
// 第一行: 稀有度 + 名字
const line1 = `${rarityEmoji} ${nameWithDeath}`;
// 第二行: ES 和 HP,用 | 分隔
let line2Parts = [];
if (enemy.hpMax !== undefined) {
const hp = enemy.hp ?? 0;
const hpPct = ((hp / enemy.hpMax) * 100).toFixed(1);
line2Parts.push(`❤️ HP: ${hp} / ${enemy.hpMax} (${hpPct}%)`);
}
if (enemy.esMax !== undefined) {
const es = enemy.es ?? 0;
const esPct = ((es / enemy.esMax) * 100).toFixed(1);
line2Parts.push(`🛡️ ES: ${es} / ${enemy.esMax} (${esPct}%)`);
}
const line2 = line2Parts.join(' | ');
// 拼接两行,注意要换行
myLog.logWithScroll(` ${line1}\n\t\t\t\t\t\t${line2}`);
});
myLog.groupEnd();
myLog.logWithScroll(`⏱️ 最终战斗时间: ${finalScene.time.toFixed(3)}`);
myLog.groupEnd();
myLog.logWithScroll("\n\n")
}
/**
* 智能格式化数字:
* - 如果是整数,无小数部分,直接显示整数。
* - 如果数值 >= 10000,转为以 K(千)为单位显示。
* - 根据数值大小智能决定保留几位小数:
* < 10 → 保留3位小数
* < 100 → 保留2位小数
* < 10000 → 保留1位小数
* >= 10000 → 使用 K 格式
*/
function formatNumberSmart(value) {
// 处理非有限数字(例如 NaN、Infinity)
if (!isFinite(value)) return 'NaN';
const absVal = Math.abs(value);
// 单位换算优先级(T > B > M > K)
if (absVal >= 1e12) {
return `${formatWithPrecision(value / 1e12)}T`;
} else if (absVal >= 1e9) {
return `${formatWithPrecision(value / 1e9)}B`;
} else if (absVal >= 1e6) {
return `${formatWithPrecision(value / 1e6)}M`;
} else if (absVal >= 1e3) {
return `${formatWithPrecision(value / 1e3)}K`;
}
// 对小于 1e3 的数值,使用普通精度格式化
return formatWithPrecision(value);
/**
* 根据数值的绝对值范围决定保留的小数位数:
* <10 → 保留 3 位
* <100 → 保留 2 位
* <10000 → 保留 1 位
* ≥10000 → 已处理过,不会触发这段代码
*/
function formatWithPrecision(num) {
const absNum = Math.abs(num);
if (absNum < 10) {
return num.toFixed(3);
} else if (absNum < 100) {
return num.toFixed(2);
} else if (absNum < 1000) {
return num.toFixed(1);
} else {
// 理论上不会执行到这里(已在主函数中处理 ≥10000)
return Math.round(num).toString();
}
}
}
//todo 绘制前端样式
(() => {
const theme = localStorage.getItem("theme") ?? "dark";
let currentTheme = theme;
// ----- 样式注入 -----
function injectStyle(theme) {
const style = document.createElement('style');
style.id = 'myLogStyle';
style.textContent = `
#myLogModal {
background: ${theme === 'light' ? '#fafafa' : '#1e1e1e'};
color: ${theme === 'light' ? '#222' : '#eee'};
border: 2px solid ${theme === 'light' ? '#ccc' : '#666'};
box-shadow: 0 0 10px ${theme === 'light' ? '#ccc' : '#000'};
position: fixed;
top: 5%;
left: 5%;
width: 90%;
height: 90%;
overflow-y: auto;
font-family: Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
user-select: text;
border-radius: 6px;
padding: 10px;
display: none;
z-index: 999999;
}
#myLogModal header {
display: flex;
justify-content: flex-end;
height: 30px;
}
#myLogModal .myLog-group {
border-left: 2px solid ${theme === 'light' ? '#bbb' : '#444'};
margin-left: 16px;
padding-left: 8px;
margin-bottom: 4px;
}
#myLogModal .myLog-info { color: ${theme === 'light' ? '#007acc' : '#4fc3f7'}; }
#myLogModal .myLog-warn { color: ${theme === 'light' ? '#b28500' : '#f9a825'}; }
#myLogModal .myLog-error { color: ${theme === 'light' ? '#c62828' : '#e53935'}; }
#myLogModal .myLog-debug { color: ${theme === 'light' ? '#4a90e2' : '#90caf9'}; }
#myLogFloatingButtons {
position: sticky;
justify-content: space-between; /* 左右两侧分开 */
top: 0;
right: 0;
display: flex;
gap: 8px;
width: 100%; /* 确保撑满容器宽度 */
padding: 8px 10px;
background: ${theme === 'light' ? '#fafafa' : '#1e1e1e'};
border-bottom: 1px solid ${theme === 'light' ? '#ddd' : '#444'};
z-index: 10002;
}
.myLog-left-buttons,
.myLog-right-buttons {
display: flex;
gap: 8px;
}
#myLogFloatingButtons button {
background: transparent;
color: ${theme === 'light' ? '#333' : '#ccc'};
border: none;
padding: 6px 12px;
font-size: 14px;
border-radius: 6px;
cursor: pointer;
transition: 0.2s ease;
opacity: 0.75;
}
#myLogFloatingButtons button:hover {
opacity: 1;
background: ${theme === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.1)'};
color: ${theme === 'light' ? '#000' : '#fff'};
}
#logToggleBtn {
position: fixed;
top: 70px;
right: 210px;
padding: 8px 16px;
font-size: 14px;
border-radius: 6px;
cursor: pointer;
z-index: 9999;
background: ${theme === 'light' ? '#ddd' : '#333'};
color: ${theme === 'light' ? '#222' : '#eee'};
border: none;
}
#logToggleBtn:hover {
background: ${theme === 'light' ? '#bbb' : '#555'};
}
`;
const old = document.getElementById('myLogStyle');
if (old) old.remove();
document.head.appendChild(style);
}
// ----- DOM结构 -----
const modal = document.createElement('div');
modal.id = 'myLogModal';
modal.innerHTML = `
<div id="myLogFloatingButtons">
<div class="myLog-left-buttons">
<button id="btnAnalyzeBattle">📡 获取数据</button>
<button id="btnPrintData">📊 统计分析</button>
<button id="myLogClearBtn">🗑️ 清空面板</button>
<button id="btnTest">测试别点</button>
</div>
<div class="myLog-right-buttons">
<button id="myLogCloseBtn">❌ 关闭</button>
</div>
</div>
<div id="myLogContent"></div>
`;
document.body.appendChild(modal);
const content = modal.querySelector('#myLogContent');
const clearBtn = modal.querySelector('#myLogClearBtn');
const closeBtn = modal.querySelector('#myLogCloseBtn');
const analyzeBtn = modal.querySelector('#btnAnalyzeBattle');
const printBtn = modal.querySelector('#btnPrintData');
const btnTest = modal.querySelector("#btnTest");
analyzeBtn.id = "_my_analyzeBtn";
btnTest.style.display = "none";
// 创建按钮
const toggleButton = document.createElement("button");
toggleButton.id = "logToggleBtn";
toggleButton.textContent = "战斗分析";
toggleButton.onclick = () => {
modal.style.display = modal.style.display === 'block' ? 'none' : 'block';
modal.scrollTop = modal.scrollHeight;
};
document.body.appendChild(toggleButton);
// 动态更新状态 emoji
/* function updateSSEStatusEmoji(status) {
const emojiMap = {
connected: '🟢',
disconnected: '🔴',
connecting: '🟡',
null: ''
};
const emoji = emojiMap[status] || '';
toggleButton.textContent = `${emoji} 战斗分析`;
}*/
// ----- 按钮功能 -----
clearBtn.onclick = () => {
content.innerHTML = '';
logState.currentGroup = content;
};
closeBtn.onclick = () => modal.style.display = 'none';
analyzeBtn.onclick = () => {
if (sseConnection) {
stopSSE();
} else {
startSSE?.();
}
};
printBtn.onclick = () => {
if (typeof showData === 'function') {
showData();
} else {
myLog.warn("⚠️ showData 函数不存在");
}
};
btnTest.onclick = () => {
const keys = Object.keys(lastBattle);
if (keys.length > 0) {
_analyzeLoss(lastBattle[keys[0]])
}
};
// ----- 日志状态 -----
const logState = {
currentGroup: content,
groupStack: [],
};
let autoScroll = true;
// 绑定滚动事件,判断用户是否手动滚动离开底部
modal.addEventListener('scroll', () => {
const nearBottom = modal.scrollHeight - modal.scrollTop - modal.clientHeight < 10;
autoScroll = nearBottom;
});
// 格式化日志参数
function formatArg(arg) {
if (typeof arg === 'string') return arg;
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
function outputLog(message, type = 'info',autoScrollAllowed = true) {
const el = document.createElement('div');
el.textContent = message;
el.className = `myLog-${type}`;
logState.currentGroup.appendChild(el);
// 如果全局自动滚动开着,且当前允许自动滚动才滚动到底部
if (autoScroll && autoScrollAllowed) {
modal.scrollTop = modal.scrollHeight;
}
}
function formatArg(arg) {
if (typeof arg === 'string') return arg;
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
// ----- myLog 接口 -----
window.myLog = window.myLog = {
setTheme(newTheme) {
if (newTheme === 'dark' || newTheme === 'light') {
currentTheme = newTheme;
localStorage.setItem("theme", newTheme);
injectStyle(newTheme);
}
},
log(...args) {
const msg = args.map(formatArg).join(' ');
outputLog(msg, 'info',false);
},
logWithScroll(...args) {
const msg = args.map(formatArg).join(' ');
outputLog(msg, 'info', true); // true 表示允许自动滚动
},
warn(...args) {
const msg = args.map(formatArg).join(' ');
outputLog(msg, 'warn');
},
error(...args) {
const msg = args.map(formatArg).join(' ');
outputLog(msg, 'error');
},
debug(...args) {
const msg = args.map(formatArg).join(' ');
outputLog(msg, 'debug');
},
group(title) {
const group = document.createElement('div');
group.className = 'myLog-group';
const label = document.createElement('div');
label.textContent = title;
label.style.fontWeight = 'bold';
group.appendChild(label);
logState.currentGroup.appendChild(group);
logState.groupStack.push(logState.currentGroup);
logState.currentGroup = group;
},
groupEnd() {
if (logState.groupStack.length > 0) {
logState.currentGroup = logState.groupStack.pop();
}
},
// 新增 array 方法
array(obj) {
if (!logState.currentGroup) return;
function renderCollapsible(data, level = 0) {
const container = document.createElement('div');
container.style.marginLeft = level * 12 + 'px';
container.style.fontFamily = 'monospace';
if (typeof data !== 'object' || data === null) {
container.textContent = String(data);
return container;
}
const isArray = Array.isArray(data);
const summary = document.createElement('div');
summary.style.cursor = 'pointer';
summary.style.userSelect = 'none';
summary.style.fontWeight = 'bold';
summary.textContent = isArray ? `Array(${data.length})` : 'Object';
const content = document.createElement('div');
content.style.display = isArray ? 'none' : 'block'; // ✅ 关键修改:数组默认折叠,对象默认展开
content.style.marginLeft = '12px';
summary.onclick = () => {
content.style.display = content.style.display === 'none' ? 'block' : 'none';
};
container.appendChild(summary);
container.appendChild(content);
if (isArray) {
data.forEach((item, idx) => {
const child = renderCollapsible(item, level + 1);
const wrapper = document.createElement('div');
wrapper.style.display = 'flex';
const indexSpan = document.createElement('span');
indexSpan.textContent = idx + ': ';
indexSpan.style.fontWeight = 'normal';
indexSpan.style.marginRight = '6px';
wrapper.appendChild(indexSpan);
wrapper.appendChild(child);
content.appendChild(wrapper);
});
} else {
for (const key in data) {
if (!Object.hasOwnProperty.call(data, key)) continue;
const child = renderCollapsible(data[key], level + 1);
const wrapper = document.createElement('div');
const keySpan = document.createElement('span');
keySpan.textContent = `${key}: `;
keySpan.style.fontWeight = 'normal';
keySpan.style.marginRight = '6px';
wrapper.appendChild(keySpan);
wrapper.appendChild(child);
content.appendChild(wrapper);
}
}
return container;
}
const el = document.createElement('div');
el.className = 'myLog-info';
el.appendChild(renderCollapsible(obj));
logState.currentGroup.appendChild(el);
const modal = document.getElementById('myLogModal');
if (modal) modal.scrollTop = modal.scrollHeight;
}
};
// 初始化样式
injectStyle(currentTheme);
})();
function updateBtnStatus(status) {
_updateToggleButtonEmoji(status);
const analyzeBtn = document.getElementById("_my_analyzeBtn");
const textMap = {
connected: '🛑 停止获取',
disconnected: '📡 获取数据',
connecting: '🟡 连接中',
null: ''
}
const text = textMap[status] || '📡 获取数据';
analyzeBtn.textContent = text;
}
function _updateToggleButtonEmoji(status) {
const emojiMap = {
connected: '🟢',
disconnected: '',
connecting: '🟡',
null: ''
};
const emoji = emojiMap[status] || '';
const toggleButton = document.getElementById("logToggleBtn");
toggleButton.textContent = `${emoji} 战斗分析`;
}
})();