Greasy Fork

Milkyway Idle - Current Loot Tracker

Tracks loot with overlay, group combat loot tabs, visual feedback, CSV export, clear log, persistent layout

目前为 2025-04-02 提交的版本。查看 最新版本

// ==UserScript==
// @name         Milkyway Idle - Current Loot Tracker
// @namespace    https://milkywayidle.com/
// @version      1.7
// @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() {
          const OriginalWebSocket = window.WebSocket;
          window.WebSocket = class extends OriginalWebSocket {
            constructor(...args) {
              super(...args);
              this.addEventListener("message", (event) => {
                try {
                  const data = JSON.parse(event.data);
                  if (data.type === "new_battle") {
                    window.dispatchEvent(new CustomEvent("LootTrackerBattle", { detail: data }));
                  }
                } catch {}
              });
              const originalSend = this.send;
              this.send = function (...sendArgs) {
                try {
                  const data = JSON.parse(sendArgs[0]);
                  if (
                    data.type === "new_character_action" &&
                    data.newCharacterActionData?.shouldClearQueue &&
                    data.newCharacterActionData.actionHrid?.startsWith("/actions/combat/")
                  ) {
                    window.dispatchEvent(new CustomEvent("LootTrackerCombatReset"));
                  }
                } catch {}
                return originalSend.apply(this, sendArgs);
              };
            }
          };
        })();
      `;
    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();
  }
})();