可复用的 Hentaiverse 装备悬停浮层(Shadow DOM)。
Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.sleazyfork.org/scripts/601302/1969559/hv-equip-tip.js
// ==UserScript==
// @name HV 装备浮层
// @namespace https://hentaiverse.org/
// @version 1.1.4
// @description 可复用的 Hentaiverse 装备悬停浮层(Shadow DOM)。
// @author ssnangua
// @license MIT
// @grant GM_xmlhttpRequest
// @connect hentaiverse.org
// @connect alt.hentaiverse.org
// ==/UserScript==
/**
* 用法:
* // @require https://.../hv-equip-tip.lib.user.js
* // @grant GM_xmlhttpRequest
* // @connect hentaiverse.org
* // @connect alt.hentaiverse.org
*
* HvEquipTip.attach() // 开始监听悬停。重复调用不会重复绑定
* HvEquipTip.isTranslate() // 当前是否汉化
* HvEquipTip.isTranslate(true|false) // 随时开关。浮层正开着会立刻换语言
* HvEquipTip.url(href) // 规范的装备页地址,不是装备链接时返回 ""
* HvEquipTip.open(url) // 用 450×520 的小窗口打开
*/
(function (root) {
"use strict";
const pageWindow = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
// 与 Hentaiverse 汉化包「外显词缀」同一套译名和底色。长词在前,避免 Power 吃掉 Power Armor。
const EQ_PHRASES = [
[
" of the Thrice-blessed",
" 恩典(圣抗+)",
"background: #ffffff; color: #5c5a5a; padding: 0 1px;",
],
[" of the Stone-skinned", " 石肤者(物减伤+)"],
[
" of the Thunder-child",
" 雷之子(电抗+)",
"background: #ffff00; color: #9f9f16; padding: 0 1px;",
],
[" of the Battlecaster", " 战法师(魔耗-魔命+无干涉)"],
[" of the Curse-weaver", " 织咒者(减益熟练+)"],
[" of the Earth-walker", " 地行者(增益熟练+)"],
[" of the Elementalist", " 元素使(元素熟练+)"],
[" of the Shadowdancer", " 影武者(闪避攻暴+)"],
[" of the Demon-fiend", " 恶魔(黑暗熟练+)"],
[" of the Heaven-sent", " 天堂(神圣熟练+)"],
[
" of the Spirit-ward",
" 魂护(暗抗+)",
"background: #cccccc; color: #000000; padding: 0 1px;",
],
[
" of the Fire-eater",
" 吞火者(火抗+)",
"background: #ffa6a6; color: #000000; padding: 0 1px;",
],
[
" of the Frost-born",
" 冰诞者(冰抗+)",
"background: #a0f4f4; color: #000000; padding: 0 1px;",
],
[
" of the Wind-waker",
" 驭风者(风抗+)",
"background: #b1f9b1; color: #000000; padding: 0 1px;",
],
[" of the Arcanist", " 秘法(智力智慧魔命+)"],
[" of the Illithid", " 汲灵(5%回血+吸魔)"],
[" of Destruction", " 毁灭(法伤+)"],
[" of the Banshee", " 女妖(5%回血+吸灵)"],
[" of the Barrier", " 屏障(格挡+)"],
[" of the Cheetah", " 猎豹(敏捷+)"],
[" of the Raccoon", " 浣熊(灵巧+)"],
[" of the Vampire", " 吸血鬼(10%回血)"],
[" of Deflection", " 偏转(刺减伤+)"],
[" of Protection", " 保护(物减伤+)"],
[" of the Nimble", " 灵活(招架+)"],
[" of the Turtle", " 乌龟(体质+)"],
[
"of Destruction",
" 毁灭(法伤+)",
"background: #9400d3; color: #ffffff; padding: 0 1px;",
],
["Quintessential", "第五元素"],
[" of Dampening", " 抑制(打减伤+)"],
[" of Priestess", " 牧师"],
[" of Slaughter", " 杀戮(攻击+)"],
[" of Stoneskin", " 石肤(斩减伤+)"],
[" of Swiftness", " 迅捷(攻速+)"],
[" of the Fleet", " 迅捷(闪避+)"],
[" of Negation", " 否定(抵抗+)"],
[" of the Hulk", " 巨物(虚空抗+)"],
[
"Force Shield",
"力场盾(副)",
"background: #ffa500; color: #000000; padding: 0 1px;",
],
[
"of Slaughter",
" 杀戮(攻击+)",
"background: #FF0000; color: #FFFFFF; padding: 0 1px;",
],
["Tower Shield", "🚪塔盾(副)"],
[" of Balance", " 平衡(命中暴伤+)"],
[" of the Fox", " 狐狸(智力+)"],
[" of the Owl", " 猫头鹰(智慧+)"],
[" of Warding", " 护佑(魔减伤+)"],
["Breastplate", "🥼护胸"],
["Kite Shield", "🔰鸢盾(副)"],
[
"Magnificent",
"☆史诗☆",
"background: #a6daf6; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #a6daf6;",
],
[
"of Heimdall",
" 海姆达(圣法伤+)",
"background: #ffffff; color: #000000; padding: 0 1px;",
],
[
"of Niflheim",
" 尼芙菲姆(冰法伤+)",
"background: #94c2f5; color: #000000; padding: 0 1px;",
],
[
"Power Armor",
"<span style='background: #ffa500; color: #000000; padding: 0 1px;'>动力(重)</span> 🥼盔甲",
"background: transparent; color: inherit; padding: 0;",
],
["Swordchucks", "⚔︎锁链双剑(双)"],
[
"Tempestuous",
"风暴(风法伤+)",
"background: #7ff97c; color: #000000; padding: 0 1px;",
],
[" of the Ox", " 公牛(力量+)"],
[" of Turtle", " 乌龟(体质+)"],
["Great Mace", "🔨战锤(双)"],
[
"of Mjolnir",
" 姆乔尔尼尔(电法伤+)",
"background: #f4f375; color: #000000; padding: 0 1px;",
],
["Reinforced", "加固的(斩打刺减伤+)"],
["Shortsword", "⚔️短剑(单)"],
[" of Focus", " 专注(魔命+法暴伤+魔耗-)"],
[
"Drakehide",
"龙皮(轻)",
"background: #666666; color: #FFFFFF; padding: 0 1px;",
],
[
"Exquisite",
"✧精良✧",
"background: #d7e698; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #d7e698;",
],
["Gauntlets", "🤲手甲"],
[
"Legendary",
"✪传奇✪",
"background: #ffbbff; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffbbff;",
],
["Longsword", "⚔️长剑(双)"],
[
"of Fenrir",
" 芬里尔(暗法伤+)",
"background: #000000; color: #ffffff; padding: 0 1px;",
],
["Shielding", "盾化的(格挡+)"],
["Wakizashi", "🔪脇差(单)"],
[
"Ethereal",
"虚空(无负重干涉)",
"background: #ffffff; color: #5c5a5a; padding: 0 1px;",
],
[
"Gossamer",
"薄纱(布)",
"background: #FFFFFF; color: #000000; padding: 0 1px;",
],
[
"Hallowed",
"神圣(圣法伤+)",
"background: #ffffff; color: #000000; padding: 0 1px;",
],
[
"Ironsilk",
"铁丝(布)",
"background: #FFFFFF; color: #000000; padding: 0 1px;",
],
["Leggings", "🦵绑腿"],
[
"of Freyr",
" 弗瑞尔(风法伤+)",
"background: #7ff97c; color: #000000; padding: 0 1px;",
],
[
"of Surtr",
" 苏尔特(火法伤+)",
"background: #f97c7c; color: #000000; padding: 0 1px;",
],
[
"Peerless",
"☯无双☯",
"background: #ffd760; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffd760;",
],
["Platinum", "白金"],
[
"Reactive",
"反应装甲(重)",
"background: #ffa500; color: #000000; padding: 0 1px;",
],
["Sabatons", "👣马靴"],
["Sapphire", "蓝宝石"],
[
"Shocking",
"闪电(电法伤+)",
"background: #f4f375; color: #000000; padding: 0 1px;",
],
[
"Superior",
"上等",
"background: #fbf9f9; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #fbf9f9;",
],
["Titanium", "钛"],
[
"Ultimate",
"𖣔至尊𖣔",
"background: #f98048; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffd760;",
],
["adorned", " 装饰"],
[
"Average",
"中等",
"background: #dfdfdf; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #dfdfdf;",
],
["Buckler", "🛡️小圆盾(副)"],
["Charged", "充能的(施速+)"],
["Cuirass", "🥼胸甲"],
[
"Demonic",
"恶魔(暗法伤+)",
"background: #000000; color: #ffffff; padding: 0 1px;",
],
["Diamond", "金刚石"],
["Emerald", "祖母绿"],
["Greaves", "🦵护胫"],
["Katalox", "铁木(圣暗熟练/法伤+)"],
[
"Leather",
"皮革(轻)",
"background: #666666; color: #FFFFFF; padding: 0 1px;",
],
["Mithril", "秘银的(负重-)"],
[
"Radiant",
"✪魔光的✪(法伤+)",
"background: #ffffff; color: #000000; padding: 0 1px;",
],
["Redwood", "红木(元素熟练/法伤+)"],
["trimmed", " 镶边"],
[
"Arctic",
"极寒(冰法伤+)",
"background: #94c2f5; color: #000000; padding: 0 1px;",
],
["Astral", "五芒星"],
["Bronze", "铜"],
[
"Cobalt",
"钴石的(冰抗+)",
"background: #a0f4f4; color: #000000; padding: 0 1px;",
],
[
"Cotton",
"棉布(布)",
"background: #FFFFFF; color: #000000; padding: 0 1px;",
],
["Dagger", "🗡️匕首(单)"],
[
"Flimsy",
"脆弱",
"background: #848482; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #848482;",
],
["Frugal", "节能的(魔耗-)"],
["Gloves", "🤲手套"],
["Helmet", "🪖头盔"],
["Katana", "※太刀(双)"],
[
"Kevlar",
"凯夫拉(轻)",
"background: #666666; color: #FFFFFF; padding: 0 1px;",
],
["Mystic", "神秘的(法爆伤+)"],
["Rapier", "🤺西洋剑(单)"],
[
"Savage",
"残暴的(攻暴伤+)",
"color: red; background: transparent; padding: 0 1px;",
],
["Scythe", "𖤠镰刀(双)"],
["Silver", "银"],
["tipped", " 前端"],
["Willow", "柳木(减益熟练+暗法伤+)"],
[
"Zircon",
"锆石的(圣抗+)",
"background: #ffffff; color: #5c5a5a; padding: 0 1px;",
],
["Agile", "俊敏的(攻速+)"],
[
"Amber",
"琥珀的(电抗+)",
"background: #ffff00; color: #9f9f16; padding: 0 1px;",
],
["Boots", "👣靴子"],
[
"Chain",
"锁链(重)",
"background: #000000; color: #FFFFFF; padding: 0 1px;",
],
[
"Crude",
"粗糙",
"background: #acacac; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #acacac;",
],
["Ebony", "乌木(元素圣暗熟练/法伤+)"],
["Estoc", "🤺刺剑(双)"],
[
"Fiery",
"灼热(火法伤+)",
"background: #f97c7c; color: #000000; padding: 0 1px;",
],
["Pants", "🦵裤子"],
[
"Phase",
"相位(布)",
"background: #ffa500; color: #000000; padding: 0 1px;",
],
[
"Plate",
"板甲(重)",
"background: #000000; color: #FFFFFF; padding: 0 1px;",
],
[
"Power",
"动力(重)",
"background: #ffa500; color: #000000; padding: 0 1px;",
],
["Prism", "光棱"],
[
"Shade",
"暗影(轻)",
"background: #ffa500; color: #000000; padding: 0 1px;",
],
["Shoes", "👣鞋子"],
["Staff", "🧙♂️法杖(双)"],
["Steel", "钢"],
["Club", "🦯棍(单)"],
[
"Fair",
"普通",
"background: #c1c1c1; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #c1c1c1;",
],
[
"Fine",
"优质",
"background: #b9ffb9; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #b9ffb9;",
],
["Gold", "金"],
["Iron", "铁"],
[
"Jade",
"翡翠的(风抗+)",
"background: #b1f9b1; color: #000000; padding: 0 1px;",
],
["Mace", "🔨战锤(双)"],
[
"Onyx",
"缟玛瑙(暗抗+)",
"background: #cccccc; color: #000000; padding: 0 1px;",
],
["Robe", "🥼长袍"],
[
"Ruby",
"红宝石(火抗+)",
"background: #ffa6a6; color: #000000; padding: 0 1px;",
],
[
"Silk",
"丝绸(布)",
"background: #FFFFFF; color: #000000; padding: 0 1px;",
],
["Axe", "🪓斧(单)"],
["Cap", "🪖帽 "],
["Oak", "橡木(增益熟练+圣法伤+)"],
];
const STATS = {
Strength: "力量",
Dexterity: "灵巧",
Agility: "敏捷",
Endurance: "体质",
Intelligence: "智力",
Wisdom: "智慧",
"Attack Damage": "攻击伤害",
"Attack Accuracy": "攻击命中值",
"Attack Critical": "攻击暴击率",
"Attack Speed": "攻击速度",
"Attack Crit Damage": "攻击暴击伤害",
"Attack Critical Damage": "攻击暴击伤害",
"Magic Damage": "魔法伤害",
"Magic Accuracy": "魔法命中值",
"Magic Critical": "魔法暴击率",
"Magic Crit Damage": "魔法暴击伤害",
"Spell Damage": "法术伤害加成",
"Casting Speed": "施法速度",
"Mana Conservation": "魔力消耗降低",
"Physical Mitigation": "物理减伤",
"Magical Mitigation": "魔法减伤",
"Damage Mitigation": "属性减伤",
"Spell Damage Bonus": "魔法伤害加成",
"Crushing Mitigation": "打击减伤",
"Piercing Mitigation": "刺击减伤",
"Slashing Mitigation": "斩击减伤",
"Fire Mitigation": "火焰抗性",
"Cold Mitigation": "冰霜抗性",
"Elec Mitigation": "闪电抗性",
"Wind Mitigation": "疾风抗性",
"Holy Mitigation": "神圣抗性",
"Dark Mitigation": "黑暗抗性",
"Void Mitigation": "虚空抗性",
"Fire Damage": "火焰伤害",
"Cold Damage": "寒冰伤害",
"Elec Damage": "闪电伤害",
"Wind Damage": "疾风伤害",
"Holy Damage": "神圣伤害",
"Dark Damage": "黑暗伤害",
"Void Damage": "虚空伤害",
"Crushing Damage": "打击伤害",
"Piercing Damage": "刺击伤害",
"Slashing Damage": "斩击伤害",
Block: "格挡值",
"Block Chance": "格挡值",
Parry: "招架值",
"Parry Chance": "招架值",
Evade: "回避值",
"Evade Chance": "回避值",
Resist: "抵抗值",
"Counter-Resist": "反抵抗率",
"Counter-resist": "反抵抗率",
"Counter-parry": "反招架值",
"Counter-Parry": "反招架值",
"Spell Crit": "法术暴击率",
"Lasts for": "持续",
DOT: "持续伤害比例",
"Ether Theft": "魔力回流",
Unassigned: "未确定",
"Salvaged - Repair Required": "已分解 - 需要完全修理",
"Upgrades and Enchantments": "强化与附魔",
Fire: "火焰",
Cold: "冰霜",
Elec: "闪电",
Wind: "疾风",
Holy: "神圣",
Dark: "黑暗",
Void: "虚空",
Crushing: "打击",
Piercing: "刺击",
Slashing: "斩击",
Burden: "负重",
Interference: "干涉",
"HP Bonus": "生命加成",
"MP Bonus": "魔力加成",
"SP Bonus": "灵力加成",
Elemental: "元素魔法",
Divine: "神圣魔法",
Forbidden: "黑暗魔法",
Deprecating: "减益魔法",
Supportive: "增益魔法",
Proficiency: "熟练度加成",
roll: "投点",
Roll: "投点",
"Average roll": "平均投点",
"平均 Roll": "平均投点",
};
const PANEL = [
["One-handed Weapon", "单手武器"],
["Two-handed Weapon", "双手武器"],
["One-Handed Weapon", "单手武器"],
["Two-Handed Weapon", "双手武器"],
["One-Handed", "单手武器"],
["Two-Handed", "双手武器"],
["Cloth Armor", "布甲"],
["Light Armor", "轻甲"],
["Heavy Armor", "重甲"],
["Primary Attributes", "主属性"],
["Current Owner", "现持有人"],
["Dropped by", "掉落于"],
["Equipment Shop", "装备店"],
["Rewarded as a Clear Bonus to", "作为竞技场通关奖励给予"],
["Transferred from Isekai by", "从异世界中转移获得"],
["Rewarded from Snowflake's Shrine to", "作为雪花祭坛献祭奖励给予"],
["Penetrated Armor", "穿甲"],
["Bleeding Wound", "流血"],
["Siphon Spirit", "灵力吸取"],
["Siphon Magic", "魔力吸取"],
["Siphon Health", "生命吸取"],
["Elemental Strike", "属性打击"],
["Fire Strike", "火焰打击"],
["Cold Strike", "冰霜打击"],
["Lightning Strike", "闪电打击"],
["Elec Strike", "闪电打击"],
["Wind Strike", "疾风打击"],
["Holy Strike", "神圣打击"],
["Dark Strike", "黑暗打击"],
["Void Strike", "虚空打击"],
["Condition:", "耐久度:"],
["Interference:", "干涉:"],
["Untradeable", "不可交易"],
["Tradeable", "可交易"],
["Soulbound", "灵魂绑定"],
["Ether Tap", "魔力回流"],
["Burden:", "负重:"],
["Energy:", "电量:"],
["Stunned", "眩晕"],
["Level ", "等级 "],
["Tier", "阶级"],
["chance", "几率"],
[" turns", " 回合"],
[" turn", " 回合"],
["Shield", "盾牌"],
["Cloth", "布甲"],
["Light", "轻甲"],
["Heavy", "重甲"],
["Staff", "🧙♂️法杖(双)"],
["None", "无"],
];
const NAME_RE = /^(?:Flimsy|Crude|Fair|Average|Superior|Fine|Exquisite|Magnificent|Legendary|Peerless|Ultimate)\s+\S/;
function escapeRegExp(value) {
return value.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
}
const equipMap = new Map(
EQ_PHRASES.map((row) => [row[0], { zh: row[1], style: row[2] || "" }]),
);
const panelMap = new Map(PANEL);
for (const [key, value] of Object.entries(STATS)) {
if (!panelMap.has(key)) panelMap.set(key, value);
}
function equipPattern(keys) {
return keys
.slice()
.sort((a, b) => b.length - a.length || a.localeCompare(b))
.map((key) => {
const escaped = escapeRegExp(key);
return /^\w+$/.test(key) ? "\\b" + escaped + "\\b" : escaped;
})
.join("|");
}
const styledKeys = [];
const plainKeys = [];
for (const [key, entry] of equipMap) {
if (entry.style) styledKeys.push(key);
else plainKeys.push(key);
}
const equipStyleRe = styledKeys.length ? new RegExp(equipPattern(styledKeys), "g") : null;
const equipPlainRe = plainKeys.length ? new RegExp(equipPattern(plainKeys), "g") : null;
const equipAllRe = new RegExp(equipPattern([...equipMap.keys()]), "g");
const panelRe = new RegExp(equipPattern([...panelMap.keys()]), "g");
function collectMatches(regex, text) {
if (!regex || !text) return [];
regex.lastIndex = 0;
const matches = [];
let found;
while ((found = regex.exec(text))) {
matches.push({ start: found.index, end: found.index + found[0].length, key: found[0] });
if (!found[0]) regex.lastIndex += 1;
}
return matches;
}
function isEquipName(text) {
const trimmed = String(text || "").trim();
if (trimmed.length < 6 || trimmed.length > 140) return false;
if (/[.!?;::。!?]/.test(trimmed)) return false;
const summary = /\bLv\.?\s*\d+\s*$/.test(trimmed);
if (!NAME_RE.test(trimmed) && !summary) return false;
equipAllRe.lastIndex = 0;
const rest = trimmed
.replace(equipAllRe, " ")
.replace(/\b(?:of|the)\b/gi, " ")
.replace(/\bLv\.?\s*/gi, " ")
.replace(/[\d.\s+\-()]/g, "");
equipAllRe.lastIndex = 0;
return rest.length === 0;
}
function paintName(el, source) {
const styled = collectMatches(equipStyleRe, source);
const frag = document.createDocumentFragment();
let prevSpace = false;
function appendPiece(value, style) {
if (!value) return;
if (prevSpace) value = value.replace(/^[ \t]+/, "");
if (!value) return;
if (style) {
const mark = document.createElement("mark");
mark.className = "hv-eq";
mark.style.cssText = "background:transparent;color:inherit;padding:0;margin:0;" + style;
if (value.includes("<")) mark.innerHTML = value;
else mark.textContent = value;
frag.appendChild(mark);
} else {
frag.appendChild(document.createTextNode(value));
}
prevSpace = /\s$/.test(value);
}
function appendPlain(value) {
const lv = value.match(/^(.*?)(\s*Lv\.?\s*\d+)(.*)$/);
if (lv) {
appendPlain(lv[1]);
appendPiece(" 等级" + lv[2].replace(/\D/g, ""), "");
appendPlain(lv[3]);
return;
}
const matches = collectMatches(equipPlainRe, value);
if (!matches.length) {
appendPiece(value, "");
return;
}
let cursor = 0;
for (const match of matches) {
appendPiece(value.slice(cursor, match.start), "");
const entry = equipMap.get(match.key);
appendPiece(entry ? entry.zh : match.key, "");
cursor = match.end;
}
appendPiece(value.slice(cursor), "");
}
if (!styled.length) appendPlain(source);
else {
let cursor = 0;
for (const match of styled) {
appendPlain(source.slice(cursor, match.start));
const entry = equipMap.get(match.key);
appendPiece(entry ? entry.zh : match.key, entry && entry.style);
cursor = match.end;
}
appendPlain(source.slice(cursor));
}
el.replaceChildren(frag);
}
function applyPanel(text) {
const structured = text
.replace(/Dropped[ \u00a0]+by[ \u00a0]+(.+?)[ \u00a0]+for/g, (match, source) => {
const name = source.replace(/\u00a0/g, " ").replace(/ +/g, " ").trim();
return `掉落于 "${name}",获得者`;
})
.replace(/on[ \u00a0]+(\d{4}-\d{2}-\d{2})/g, (match, date) => `掉落时间: ${date} `);
const normalized = structured.replace(/\u00a0/g, " ");
let result = "";
let last = 0;
panelRe.lastIndex = 0;
let match;
while ((match = panelRe.exec(normalized)) !== null) {
result += structured.slice(last, match.index);
result += panelMap.get(match[0]) || match[0];
last = match.index + match[0].length;
if (!match[0]) panelRe.lastIndex += 1;
}
return result + structured.slice(last);
}
function translateCard(root) {
const texts = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) texts.push(walker.currentNode);
for (const node of texts) {
const parent = node.parentElement;
if (!parent || parent.closest("mark.hv-eq") || parent.children.length > 0) continue;
if (!isEquipName(parent.textContent)) continue;
paintName(parent, parent.textContent);
}
const rest = [];
const walker2 = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
while (walker2.nextNode()) rest.push(walker2.currentNode);
for (const node of rest) {
if (!node.parentElement || node.parentElement.closest("mark.hv-eq")) continue;
const next = applyPanel(node.data);
if (next !== node.data) node.data = next;
}
}
const TIP_CSS = `
:host {
all: initial;
position: fixed;
z-index: 2147483646;
display: block;
width: max-content;
pointer-events: none;
}
:host([hidden]) { display: none !important; }
.card {
box-sizing: border-box;
width: 420px;
max-height: 80vh;
overflow: visible;
padding: 4px 0;
border: 1px solid #5c0d11;
background: #e3e0d1;
color: #5c0d11;
font-family: verdana, sans-serif;
font-size: 8pt;
line-height: normal;
text-align: center;
}
p { margin: 0; }
a:link, a:visited, a:active { color: #5c0d11; }
.showequip > div:first-child a { color: inherit; text-decoration: none; }
.showequip > div { margin: 3px auto 0; padding: 1px; }
.showequip > div:first-child,
.eq span, .eqt, .eqr, .eqc { font-weight: 700; }
.showequip > div:first-child {
border-bottom: 1px solid #a47c78;
font-size: 10pt;
padding: 2px 0 4px;
}
.eq > div { margin: 1px auto; padding: 1px; text-align: center; min-height: 17px; }
.eqt, .eqr { font-size: 110%; }
.ex, .ep { display: grid; gap: 2px 5px; justify-items: center; }
.ex {
grid-template-columns: repeat(2, 1fr);
border-top: 1px solid #a47c78;
text-align: right !important;
}
.ex > div { width: 165px; height: 18px; }
.ex > div > div:nth-child(1) { float: left; width: 99px; margin: 0; padding: 2px; white-space: nowrap; }
.ex > div > div:nth-child(2) { float: left; width: 45px; margin: 0; padding: 2px 0 2px 2px; }
.ex > div > div:nth-child(3) { float: left; width: 6px; margin: 0; padding: 2px; }
.ep > div { width: 115px; height: 18px; }
.ep > div:first-child {
font-weight: 700;
margin: 4px auto 0 !important;
width: 320px !important;
grid-column: 1 / -1;
}
.ep > div > div:nth-child(1) { float: left; width: 60px; margin: 0; padding: 2px; text-align: right; white-space: nowrap; }
.ep > div > div:nth-child(2) { float: left; width: 45px; margin: 0; padding: 2px 0 2px 2px; text-align: left; }
.ep1 { grid-template-columns: repeat(1, 1fr); }
.ep2 { grid-template-columns: repeat(2, 1fr); }
.ep3 { grid-template-columns: repeat(3, 1fr); }
.tip-msg { padding: 10px; font-weight: 700; }
mark.hv-eq { background: transparent; color: inherit; padding: 0; margin: 0; font: inherit; }
.hv-pct { margin-top: 3px; padding: 2px 0; border-top: 1px solid #a47c78; font-weight: 700; }
.hv-pct-mode { font-size: 10px; }
.hv-pct-high, .hv-pct-gain { color: #0ab844; }
.hv-pct-low { color: #f03939; }
.hv-pct-mid { color: #1d3271; }
`;
function canonicalEquipUrl(href) {
let url;
try {
url = new URL(href, location.href);
} catch (error) {
return "";
}
if (!/^(?:alt\.)?hentaiverse\.org$/i.test(url.hostname)) return "";
const match = /^\/(isekai\/)?equip\/([1-9]\d{0,11})\/([a-f0-9]{10})\/?$/i.exec(url.pathname);
if (!match) return "";
const host = /^alt\.hentaiverse\.org$/i.test(url.hostname) ? "alt.hentaiverse.org" : "hentaiverse.org";
return "https://" + host + "/" + (match[1] || "") + "equip/" + match[2] + "/" + match[3].toLowerCase();
}
function openEquipWindow(url) {
const width = 450;
const height = 520;
const screen = pageWindow.screen;
const features = [
"toolbar=0",
"scrollbars=0",
"location=0",
"statusbar=0",
"menubar=0",
"resizable=0",
"width=" + width,
"height=" + height,
"left=" + (screen.width - width) / 2,
"top=" + (screen.height - height) / 2,
].join(",");
const name = "_pu" + String(Math.random()).replace(/0\./, "");
return pageWindow.open(url, name, features);
}
function absoluteUrl(value, base) {
try {
return new URL(value, base).href;
} catch (error) {
return value;
}
}
function parseEquipHtml(html, url) {
const doc = new DOMParser().parseFromString(html, "text/html");
const content = doc.querySelector(".showequip");
if (!content) {
const message = (doc.body && doc.body.textContent ? doc.body.textContent : "").replace(/\s+/g, " ").trim();
if (/logged on/i.test(message)) throw new Error("需要登录 Hentaiverse 才能预览装备");
throw new Error(message || "没有装备信息");
}
content.querySelectorAll("script").forEach((el) => el.remove());
content.querySelectorAll(".ep").forEach((ep) => {
if (/\bep[123]\b/.test(ep.className)) return;
const cols = Math.min(3, Math.max(1, ep.children.length - 1));
ep.classList.add("ep" + cols);
});
content.querySelectorAll("[src]").forEach((el) => {
const src = el.getAttribute("src");
if (src) el.setAttribute("src", absoluteUrl(src, url));
});
const title = content.firstElementChild;
if (title && !title.querySelector("a")) {
const link = doc.createElement("a");
link.href = url;
while (title.firstChild) link.appendChild(title.firstChild);
title.appendChild(link);
}
return document.adoptNode(content);
}
class HvEquipTipElement extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = TIP_CSS;
this.body = document.createElement("div");
this.body.className = "card";
shadow.append(style, this.body);
this.body.addEventListener("click", (event) => {
const node = event.target;
const anchor = node && node.closest ? node.closest("a[href]") : null;
if (!anchor) return;
const equip = canonicalEquipUrl(anchor.getAttribute("href") || anchor.href || "");
if (!equip) return;
event.preventDefault();
openEquipWindow(equip);
});
}
}
if (!customElements.get("hv-equip-tip")) {
customElements.define("hv-equip-tip", HvEquipTipElement);
}
const cache = new Map();
let translating = false;
let showPercent = true;
let showMaxForge = false;
try {
showPercent = localStorage.getItem("hvEquipTipPercent") !== "false";
} catch (error) {}
let options = { delay: 200 };
let bound = false;
let tip = null;
let currentLink = null;
let currentUrl = "";
let showTimer = 0;
let hideTimer = 0;
let request = null;
let pointerX = 0;
let pointerY = 0;
function ensureTip() {
if (tip && tip.isConnected) return tip;
tip = document.createElement("hv-equip-tip");
tip.hidden = true;
tip.style.position = "fixed";
tip.style.zIndex = "2147483646";
tip.style.pointerEvents = "none";
tip.style.width = "max-content";
document.body.appendChild(tip);
return tip;
}
function hideTip() {
if (!tip) return;
tip.hidden = true;
tip.body.replaceChildren();
}
function placeTip() {
const box = ensureTip();
const gap = 16;
const margin = 8;
const tipRect = box.getBoundingClientRect();
const width = tipRect.width || 420;
const height = tipRect.height || 0;
let left = pointerX + gap;
let top = pointerY + gap;
if (left + width > window.innerWidth - margin) {
left = pointerX - width - gap;
}
if (left < margin) left = margin;
if (height && top + height > window.innerHeight - margin) {
top = pointerY - height - gap;
}
if (top < margin) top = margin;
box.style.left = left + "px";
box.style.top = top + "px";
}
function showHtml(link, html) {
if (currentLink !== link) return;
const box = ensureTip();
box.body.replaceChildren();
box.body.insertAdjacentHTML("afterbegin", html);
box.hidden = false;
placeTip();
}
function showMessage(link, message) {
if (currentLink !== link) return;
const box = ensureTip();
box.body.replaceChildren();
const line = document.createElement("div");
line.className = "tip-msg";
line.textContent = message;
box.body.appendChild(line);
box.hidden = false;
placeTip();
}
function abortRequest() {
if (!request) return;
try { request.abort(); } catch (error) {}
request = null;
}
function insideTip(node) {
if (!tip || !node) return false;
if (node === tip || tip.contains(node)) return true;
const rootNode = node.getRootNode ? node.getRootNode() : null;
return rootNode === tip.shadowRoot;
}
const RANGE_QUALITY = {
Peerless: { range: [200, 200], cap: 60 },
Legendary: { range: [170, 200], cap: 50 },
Magnificent: { range: [150, 180], cap: 40 },
Exquisite: { range: [120, 160], cap: 20 },
Superior: { range: [90, 130], cap: 20 },
Average: { range: [60, 100], cap: 20 },
Fair: { range: [30, 70], cap: 20 },
Crude: { range: [0, 40], cap: 20 },
};
const RANGE_CHARM = new Set(["HP Bonus", "MP Bonus", "Counter-parry"]);
const RANGE_VALUE = /^(\d+(?:\.\d+)?)(?:\s+(\S.*))?$/;
const RANGE_BASE = /Base:\s*([+-]?[\d.]+)/i;
const RANGE_TIER = /Tier\s+(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)/i;
const RANGE_DAMAGE = /^(?:Magic|Attack|Void|Crushing|Piercing|Slashing) Damage/;
function rangeTone(percent, high, low) {
if (percent >= high) return "hv-pct-high";
if (percent <= low) return "hv-pct-low";
return "hv-pct-mid";
}
function parseRanges(container) {
const eq = container.querySelector(".eq");
const eqt = container.querySelector(".eqt");
if (!eq || !eqt) return null;
const title = (container.firstElementChild ? container.firstElementChild.textContent : "").trim();
const found = Object.entries(RANGE_QUALITY).find(([key]) => title.startsWith(key));
if (!found) return null;
const [quality, config] = found;
const stats = [];
const rows = [
...eq.querySelectorAll(".ex > div, .ep > div"),
...Array.from(eq.querySelectorAll('[title^="Base: "]')).filter((el) => !el.closest(".ex, .ep")),
];
for (const row of rows) {
const span = row.querySelector("span");
if (!span) continue;
const match = RANGE_VALUE.exec(span.textContent.trim());
if (!match || !match[1]) continue;
const name = match[2] != null ? match[2] : (row.firstElementChild ? row.firstElementChild.textContent.trim() : "");
if (!name) continue;
const baseTitle = row.getAttribute("title") || "";
const baseMatch = baseTitle.match(RANGE_BASE);
const prefixNode = span.previousSibling;
let suffixNode = span.parentNode ? span.parentNode.nextSibling : null;
while (suffixNode && suffixNode.nodeType === 3 && !suffixNode.textContent.trim()) {
suffixNode = suffixNode.nextSibling;
}
const unitNode = span.nextSibling && span.nextSibling.nodeType === 3 ? span.nextSibling : null;
stats.push({
title: name,
val: Number.parseFloat(match[1]),
valStr: match[1],
base: baseMatch ? Number.parseFloat(baseMatch[1]) : null,
rate: RANGE_DAMAGE.test(name) ? 2 : 1,
prefix: prefixNode && prefixNode.nodeType === 3 ? prefixNode.textContent : "",
suffix: suffixNode ? suffixNode.textContent : "",
typeText: match[2] != null ? match[2] : null,
el: span,
prefixNode: prefixNode,
suffixNode: suffixNode,
unitNode: unitNode,
});
}
const tierMatch = RANGE_TIER.exec(eqt.textContent);
const levelMatch = /Level (\d+)/i.exec(eqt.textContent);
const equip = tierMatch
? {
type: "Soulbound",
quality: quality,
range: config.range,
tierUp: +tierMatch[1],
tierIw: +tierMatch[2],
tierMax: +tierMatch[3],
forgeMax: +tierMatch[3] * 2,
eqt: eqt,
stats: stats,
}
: {
type: "Tradeable",
quality: quality,
range: config.range,
forgeMax: config.cap,
level: levelMatch ? +levelMatch[1] : 0,
stats: stats,
};
equip.container = container;
return equip;
}
function renderRanges(equip) {
if (equip.type === "Soulbound") {
const up = equip.tierUp;
const iw = equip.tierIw;
const maxTier = equip.tierMax;
if (showMaxForge && (maxTier - up > 0 || maxTier - iw > 0)) {
const diff = (current) => (
maxTier - current > 0
? current + '<span class="hv-pct-gain">+' + (maxTier - current) + "</span>"
: String(maxTier)
);
equip.eqt.innerHTML = "Tier " + diff(up) + " / " + diff(iw) + " / " + maxTier;
} else {
equip.eqt.textContent = "Tier " + up + " / " + iw + " / " + maxTier;
}
}
let total = 0;
let count = 0;
const min = equip.range[0];
const max = equip.range[1];
const forged = equip.type === "Soulbound" ? equip.tierUp + equip.tierIw : 0;
for (const stat of equip.stats) {
let percent = null;
if (stat.base !== null) {
const span = max - min || 30;
percent = Number.parseFloat((((stat.base - (max - span)) / span) * 100).toFixed(1));
total += percent;
count += 1;
}
const forgeMode = showMaxForge && equip.forgeMax > forged && !RANGE_CHARM.has(stat.title);
const percentMode = showPercent && !forgeMode && percent !== null;
const hideAround = forgeMode || percentMode;
if (stat.prefixNode && stat.prefixNode.nodeType === 3) {
stat.prefixNode.textContent = hideAround ? "" : stat.prefix;
}
const suffixIsUnit = stat.suffixNode && /^\s*x\s*$/i.test(stat.suffixNode.textContent || "");
if (stat.suffixNode && !stat.typeText && (stat.suffixNode.nodeType === 3 || suffixIsUnit)) {
stat.suffixNode.textContent = hideAround ? "" : stat.suffix;
}
if (stat.unitNode && hideAround && /^\s*x\s*$/i.test(stat.unitNode.textContent)) {
stat.unitNode.textContent = "";
}
const typeText = stat.typeText && !(hideAround && stat.typeText === "x") ? " " + stat.typeText : "";
stat.el.classList.remove("hv-pct-high", "hv-pct-low", "hv-pct-mid");
if (forgeMode) {
const ratio = stat.rate / 100;
const maxVal = stat.val * (1 + equip.forgeMax * ratio) / (1 + forged * ratio);
const gain = +(maxVal - stat.val).toFixed(2);
stat.el.innerHTML = stat.valStr + '<span class="hv-pct-gain">+' + gain + "</span>" + typeText;
} else if (percentMode) {
stat.el.textContent = percent + "%" + typeText;
stat.el.classList.add(rangeTone(percent, 70, 30));
} else {
stat.el.textContent = stat.valStr + typeText;
}
}
const footer = document.createElement("div");
footer.className = "hv-pct";
if (count > 0) {
const avg = Number.parseFloat((total / count).toFixed(1));
const avgLine = document.createElement("div");
avgLine.className = rangeTone(avg, 60, 40);
const modeLine = document.createElement("div");
modeLine.className = "hv-pct-mode";
if (translating) {
const mode = showMaxForge
? "满锻"
: showPercent
? "百分比(按F切换数值,W切换满锻)"
: "数值(F切换百分比,W切换满锻)";
avgLine.textContent = "优秀度: " + avg + "%";
modeLine.textContent = "模式: " + mode;
} else {
const mode = showMaxForge ? "Max Forged" : showPercent ? "Percentage" : "Value";
avgLine.textContent = "Avg: " + avg + "%";
modeLine.textContent = "Mode: " + mode;
}
footer.append(avgLine, modeLine);
}
equip.container.appendChild(footer);
}
function applyRanges(container) {
const equip = parseRanges(container);
if (equip) renderRanges(equip);
}
function refreshOpenTip() {
if (!currentLink || !currentUrl || !tip || tip.hidden) return;
const entry = cache.get(currentUrl);
if (entry) present(currentLink, entry);
}
function present(link, entry) {
const holder = document.createElement("div");
holder.innerHTML = entry.raw;
const node = holder.firstElementChild;
if (node) {
applyRanges(node);
if (translating) translateCard(node);
}
showHtml(link, node ? node.outerHTML : entry.raw);
}
function renderTip(link) {
const url = canonicalEquipUrl(link.getAttribute("href") || link.href || "");
if (!url) return;
if (insideTip(link)) return;
window.clearTimeout(showTimer);
window.clearTimeout(hideTimer);
abortRequest();
currentLink = link;
currentUrl = url;
const cached = cache.get(url);
if (cached) {
present(link, cached);
return;
}
hideTip();
const delay = Number(options.delay) >= 0 ? Number(options.delay) : 200;
showTimer = window.setTimeout(() => {
if (currentLink !== link || typeof GM_xmlhttpRequest !== "function") return;
request = GM_xmlhttpRequest({
method: "GET",
url,
timeout: 15000,
onload: (response) => {
request = null;
if (currentLink !== link) return;
if (response.status < 200 || response.status >= 300) {
showMessage(link, "装备页打开失败(" + response.status + ")");
return;
}
try {
const node = parseEquipHtml(response.responseText, url);
const entry = { raw: node.outerHTML, zh: "" };
cache.set(url, entry);
present(link, entry);
} catch (error) {
showMessage(link, error && error.message ? error.message : "没有装备信息");
}
},
onerror: () => {
request = null;
showMessage(link, "装备页打开失败");
},
ontimeout: () => {
request = null;
showMessage(link, "装备页打开超时");
},
onabort: () => {
request = null;
},
});
}, delay);
}
function eventElement(event) {
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
const node = path.length ? path[0] : event.target;
if (!node) return null;
return node.nodeType === 1 ? node : node.parentElement;
}
function isTranslate(value) {
if (arguments.length === 0) return translating;
translating = !!value;
refreshOpenTip();
return translating;
}
function attach(next) {
if (next && next.delay != null) options.delay = Number(next.delay);
if (bound) return;
bound = true;
document.addEventListener("pointerover", (event) => {
if (event.pointerType === "touch") return;
const el = eventElement(event);
if (!el) return;
if (insideTip(el)) {
window.clearTimeout(hideTimer);
return;
}
const link = el.closest ? el.closest("a[href]") : null;
if (!link || !canonicalEquipUrl(link.getAttribute("href") || link.href || "")) return;
if (link === currentLink && tip && !tip.hidden) {
window.clearTimeout(hideTimer);
return;
}
pointerX = event.clientX;
pointerY = event.clientY;
renderTip(link);
}, true);
document.addEventListener("keydown", (event) => {
if (event.repeat || event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return;
if (typeof event.key !== "string") return;
const key = event.key.toLowerCase();
if (key !== "f" && key !== "w") return;
const target = event.target;
if (target && target.closest && target.closest("input, textarea, select, [contenteditable='true']")) return;
event.preventDefault();
if (key === "f") {
showMaxForge = false;
showPercent = !showPercent;
try { localStorage.setItem("hvEquipTipPercent", showPercent ? "true" : "false"); } catch (error) {}
} else {
showPercent = false;
showMaxForge = !showMaxForge;
}
refreshOpenTip();
}, true);
document.addEventListener("pointerout", (event) => {
if (!currentLink || !tip || event.pointerType === "touch") return;
const el = eventElement(event);
if (!el) return;
if (!insideTip(el) && !currentLink.contains(el)) return;
const nextNode = event.relatedTarget;
if (nextNode && (insideTip(nextNode) || currentLink.contains(nextNode))) return;
window.clearTimeout(showTimer);
abortRequest();
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
currentLink = null;
currentUrl = "";
hideTip();
}, 200);
}, true);
}
root.HvEquipTip = {
url: canonicalEquipUrl,
open: openEquipWindow,
attach: attach,
isTranslate: isTranslate,
};
})(typeof globalThis !== "undefined" ? globalThis : this);