KatieQoL

Various QOL enhancements for FlatMMO

当前为 2025-07-29 提交的版本,查看 最新版本

// ==UserScript==
// @name         KatieQoL
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Various QOL enhancements for FlatMMO
// @author       Straightmale
// @match        *://flatmmo.com/play.php
// @grant        none
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
  'use strict';

  const STORAGE_KEY = 'katieqol_settings';

  let settings = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
  if (!settings.pickupNotifier) settings.pickupNotifier = { enabled: true };
  if (!settings.xpTracker) settings.xpTracker = { enabled: true, visibleSkills: {} };
  if (!settings.visuals) settings.visuals = {
    backgroundColor: 'rgba(0,0,0,0.85)',
    textColor: 'white'
  };

  const skills = [
    'melee', 'archery', 'health', 'magic', 'worship', 'mining',
    'forging', 'crafting', 'enchantment', 'fishing', 'woodcutting',
    'firemake', 'cooking', 'brewing', 'farming', 'hunting'
  ];

  skills.forEach(s => {
    if (!(s in settings.xpTracker.visibleSkills)) settings.xpTracker.visibleSkills[s] = true;
  });

  function saveSettings() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
  }

  let lastInventory = {};

  const notifierContainer = document.createElement('div');
  notifierContainer.style.position = 'fixed';
  notifierContainer.style.bottom = '60px';
  notifierContainer.style.right = '20px';
  notifierContainer.style.zIndex = '99999';
  notifierContainer.style.fontFamily = 'Arial,sans-serif';
  notifierContainer.style.fontSize = '13px';
  notifierContainer.style.color = settings.visuals.textColor;
  notifierContainer.style.maxWidth = '300px';
  notifierContainer.style.pointerEvents = 'none';
  notifierContainer.style.background = settings.visuals.backgroundColor;
  notifierContainer.style.padding = '8px';
  notifierContainer.style.borderRadius = '8px';
  notifierContainer.style.boxShadow = '0 0 15px rgba(0,0,0,0.7)';
  document.body.appendChild(notifierContainer);

  function applyVisuals() {
    notifierContainer.style.background = settings.visuals.backgroundColor;
    notifierContainer.style.color = settings.visuals.textColor;
    xpPanel.style.background = settings.visuals.backgroundColor;
    xpPanel.style.color = settings.visuals.textColor;
    settingsPanel.style.background = settings.visuals.backgroundColor;
    settingsPanel.style.color = settings.visuals.textColor;
    toggleSettingsBtn.style.background = settings.visuals.backgroundColor;
    toggleSettingsBtn.style.color = settings.visuals.textColor;
    toggleSettingsBtn.style.border = `1px solid ${settings.visuals.textColor}`;
  }

  function showPickupMessage(text) {
    if (!settings.pickupNotifier.enabled) return;
    const el = document.createElement('div');
    el.textContent = text;
    el.style.background = 'rgba(0,0,0,0.6)';
    el.style.padding = '6px 10px';
    el.style.marginTop = '4px';
    el.style.borderRadius = '6px';
    el.style.boxShadow = '0 0 6px black';
    el.style.opacity = '1';
    el.style.transition = 'opacity 1s ease';
    notifierContainer.appendChild(el);
    setTimeout(() => {
      el.style.opacity = '0';
      setTimeout(() => notifierContainer.removeChild(el), 1000);
    }, 3000);
  }

  const xpData = {};
  const now = () => Date.now();

  skills.forEach(skill => {
    xpData[skill] = { lastXP: 0, lastTimestamp: now(), lastReportedXP: 0 };
  });

  const xpTable = [
    0,9,28,66,131,228,368,557,805,1123,1519,
    2006,2596,3301,4136,5114,6251,7565,9073,10795,
    12750,14961,17450,20243,23365,26846,30714,35001,39742,44971,
    50728,57053,63988,71580,79876,88929,98793,109525,121186,133842,
    147562,162417,178485,195848,214591,234806,256589,280041,305272,332394,
    361528,392800,426345,462304,500828,542072,586205,633401,683845,737733,
    795271,856676,922177,992014,1066442,1145728,1230154,1320018,1415632,1517325,
    1625444,1740353,1862437,1992099,2129766,2275884,2430923,2595379,2769772,2954650,
    3150588,3358190,3578094,3810967,4057513,4318468,4594610,4886754,5195754,5522512,
    5867972,6233126,6619016,7026737,7457436,7912321,8392658,8899775,9435068,10000000
  ];

  function xpToLevel(xp) {
    for(let lvl = 1; lvl < xpTable.length; lvl++) {
      if(xp < xpTable[lvl]) return lvl;
    }
    return xpTable.length;
  }

  function formatTime(seconds) {
    if (seconds === Infinity) return '∞';
    if (seconds < 60) return `${Math.round(seconds)}s`;
    if (seconds < 3600) return `${Math.floor(seconds/60)}m ${Math.round(seconds%60)}s`;
    return `${Math.floor(seconds/3600)}h ${Math.floor((seconds%3600)/60)}m`;
  }

  const xpPanel = document.createElement('div');
  xpPanel.style.position = 'fixed';
  xpPanel.style.top = '60px';
  xpPanel.style.right = '20px';
  xpPanel.style.width = '380px';
  xpPanel.style.maxHeight = '400px';
  xpPanel.style.overflowY = 'auto';
  xpPanel.style.background = settings.visuals.backgroundColor;
  xpPanel.style.color = settings.visuals.textColor;
  xpPanel.style.padding = '10px';
  xpPanel.style.fontFamily = 'Arial,sans-serif';
  xpPanel.style.fontSize = '13px';
  xpPanel.style.borderRadius = '8px';
  xpPanel.style.zIndex = '99999';
  xpPanel.style.userSelect = 'none';
  xpPanel.style.boxShadow = '0 0 15px rgba(0,0,0,0.7)';
  document.body.appendChild(xpPanel);

  function formatNumber(num) {
    return num.toLocaleString(undefined, {maximumFractionDigits: 2});
  }

  function updateXPDisplay() {
    if (!settings.xpTracker.enabled) {
      xpPanel.style.display = 'none';
      return;
    }
    xpPanel.style.display = 'block';

    const nowTime = now();
    let html = '<table style="width:100%; border-collapse: collapse;">';
    html += `<thead><tr><th style="text-align:left; padding:2px 6px;">Skill</th><th style="text-align:right; padding:2px 6px;">XP/min</th><th style="text-align:right; padding:2px 6px;">XP/hour</th><th style="text-align:right; padding:2px 6px;">Time to Next Level</th></tr></thead><tbody>`;
    skills.forEach(skill => {
      if (!settings.xpTracker.visibleSkills[skill]) return;
      const data = xpData[skill];
      const elapsedMs = nowTime - data.lastTimestamp;
      if (elapsedMs <= 0) return;
      const xpGained = data.lastXP - data.lastReportedXP;
      const xpPerMs = xpGained / elapsedMs;
      const xpPerMin = xpPerMs * 60 * 1000;
      const xpPerHour = xpPerMin * 60;

      const currentLevel = xpToLevel(data.lastXP);
      const nextLevelXP = xpTable[currentLevel] ?? xpTable[xpTable.length-1];
      const xpNeeded = nextLevelXP - data.lastXP;

      let timeRemaining = Infinity;
      if (xpPerMs > 0) timeRemaining = xpNeeded / xpPerMs / 1000;

      html += `<tr>
        <td style="padding:2px 6px;">${skill.charAt(0).toUpperCase() + skill.slice(1)}</td>
        <td style="text-align:right; padding:2px 6px;">${formatNumber(xpPerMin)}</td>
        <td style="text-align:right; padding:2px 6px;">${formatNumber(xpPerHour)}</td>
        <td style="text-align:right; padding:2px 6px;">${timeRemaining === Infinity ? '∞' : formatTime(timeRemaining)}</td>
      </tr>`;

      data.lastReportedXP = data.lastXP;
      data.lastTimestamp = nowTime;
    });
    html += '</tbody></table>';
    xpPanel.innerHTML = `<h3 style="margin-top:0;margin-bottom:8px;">Skill XP Rate Tracker</h3>${html}`;
  }

  const settingsPanel = document.createElement('div');
  settingsPanel.style.position = 'fixed';
  settingsPanel.style.top = '50%';
  settingsPanel.style.left = '50%';
  settingsPanel.style.transform = 'translate(-50%, -50%)';
  settingsPanel.style.width = '420px';
  settingsPanel.style.maxHeight = '480px';
  settingsPanel.style.overflowY = 'auto';
  settingsPanel.style.background = settings.visuals.backgroundColor;
  settingsPanel.style.color = settings.visuals.textColor;
  settingsPanel.style.padding = '15px 20px';
  settingsPanel.style.fontFamily = 'Arial,sans-serif';
  settingsPanel.style.fontSize = '14px';
  settingsPanel.style.borderRadius = '12px';
  settingsPanel.style.zIndex = '100000';
  settingsPanel.style.boxShadow = '0 0 25px rgba(0,0,0,0.8)';
  settingsPanel.style.display = 'none';
  document.body.appendChild(settingsPanel);

  const toggleSettingsBtn = document.createElement('button');
  toggleSettingsBtn.textContent = '⚙';
  toggleSettingsBtn.style.position = 'fixed';
  toggleSettingsBtn.style.bottom = '20px';
  toggleSettingsBtn.style.left = '50%';
  toggleSettingsBtn.style.transform = 'translateX(-50%)';
  toggleSettingsBtn.style.zIndex = '100001';
  toggleSettingsBtn.style.padding = '8px 16px';
  toggleSettingsBtn.style.fontFamily = 'Arial,sans-serif';
  toggleSettingsBtn.style.cursor = 'pointer';
  toggleSettingsBtn.style.borderRadius = '8px';
  toggleSettingsBtn.style.border = `1px solid ${settings.visuals.textColor}`;
  toggleSettingsBtn.style.background = settings.visuals.backgroundColor;
  toggleSettingsBtn.style.color = settings.visuals.textColor;
  document.body.appendChild(toggleSettingsBtn);

  toggleSettingsBtn.addEventListener('click', () => {
    settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
  });

  function renderSettingsUI() {
    settingsPanel.innerHTML = `
      <h3 style="margin-top:0;margin-bottom:8px;">Item Pickup Notifier</h3>
      <label style="display:block; margin-bottom:6px; cursor:pointer;">
        <input type="checkbox" id="pickupEnabled" ${settings.pickupNotifier.enabled ? 'checked' : ''}>
        Enabled
      </label>

      <hr style="border:1px solid rgba(255,255,255,0.2); margin:12px 0;">

      <h3 style="margin-top:0;margin-bottom:8px;">XP Tracker</h3>
      <label style="display:block; margin-bottom:6px; cursor:pointer;">
        <input type="checkbox" id="xpEnabled" ${settings.xpTracker.enabled ? 'checked' : ''}>
        Enabled
      </label>
      <p style="margin:8px 0 4px 0;">Visible Skills:</p>
      <div id="skillsToggles" style="max-height:160px; overflow-y:auto; border:1px solid rgba(255,255,255,0.2); padding:4px; border-radius:4px;">
      </div>

      <hr style="border:1px solid rgba(255,255,255,0.2); margin:12px 0;">

      <h3 style="margin-top:0;margin-bottom:8px;">Visual Customization</h3>
      <label style="display:block; margin-bottom:6px;">
        Background Color:
        <input type="color" id="backgroundColorPicker" value="${rgbToHex(settings.visuals.backgroundColor)}" style="margin-left:10px;">
      </label>
      <label style="display:block; margin-bottom:6px;">
        Text Color:
        <input type="color" id="textColorPicker" value="${rgbToHex(settings.visuals.textColor)}" style="margin-left:10px;">
      </label>
    `;

    const skillsContainer = document.getElementById('skillsToggles');
    skillsContainer.innerHTML = '';

    skills.forEach(skill => {
      const checked = settings.xpTracker.visibleSkills[skill];
      const label = document.createElement('label');
      label.style.display = 'block';
      label.style.cursor = 'pointer';
      label.style.userSelect = 'none';
      label.innerHTML = `<input type="checkbox" data-skill="${skill}" ${checked ? 'checked' : ''} style="margin-right:6px;">${skill.charAt(0).toUpperCase() + skill.slice(1)}`;
      skillsContainer.appendChild(label);
    });

    document.getElementById('pickupEnabled').onchange = e => {
      settings.pickupNotifier.enabled = e.target.checked;
      saveSettings();
    };

    document.getElementById('xpEnabled').onchange = e => {
      settings.xpTracker.enabled = e.target.checked;
      saveSettings();
      updateXPDisplay();
    };

    skillsContainer.querySelectorAll('input[type=checkbox]').forEach(cb => {
      cb.onchange = e => {
        const skill = e.target.dataset.skill;
        settings.xpTracker.visibleSkills[skill] = e.target.checked;
        saveSettings();
        updateXPDisplay();
      };
    });

    document.getElementById('backgroundColorPicker').oninput = e => {
      settings.visuals.backgroundColor = e.target.value;
      saveSettings();
      applyVisuals();
    };

    document.getElementById('textColorPicker').oninput = e => {
      settings.visuals.textColor = e.target.value;
      saveSettings();
      applyVisuals();
    };
  }
  renderSettingsUI();

  function rgbToHex(rgb) {
    // Convert rgba or rgb string like 'rgba(0,0,0,0.85)' or 'rgb(255,255,255)' to hex
    if (rgb.startsWith('#')) return rgb;
    const match = rgb.match(/\d+/g);
    if (!match) return '#000000';
    const r = parseInt(match[0]);
    const g = parseInt(match[1]);
    const b = parseInt(match[2]);
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
  }

  const OriginalWebSocket = window.WebSocket;
  window.WebSocket = function(...args) {
    const ws = new OriginalWebSocket(...args);
    ws.addEventListener('message', (event) => {
      const data = event.data;
      if (typeof data !== 'string') return;

      if (data.startsWith('SET_INVENTORY_ITEMS=')) {
        if (!settings.pickupNotifier.enabled) return;

        const msg = data.substring('SET_INVENTORY_ITEMS='.length);
        const parts = msg.split('~');

        const inventory = {};
        for (let i = 0; i < parts.length; i += 4) {
          const itemId = parts[i];
          const count = parseInt(parts[i+1], 10) || 0;
          inventory[itemId] = (inventory[itemId] || 0) + count;
        }

        for (const [itemId, count] of Object.entries(inventory)) {
          const oldCount = lastInventory[itemId] || 0;
          if (count > oldCount) {
            showPickupMessage(`+${count - oldCount} ${itemId.replace(/_/g, ' ')}`);
          }
        }

        lastInventory = inventory;
      }

      if (data.startsWith('REFRESH_VAR=')) {
        if (!settings.xpTracker.enabled) return;

        const msg = data.substring('REFRESH_VAR='.length);
        const parts = msg.split('~');
        if (parts.length >= 3) {
          let skillXpKey = parts[1];
          const xpValue = Number(parts[2]);
          if (skills.includes(skillXpKey.replace('_xp',''))) {
            const skill = skillXpKey.replace('_xp','');
            const data = xpData[skill];
            if (xpValue > data.lastXP) data.lastXP = xpValue;
          }
        }
      }
    });
    return ws;
  };

  setInterval(updateXPDisplay, 5000);
  makeDraggable(xpPanel);
  makeDraggable(settingsPanel);
  makeDraggable(notifierContainer);

  applyVisuals();





function makeDraggable(el, handleSelector = null) {
  let isDragging = false;
  let offsetX = 0;
  let offsetY = 0;

  const handle = handleSelector ? el.querySelector(handleSelector) : el;
  if (!handle) return;

  handle.style.cursor = 'move';
  handle.addEventListener('mousedown', (e) => {
    isDragging = true;
    offsetX = e.clientX - el.getBoundingClientRect().left;
    offsetY = e.clientY - el.getBoundingClientRect().top;
    document.body.style.userSelect = 'none';
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    el.style.left = (e.clientX - offsetX) + 'px';
    el.style.top = (e.clientY - offsetY) + 'px';
    el.style.right = 'auto';
    el.style.bottom = 'auto';
    el.style.position = 'fixed';
  });

  document.addEventListener('mouseup', () => {
    isDragging = false;
    document.body.style.userSelect = '';
  });
}

})();