Torn Enemy HP% + Execute UI

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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();

})();