Torn Enemy HP% + Execute UI

Enemy HP% with EXECUTE indicator; PDA-friendly; draggable/minimizable HUD

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Torn Enemy HP% + Execute UI
// @namespace    
// @version      1.4.2
// @description  Enemy HP% with EXECUTE indicator; PDA-friendly; draggable/minimizable HUD
// @author       flc
// @match        https://www.torn.com/loader.php?sid=attack*
// @match        https://www.torn.com/loader.php?*sid=attack*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const HUD_ID = 'executeHud';

  // Single-instance guard (prevents duplicate HUD / observers)
  if (window.__tornExecRunning) return;
  window.__tornExecRunning = true;

  const LS = {
    THRESH:  'torn_exec_threshold_percent_v5',
    YOURMAX: 'torn_exec_your_maxhp_v5',
    ENEMYMAX:'torn_exec_enemy_maxhp_v5',
    POS:     'torn_exec_hud_pos_v3',
    MIN:     'torn_exec_hud_minimized_v3'
  };

  const DEFAULTS = { THRESH: 20, YOURMAX: '', ENEMYMAX: '', POS: { top: 12, left: null }, MIN: false };

  const POLL_FALLBACK_MS = 500;   // backup poll
  const MIN_SCAN_INTERVAL = 200;  // throttle scans
  const FIXED_MIN_MAX = 300;      // ignore X/Y where Y < 300 (ammo, cooldowns, etc.)

  const getLS = (k, d) => { const v = localStorage.getItem(k); if (v === null) return d; try { return JSON.parse(v); } catch { return v; } };
  const setLS = (k, v) => localStorage.setItem(k, (typeof v === 'string' ? v : JSON.stringify(v)));
  const $ = (s, r = document) => r.querySelector(s);
  const visible = el => el && el.offsetParent !== null;
  const toInt = s => parseInt(String(s || '').replace(/,/g, '').trim(), 10);
  const pctStr = n => Number.isFinite(n) ? n.toFixed(1) + '%' : '—';
  const clamp = (n, min, max) => Math.max(min, Math.min(max, n));

  // ------------- execute% detect -------------
  function autoDetectExecutePercent() {
    const el = document.querySelector('[data-bonus-attachment-description*="below"][data-bonus-attachment-description*="life"]');
    if (!el) return null;
    const txt = el.getAttribute('data-bonus-attachment-description') || el.textContent || '';
    const m = txt.match(/below\s+(\d+)\s*%\s*life/i);
    return m ? parseInt(m[1], 10) : null;
  }

  // ------------- scan for X / Y life strings -------------
  function findHpCandidates() {
    const out = [];
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        const t = node.nodeValue;
        if (!t || t.length > 64) return NodeFilter.FILTER_SKIP;
        if (!/\d[\d,]*\s*\/\s*\d[\d,]*/.test(t)) return NodeFilter.FILTER_SKIP;
        const el = node.parentElement;
        if (!el || !visible(el)) return NodeFilter.FILTER_SKIP;
        if (el.closest('#' + CSS.escape(HUD_ID))) return NodeFilter.FILTER_SKIP; // ignore our HUD
        return NodeFilter.FILTER_ACCEPT;
      }
    });

    let n;
    while ((n = walker.nextNode())) {
      const el = n.parentElement;
      const m = n.nodeValue.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)/);
      if (!m) continue;
      const current = toInt(m[1]);
      const max = toInt(m[2]);
      if (!Number.isFinite(current) || !Number.isFinite(max) || max <= 0) continue;
      if (max < FIXED_MIN_MAX) continue;

      const container = el.closest('[class],[id]') || el;
      out.push({ current, max, el, container, y: (container.getBoundingClientRect().top + window.scrollY) });
    }

    const map = new Map();
    for (const c of out) {
      const key = `${c.current}/${c.max}`;
      if (!map.has(key) || c.y < map.get(key).y) map.set(key, c);
    }
    return Array.from(map.values());
  }

  
  function ensureCSS() {
    if ($('#execHudStyles4o')) return;
    const style = document.createElement('style');
    style.id = 'execHudStyles4o';
    style.textContent = `
      #${HUD_ID}{position:fixed;z-index:999999;min-width:220px;background:rgba(0,0,0,.6);color:#fff;border-radius:12px;padding:10px 12px;
        font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;line-height:1.2;box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(2px);touch-action:none}
      #${HUD_ID} .row{display:flex;align-items:center;gap:8px}
      #${HUD_ID} input,#${HUD_ID} button{background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:4px 6px;outline:none}
      #${HUD_ID} .badge{margin-left:auto;padding:2px 6px;border-radius:8px;font-size:12px;font-weight:700;background:#7a0000;opacity:.9}
      #${HUD_ID} .chip{display:inline-block;padding:3px 6px;border-radius:8px;border:1px solid rgba(255,255,255,.25);margin:2px;cursor:pointer;font-size:12px}
      #${HUD_ID} .hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px;cursor:move}
      #${HUD_ID} .title{font-weight:700}
      #${HUD_ID} .minbtn{margin-left:auto;border-radius:8px;padding:2px 6px;font-weight:700;cursor:pointer}
      #${HUD_ID}.minimized{min-width:unset;padding:6px 8px}
      #${HUD_ID}.minimized .body{display:none}
      #${HUD_ID}.minimized .badge{margin-left:6px}
      @keyframes execPulse{0%{transform:scale(1);box-shadow:0 0 0 0 rgba(0,255,160,.6)}70%{transform:scale(1.05);box-shadow:0 0 0 10px rgba(0,255,160,0)}100%{transform:scale(1);box-shadow:0 0 0 0 rgba(0,255,160,0)}}
    `;
    document.head.appendChild(style);
  }

  function buildHUD(pos, minimized) {
    // remove any stray duplicates

    document.querySelectorAll('#' + CSS.escape(HUD_ID)).forEach((el, i) => { if (i > 0) el.remove(); });

    ensureCSS();
    let hud = $('#' + HUD_ID);
    if (!hud) {
      hud = document.createElement('div');
      hud.id = HUD_ID;
      hud.innerHTML = `
        <div class="hdr" id="hud-drag">
          <div class="title">Enemy HP</div>
          <div id="hud-ready" class="badge">EXECUTE NOT READY</div>
          <button id="hud-min" class="minbtn" title="Minimize/Expand">▾</button>
        </div>
        <div class="body">
          <div id="hud-line" style="font-size:14px;margin-bottom:6px;">— / — (—%)</div>

          <div class="row" style="gap:6px;margin-bottom:6px;">
            <label style="font-size:12px;opacity:.9;">Execute%</label>
            <input id="hud-thresh" type="number" min="1" max="100" style="width:64px;">
            <button id="hud-apply">Set</button>
            <button id="hud-refresh" title="Rescan HP">↻</button>
          </div>

          <div class="row" style="gap:6px;margin-bottom:6px;">
            <label style="font-size:12px;opacity:.9;">Your max HP</label>
            <input id="hud-yourmax" type="number" min="1" style="width:96px;">
            <button id="hud-saveyou" title="Save">Save</button>
          </div>

          <div id="hud-picks" style="font-size:11px;opacity:.9;display:none;">
            Tap enemy HP if detection is wrong:
            <div id="hud-chipwrap"></div>
          </div>

          <div style="margin-top:4px;font-size:11px;opacity:.7;">enter execute% and HP manually +save , update as needed!</div>
        </div>
      `;
      document.body.appendChild(hud);
    }

    // position
    const setPos = p => {
      const vw = window.innerWidth, vh = window.innerHeight;
      let top = p.top, left = p.left;
      top = clamp(top ?? 12, 4, vh - 60);
      if (left == null) { hud.style.right = '12px'; hud.style.left = 'auto'; }
      else { hud.style.left = clamp(left, 4, vw - 120) + 'px'; hud.style.right = 'auto'; }
      hud.style.top = top + 'px';
    };
    setPos(pos);

    // minimize toggle
    function applyMin(min) {
      if (min) { hud.classList.add('minimized'); $('#hud-min', hud).textContent = '▴'; }
      else { hud.classList.remove('minimized'); $('#hud-min', hud).textContent = '▾'; }
      setLS(LS.MIN, min ? 'true' : 'false');
    }
    applyMin(minimized);
    $('#hud-min', hud).addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); applyMin(!hud.classList.contains('minimized')); });

    // drag (don’t start drag from minimize button)
    let dragging = false, startX=0, startY=0, startTop=0, startLeft=null, anchoredRight=(pos.left==null);
    const dragEl = $('#hud-drag', hud);
    const onStart = e => {
      if (e.target && (e.target.id === 'hud-min' || e.target.closest?.('#hud-min'))) return;
      dragging = true;
      const pt = (e.touches && e.touches[0]) || e;
      startX=pt.clientX; startY=pt.clientY;
      const rect = hud.getBoundingClientRect();
      startTop = rect.top;
      if (anchoredRight) { startLeft = rect.left; anchoredRight=false; hud.style.left = startLeft + 'px'; hud.style.right = 'auto'; } else startLeft = rect.left;
      e.preventDefault();
    };
    const onMove = e => {
      if (!dragging) return;
      const pt = (e.touches && e.touches[0]) || e;
      const dx = pt.clientX - startX, dy = pt.clientY - startY;
      const vw = window.innerWidth, vh = window.innerHeight;
      hud.style.top = clamp(startTop + dy, 4, vh - 60) + 'px';
      hud.style.left = clamp(startLeft + dx, 4, vw - 120) + 'px';
    };
    const onEnd = () => {
      if (!dragging) return; dragging=false;
      const rect = hud.getBoundingClientRect();
      setLS(LS.POS, { top: rect.top, left: rect.left });
    };
    dragEl.addEventListener('mousedown', onStart);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onEnd);
    dragEl.addEventListener('touchstart', onStart, { passive:false });
    window.addEventListener('touchmove', onMove, { passive:false });
    window.addEventListener('touchend', onEnd);

    return hud;
  }


  function start() {
    const sid = new URLSearchParams(location.search).get('sid') || '';
    if (!sid.startsWith('attack')) return;

    const savedPos = getLS(LS.POS, DEFAULTS.POS);
    const savedMin = (getLS(LS.MIN, DEFAULTS.MIN) === 'true');
    const hud = buildHUD(savedPos, savedMin);

    const line = $('#hud-line', hud);
    const badge = $('#hud-ready', hud);
    const inputThresh = $('#hud-thresh', hud);
    const btnApply = $('#hud-apply', hud);
    const btnRefresh = $('#hud-refresh', hud);
    const inputYourMax = $('#hud-yourmax', hud);
    const btnSaveYou = $('#hud-saveyou', hud);
    const picks = $('#hud-picks', hud);
    const chipwrap = $('#hud-chipwrap', hud);

    let threshold = parseInt(getLS(LS.THRESH, DEFAULTS.THRESH), 10);
    let yourMax = getLS(LS.YOURMAX, DEFAULTS.YOURMAX);
    let enemyMaxLock = getLS(LS.ENEMYMAX, DEFAULTS.ENEMYMAX);

    // const auto = autoDetectExecutePercent();
   // if (Number.isFinite(auto)) threshold = auto;

    inputThresh.value = String(threshold);
    inputYourMax.value = String(yourMax);

    const setReady = on => {
      if (on) { badge.textContent = 'EXECUTE READY'; badge.style.background = '#0b6'; badge.style.animation='execPulse 1s infinite'; }
      else { badge.textContent = 'EXECUTE NOT READY'; badge.style.background = '#7a0000'; badge.style.animation='none'; }
    };

    btnApply.addEventListener('click', () => {
      const v = parseInt(inputThresh.value, 10);
      if (Number.isFinite(v) && v > 0 && v <= 100) { threshold = v; setLS(LS.THRESH, v); }
      else inputThresh.value = String(threshold);
      scheduleScan(true);
    });

    btnSaveYou.addEventListener('click', () => {
      const v = parseInt(inputYourMax.value, 10);
      if (Number.isFinite(v) && v > 0) {
        yourMax = v; setLS(LS.YOURMAX, v);
        if (String(enemyMaxLock) === String(yourMax)) { enemyMaxLock = ''; setLS(LS.ENEMYMAX, ''); }
        scheduleScan(true);
      }
    });

    btnRefresh.addEventListener('click', () => scheduleScan(true));

    function renderChips(cands) {
      chipwrap.innerHTML = '';
      const filtered = cands.filter(c => String(c.max) !== String(yourMax));
      if (filtered.length <= 1) { picks.style.display = 'none'; return; }
      picks.style.display = 'block';
      for (const c of filtered) {
        const chip = document.createElement('span');
        chip.className = 'chip';
        chip.textContent = `${c.current.toLocaleString()}/${c.max.toLocaleString()}`;
        if (String(c.max) === String(enemyMaxLock)) chip.style.borderColor = '#0b6';
        chip.addEventListener('click', () => {
          enemyMaxLock = c.max; setLS(LS.ENEMYMAX, enemyMaxLock); scheduleScan(true);
        });
        chipwrap.appendChild(chip);
      }
    }

    function chooseEnemy(cands) {
      let candidates = cands.filter(c => String(c.max) !== String(yourMax));
      if (enemyMaxLock) {
        const locked = candidates.find(c => String(c.max) === String(enemyMaxLock));
        if (locked) return locked;
      }
      if (candidates.length) candidates = candidates.sort((a,b) => b.max - a.max);
      return candidates[0] || null;
    }

    function updateHUD(enemy) {
      if (!enemy) { line.textContent = '— / — (—%)'; setReady(false); return; }
      const pct = (enemy.current / enemy.max) * 100;
      line.textContent = `${enemy.current.toLocaleString()} / ${enemy.max.toLocaleString()} (${pctStr(pct)})`;
      setReady(pct <= threshold);
    }

    let mo, scanning = false, lastScanTs = 0, scheduled = null;

    function doScan(force = false) {
      if (scanning) return;
      const now = performance.now();
      if (!force && (now - lastScanTs) < MIN_SCAN_INTERVAL) {
        if (!scheduled) scheduled = setTimeout(() => { scheduled = null; doScan(true); }, MIN_SCAN_INTERVAL - (now - lastScanTs));
        return;
      }
      scanning = true;
      if (mo) mo.disconnect();

      const cands = findHpCandidates();
      renderChips(cands);
      const enemy = chooseEnemy(cands);
      updateHUD(enemy);

      lastScanTs = performance.now();
      scanning = false;
      if (mo) mo.observe(document.body, { subtree: true, childList: true, characterData: true });
    }
    const scheduleScan = (force=false) => doScan(force);

    function onMutations(records) {
      for (const rec of records) {
        const t = rec.target;
        if (t && t.closest && t.closest('#' + CSS.escape(HUD_ID))) continue; // ignore HUD changes
        scheduleScan(false);
        break;
      }
    }

    mo = new MutationObserver(onMutations);
    mo.observe(document.body, { subtree: true, childList: true, characterData: true });
    setInterval(() => scheduleScan(false), POLL_FALLBACK_MS);

    // initial
    scheduleScan(true);

    // keep position sane on rotate / resize
    window.addEventListener('resize', () => {
      const rect = $('#' + HUD_ID).getBoundingClientRect();
      const newTop = clamp(rect.top, 4, window.innerHeight - 60);
      const newLeft = rect.left;
      const el = $('#' + HUD_ID);
      el.style.top = newTop + 'px';
      if (el.style.right !== '12px') el.style.left = clamp(newLeft, 4, window.innerWidth - 120) + 'px';
      setLS(LS.POS, { top: newTop, left: (el.style.right === '12px' ? null : parseInt(el.style.left, 10)) });
    });
  }

  // SPA-ready
  const ready = () => {
    const sid = new URLSearchParams(location.search).get('sid') || '';
    if (sid.startsWith('attack')) start(); else setTimeout(ready, 400);
  };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready);
  else ready();

})();