您需要先安装一款用户样式管理器扩展(如 Stylus)后才能安装此样式。
您需要先安装一款用户样式管理器扩展(如 Stylus)后才能安装此样式。
您需要先安装一款用户样式管理器扩展(如 Stylus)后才能安装此样式。
您需要先安装一款用户样式管理器扩展后才能安装此样式。
您需要先安装一款用户样式管理器扩展后才能安装此样式。
您需要先安装一款用户样式管理器扩展后才能安装此样式。
(我已经安装了用户样式管理器,让我安装!)
// ==UserScript==
// @name Milkyway Idle - Current Loot Tracker
// @namespace https://milkywayidle.com/
// @version 1.8
// @description Tracks loot with overlay, group combat loot tabs, visual feedback, CSV export, clear log, persistent layout
// @match https://www.milkywayidle.com/*
// @grant none
// @run-at document-start
// @license MIT
// @inject-into page
// ==/UserScript==
(function () {
const playerLootData = {};
const previousLootCounts = {};
const lootSnapshot = {};
const lastBattleLoot = {};
let myPlayerName = null;
let activePlayer = null;
let selfTabSelected = false;
let isMinimized = localStorage.getItem("lootOverlayMinimized") === "true";
let overlayReady = false;
let hasSnapshot = false;
function detectPlayerName() {
const nameDiv = document.querySelector(
".CharacterName_name__1amXp[data-name]"
);
if (nameDiv) {
myPlayerName = nameDiv.dataset.name;
} else {
setTimeout(detectPlayerName, 500);
}
}
function createOverlay() {
if (overlayReady || document.getElementById("lootOverlay")) return;
overlayReady = true;
const panel = document.createElement("div");
panel.id = "lootOverlay";
panel.style.position = "fixed";
panel.style.top = localStorage.getItem("lootOverlayTop") || "100px";
panel.style.left = localStorage.getItem("lootOverlayLeft") || "20px";
panel.style.width = "260px";
panel.style.background = "rgba(30, 30, 30, 0.95)";
panel.style.color = "#fff";
panel.style.fontFamily = "monospace";
panel.style.fontSize = "13px";
panel.style.border = "1px solid #555";
panel.style.borderRadius = "8px";
panel.style.zIndex = 99999;
panel.style.userSelect = "none";
panel.style.boxShadow = "0 4px 10px rgba(0,0,0,0.4)";
panel.innerHTML = `
<div id="lootHeader" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:rgba(20,20,20,0.85);border-bottom:1px solid #333;border-radius:8px 8px 0 0;cursor:move;">
<span style="font-weight:bold;">📦 Current Loot</span>
<div>
<button id="lootExportBtn" data-tooltip="Export loot as CSV" style="background:none;border:none;color:#aaa;cursor:pointer;margin-right:6px;">CSV</button>
<button id="lootClearBtn" data-tooltip="Clear all loot history" style="background:none;border:none;color:#aaa;cursor:pointer;margin-right:6px;">⟳</button>
<button id="lootMinBtn" data-tooltip="Minimize/Expand" style="background:none;border:none;color:#aaa;cursor:pointer;">${
isMinimized ? "+" : "−"
}</button>
</div>
</div>
<div id="lootTabs" style="display:flex;padding:4px 10px;gap:6px;border-bottom:1px solid #333;background:rgba(24,24,24,0.8);"></div>
<div id="lootTotals" style="padding:10px;display:${
isMinimized ? "none" : "block"
};max-height:400px;overflow-y:auto;"></div>
`;
document.body.appendChild(panel);
const style = document.createElement("style");
style.textContent = `
#lootOverlay button:hover::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
background: #222;
color: #fff;
padding: 4px 8px;
font-size: 11px;
border-radius: 4px;
white-space: nowrap;
opacity: 0.9;
pointer-events: none;
z-index: 100000;
margin-top: 4px;
}
#lootTabs button {
background: none;
border: 1px solid #444;
color: #aaa;
padding: 2px 6px;
font-family: monospace;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
}
#lootTabs button.active {
background: #4caf50;
color: #fff;
border-color: #4caf50;
}
@keyframes lootFlashText {
0% { color: #b6ffb8; }
100% { color: white; }
}
.flashLoot {
animation: lootFlashText 1.2s ease-in-out;
}
.fadeGain {
color: lime;
font-weight: bold;
font-size: 10px;
vertical-align: super;
opacity: 1;
transition: opacity 2s ease-out;
margin-left: 2px;
}
`;
document.head.appendChild(style);
document.getElementById("lootMinBtn").onclick = () => {
isMinimized = !isMinimized;
document.getElementById("lootTotals").style.display = isMinimized
? "none"
: "block";
document.getElementById("lootMinBtn").textContent = isMinimized
? "+"
: "−";
localStorage.setItem("lootOverlayMinimized", isMinimized);
};
document.getElementById("lootExportBtn").onclick = () => {
if (!activePlayer || !playerLootData[activePlayer]) return;
const csv = Object.entries(playerLootData[activePlayer])
.map(
([hrid, count]) =>
`"${hrid.replace("/items/", "").replace(/_/g, " ")}",${count}`
)
.join("\n");
navigator.clipboard.writeText(csv);
};
document.getElementById("lootClearBtn").onclick = () => {
for (const p in playerLootData) {
playerLootData[p] = {};
previousLootCounts[p] = {};
lastBattleLoot[p] = {};
}
document.getElementById("lootTabs").innerHTML = "";
document.getElementById("lootTotals").innerHTML = "";
activePlayer = null;
selfTabSelected = false;
};
let dragging = false,
offsetX,
offsetY;
document.getElementById("lootHeader").onmousedown = (e) => {
if (e.target.tagName === "BUTTON") return;
dragging = true;
offsetX = e.clientX - panel.offsetLeft;
offsetY = e.clientY - panel.offsetTop;
};
document.onmousemove = (e) => {
if (dragging) {
panel.style.left = `${e.clientX - offsetX}px`;
panel.style.top = `${e.clientY - offsetY}px`;
}
};
document.onmouseup = () => {
if (dragging) {
localStorage.setItem("lootOverlayTop", panel.style.top);
localStorage.setItem("lootOverlayLeft", panel.style.left);
}
dragging = false;
};
}
function updateLootDisplay(playerName) {
const container = document.getElementById("lootTotals");
if (!container || !playerLootData[playerName]) return;
if (!previousLootCounts[playerName]) previousLootCounts[playerName] = {};
const sorted = Object.entries(playerLootData[playerName]).sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0])
);
let html = "";
sorted.forEach(([itemHrid, count]) => {
const prev = previousLootCounts[playerName][itemHrid] || 0;
const delta = count - (lastBattleLoot[playerName]?.[itemHrid] || 0);
const flash = count > prev;
const name = itemHrid.replace("/items/", "").replace(/_/g, " ");
const gainHTML = delta > 0 ? `<span class="fadeGain">+${delta}</span>` : "";
html += `<div class="${flash ? "flashLoot" : ""}" style="color:white">• ${name} × ${count}${gainHTML}</div>`;
previousLootCounts[playerName][itemHrid] = count;
});
container.innerHTML = html;
container.style.display = "block";
document.querySelectorAll(".fadeGain").forEach((el) => {
setTimeout(() => {
el.style.opacity = "0";
}, 1000);
});
}
function switchTab(playerName) {
activePlayer = playerName;
document.querySelectorAll("#lootTabs button").forEach((btn) => {
btn.classList.toggle("active", btn.textContent === playerName);
});
updateLootDisplay(playerName);
}
function addTab(player) {
const playerName = player.name;
const lootMap = player.totalLootMap || {};
const container = document.getElementById("lootTabs");
if (!container.querySelector(`button[data-name="${playerName}"]`)) {
const btn = document.createElement("button");
btn.textContent = playerName;
btn.dataset.name = playerName;
btn.onclick = () => switchTab(playerName);
container.appendChild(btn);
}
if (!playerLootData[playerName]) playerLootData[playerName] = {};
if (!lootSnapshot[playerName]) lootSnapshot[playerName] = {};
if (!lastBattleLoot[playerName]) lastBattleLoot[playerName] = {};
for (const key in lootMap) {
const { itemHrid, count } = lootMap[key];
playerLootData[playerName][itemHrid] = count;
}
if (playerName === myPlayerName && !selfTabSelected) {
selfTabSelected = true;
switchTab(playerName);
}
}
(function injectImmediately() {
const s = document.createElement("script");
s.textContent = `
(function() {
// Track all WebSocket instances
const originalWebSocket = window.WebSocket;
window.WebSocket = new Proxy(originalWebSocket, {
construct(target, args) {
const ws = new target(...args);
// Only hook Milkyway Idle sockets
if (
ws.url.includes("api.milkywayidle.com/ws") ||
ws.url.includes("api-test.milkywayidle.com/ws")
) {
ws.addEventListener("message", (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "new_battle") {
window.dispatchEvent(new CustomEvent("LootTrackerBattle", { detail: data }));
} else if (
data.type === "new_character_action" &&
data.newCharacterActionData?.shouldClearQueue &&
data.newCharacterActionData.actionHrid?.startsWith("/actions/combat/")
) {
window.dispatchEvent(new CustomEvent("LootTrackerCombatReset"));
}
} catch {}
});
}
return ws;
}
});
})();
`;
document.documentElement.appendChild(s);
})();
window.addEventListener("LootTrackerBattle", (e) => {
const data = e.detail;
data.players.forEach((player) => {
const name = player.name;
// Save snapshot BEFORE updating loot
if (!lastBattleLoot[name]) lastBattleLoot[name] = {};
for (const key in player.totalLootMap || {}) {
const { itemHrid, count } = player.totalLootMap[key];
lastBattleLoot[name][itemHrid] = playerLootData[name]?.[itemHrid] || 0;
}
addTab(player);
if (name === activePlayer) updateLootDisplay(name);
});
});
window.addEventListener("LootTrackerCombatReset", () => {
for (const p in playerLootData) {
playerLootData[p] = {};
previousLootCounts[p] = {};
lootSnapshot[p] = {};
lastBattleLoot[p] = {};
}
hasSnapshot = false;
document.getElementById("lootTabs").innerHTML = "";
document.getElementById("lootTotals").innerHTML = "";
activePlayer = null;
selfTabSelected = false;
});
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
createOverlay();
detectPlayerName();
});
} else {
createOverlay();
detectPlayerName();
}
})();