您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Show a blood-bag icon when Life is full (with Torn-style tooltip)
// ==UserScript== // @name Torn: Refill Blood Bag Reminder // @namespace torn.tools.reminders // @version 2.1.0 // @description Show a blood-bag icon when Life is full (with Torn-style tooltip) // @author You // @license MIT // @match https://www.torn.com/* // @match https://torn.com/* // @icon https://www.torn.com/favicon.ico // @homepageURL https://greasyfork.org/en/scripts/548072-torn-xanax-pre-use-blood-bag-reminder-full-life-icon-yellow-tip-native-style // @supportURL https://greasyfork.org/en/scripts/548072-torn-xanax-pre-use-blood-bag-reminder-full-life-icon-yellow-tip-native-style/feedback // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; const CONFIG = { // Life bar + sidebar lifeBarSelector: 'a[class*="life"][class*="bar"]', lifeValueSelector: '[class^="bar-value"]', lifeDescrSelector: '[class^="bar-descr"]', progressWrapSelector: 'div[class*="progress"]', statusIconsSelector: 'ul[class*="status-icons"]', // Our icon fullLifeIconId: 'tm-full-life-bloodbag', bloodBagPng: 'https://i.postimg.cc/mkZ1T68H/blood-bag-2.png', bloodBagTarget: 'https://www.torn.com/factions.php?step=your&type=1#/tab=armoury&start=0&sub=medical', pollMs: 2000, arrowExtraNudgePx: 14, // centers arrow on our custom icon tooltipTopNudgePx: -4, // small vertical nudge to better match native }; // 👇 Declare before anything can call scheduleLifeCheck (fixes TDZ crash) let lifeCheckScheduled = false; // One-time CSS: reset sprite bleed + allow UL to grow ensureResetStyles(); ensureUlWrapFix(); // Observe DOM changes (SPA) and poll const mo = new MutationObserver(() => { scheduleLifeCheck(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); setInterval(scheduleLifeCheck, CONFIG.pollMs); // Initial draw scheduleLifeCheck(); // ===== Core ===== function scheduleLifeCheck() { if (lifeCheckScheduled) return; lifeCheckScheduled = true; requestAnimationFrame(() => { lifeCheckScheduled = false; updateFullLifeIcon(); }); } function getLife() { const lifeBar = document.querySelector(CONFIG.lifeBarSelector); if (!lifeBar) return null; const valNode = lifeBar.querySelector(CONFIG.lifeValueSelector) || Array.from(lifeBar.querySelectorAll('p, span, div')) .find((n) => /\d[\d,]*\s*\/\s*\d[\d,]*/.test(n.textContent || '')); let current = null, max = null; const text = (valNode?.textContent || '').trim(); const m = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)/); if (m) { current = parseInt(m[1].replace(/,/g, ''), 10); max = parseInt(m[2].replace(/,/g, ''), 10); } const descrText = (lifeBar.querySelector(CONFIG.lifeDescrSelector)?.textContent || '').trim().toUpperCase(); const progressWrap = lifeBar.querySelector(CONFIG.progressWrapSelector); const hasFullClass = progressWrap && /\bfull___/.test(progressWrap.className); if (current != null && max != null && max > 0) { const pct = Math.round((current / max) * 100); const forcedPct = (descrText === 'FULL' || hasFullClass) ? 100 : pct; return { current, max, pct: forcedPct }; } if (descrText === 'FULL' || hasFullClass) { const maxGuess = m ? parseInt(m[2].replace(/,/g, ''), 10) : null; return { current: maxGuess ?? 0, max: maxGuess ?? 0, pct: 100 }; } return null; } function updateFullLifeIcon() { const statusUl = document.querySelector(CONFIG.statusIconsSelector); if (!statusUl) return; const existing = document.getElementById(CONFIG.fullLifeIconId); const life = getLife(); const shouldShow = !!life && life.pct === 100; if (shouldShow) { const label = `Full Life: ${formatNum(life.current)} / ${formatNum(life.max)} — consider donating blood`; if (existing) { updateIconTooltip(existing, label); return; } const li = buildBloodBagIcon(label); statusUl.appendChild(li); } else if (existing) { existing.remove(); } } function buildBloodBagIcon(tooltipText) { const li = document.createElement('li'); li.id = CONFIG.fullLifeIconId; li.style.background = 'none'; li.style.animation = 'tmPulse 900ms ease-out 1'; const a = document.createElement('a'); a.href = CONFIG.bloodBagTarget; // same-tab navigation (no target) a.setAttribute('aria-label', tooltipText); a.tabIndex = 0; a.setAttribute('data-is-tooltip-opened', 'false'); const img = document.createElement('img'); img.src = CONFIG.bloodBagPng; img.alt = 'Blood Bag'; img.width = 17; img.height = 17; img.style.display = 'block'; a.appendChild(img); li.appendChild(a); // Native-style tooltip enableNativeLikeTooltip(a); if (!document.getElementById('tm-pulse-style')) { const style = document.createElement('style'); style.id = 'tm-pulse-style'; style.textContent = ` @keyframes tmPulse { 0% { transform: scale(0.9); } 60% { transform: scale(1.1); } 100% { transform: scale(1.0); } } `; document.head.appendChild(style); } return li; } function updateIconTooltip(li, text) { const a = li.querySelector('a'); if (!a) return; a.setAttribute('aria-label', text); if (typeof a.__tmUpdateTipText === 'function') a.__tmUpdateTipText(text); } // ===== Tooltip (native-style mimic) ===== function enableNativeLikeTooltip(anchor) { let tipEl = null; let hideTimer = null; const CLS = { tip: 'tooltip___aWICR tooltipCustomClass___gbI4V', arrowWrap: 'arrow___yUDKb top___klE_Y', arrowIcon: 'arrowIcon___KHyjw', }; function buildTooltip(text) { const el = document.createElement('div'); el.className = CLS.tip; el.setAttribute('role', 'tooltip'); el.setAttribute('tabindex', '-1'); el.style.position = 'absolute'; el.style.transitionProperty = 'opacity'; el.style.transitionDuration = '200ms'; el.style.opacity = '0'; // Title (bold) + second line (like native) const [title, subtitle] = parseTwoLines(text); const b = document.createElement('b'); b.textContent = title; el.appendChild(b); if (subtitle) { const div = document.createElement('div'); div.textContent = subtitle; el.appendChild(div); } const arrowWrap = document.createElement('div'); arrowWrap.className = CLS.arrowWrap; const arrowIcon = document.createElement('div'); arrowIcon.className = CLS.arrowIcon; arrowWrap.appendChild(arrowIcon); el.appendChild(arrowWrap); return el; } function setText(text) { if (!tipEl) return; const [title, subtitle] = parseTwoLines(text); const b = tipEl.querySelector('b'); if (b) b.textContent = title; let sub = b?.nextElementSibling; if (subtitle) { if (!sub || sub.tagName !== 'DIV') { sub = document.createElement('div'); b.after(sub); } sub.textContent = subtitle; } else if (sub) { sub.remove(); } } function parseTwoLines(text) { // Expect aria-label like: "Full Life: 1,230 / 1,230 — consider donating blood" // We render as two lines: // 1) "Full Life" // 2) "Considering filling blood bags" (or whatever you set elsewhere) // If your aria-label is already "Line1\nLine2", we’ll use that split. if (text.includes('\n')) { const [a, b] = text.split('\n'); return [a.trim(), (b || '').trim()]; } // Default fallback: return ['Full Life', 'Considering filling blood bags']; } function positionTooltip() { if (!tipEl) return; const r = anchor.getBoundingClientRect(); const ew = tipEl.offsetWidth; const eh = tipEl.offsetHeight; // Top position: above the icon, with small nudge to better match native let left = Math.round(r.left + (r.width - ew) / 2); let top = Math.round(r.top - eh - 10 + CONFIG.tooltipTopNudgePx); left = Math.max(8, Math.min(left, window.innerWidth - ew - 8)); if (top < 8) { top = Math.round(r.bottom + 10); } tipEl.style.left = `${left}px`; tipEl.style.top = `${top}px`; // Arrow centering calculation based on icon center + optional nudge const arrow = tipEl.querySelector(`.${CLS.arrowWrap.split(' ')[0]}`); if (arrow) { const padL = 0; const arrowW = 12; const iconCenter = r.left + r.width / 2; let arrowLeft = Math.round(iconCenter - left - padL - arrowW / 2); arrowLeft += CONFIG.arrowExtraNudgePx; // user-tuned nudge for our icon arrow.style.left = `${arrowLeft}px`; } } function showTip() { clearTimeout(hideTimer); const text = anchor.getAttribute('aria-label'); if (!text) return; if (!tipEl) { tipEl = buildTooltip(text); document.body.appendChild(tipEl); anchor.__tmTipEl = tipEl; } else { setText(text); } anchor.setAttribute('data-is-tooltip-opened', 'true'); // First layout offscreen, then position + fade in tipEl.style.opacity = '0'; tipEl.style.left = '-9999px'; tipEl.style.top = '-9999px'; requestAnimationFrame(() => { positionTooltip(); requestAnimationFrame(() => { if (tipEl) tipEl.style.opacity = '1'; }); }); } function hideTip(immediate = false) { if (!tipEl) return; anchor.setAttribute('data-is-tooltip-opened', 'false'); if (immediate) { tipEl.remove(); anchor.__tmTipEl = null; tipEl = null; return; } tipEl.style.opacity = '0'; hideTimer = setTimeout(() => { tipEl?.remove(); anchor.__tmTipEl = null; tipEl = null; }, 210); } // Expose updater so we can refresh text on life changes anchor.__tmUpdateTipText = (text) => setText(text); anchor.addEventListener('mouseenter', showTip); anchor.addEventListener('mouseleave', () => hideTip(false)); anchor.addEventListener('focus', showTip); anchor.addEventListener('blur', () => hideTip(true)); window.addEventListener('scroll', () => hideTip(true), { passive: true }); } // ===== CSS guards ===== function ensureResetStyles() { if (document.getElementById('tm-full-life-icon-reset')) return; const s = document.createElement('style'); s.id = 'tm-full-life-icon-reset'; s.textContent = ` #tm-full-life-bloodbag, #tm-full-life-bloodbag a, #tm-full-life-bloodbag img { background: none !important; background-image: none !important; -webkit-mask: none !important; mask: none !important; box-shadow: none !important; border: none !important; } #tm-full-life-bloodbag::before, #tm-full-life-bloodbag::after, #tm-full-life-bloodbag a::before, #tm-full-life-bloodbag a::after { content: none !important; } `; document.head.appendChild(s); } function ensureUlWrapFix() { if (document.getElementById('tm-status-ul-fix')) return; const s = document.createElement('style'); s.id = 'tm-status-ul-fix'; s.textContent = ` ul[class*="status-icons"] { height: auto !important; overflow: visible !important; } `; document.head.appendChild(s); } // ===== utils ===== function formatNum(n) { try { return n.toLocaleString(); } catch { return String(n); } } })();