diablo2-web/src/game/item-tooltip.ts

432 lines
18 KiB
TypeScript

/**
* Diablo II Item Tooltip and Formatted Attribute Generator.
*
* Formats dropped Item instances into rich, canonical Diablo II tooltip data:
* - Quality colors and tiered title (Unique gold, Set green, Rare yellow, Magic blue, Rune orange, Gem purple)
* - Base equipment stats (Defense, 1H/2H Damage, Speed, Durability, Required Str/Dex/Level, ilvl)
* - Rolled magic modifiers, affix lines, and unique/set fixed modifiers formatted with authentic D2 strings
* - Sockets, rune effects, and gem socketing bonuses
*/
import type { Item, ItemBase } from './items.ts'
import type { UniqueItem, UniqueItemProp } from './unique-items.ts'
import type { SetItem, SetItemProp } from './set-items.ts'
import type { GeneratedAffixes, GeneratedRareAffixes, RolledAffix, RolledMod } from './affix-generator.ts'
export interface FormattedStatLine {
text: string
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray'
}
export interface FormattedItemTooltip {
title: string
subTitle?: string | undefined
quality: string
qualityColor: string
baseType: string
ilvl: number
reqLevel?: number | undefined
reqStr?: number | undefined
reqDex?: number | undefined
defense?: number | undefined
oneHandDamage?: { min: number; max: number } | undefined
twoHandDamage?: { min: number; max: number } | undefined
durability?: { current: number; max: number } | undefined
sockets?: number | undefined
lines: FormattedStatLine[]
setPieces?: string[] | undefined
setBonuses?: FormattedStatLine[] | undefined
flavorText?: string | undefined
}
// Canonical Chinese Translations for Common Base Names
const BASE_NAMES_CN: Record<string, string> = {
// Common Boss Drops & Favorites
uap: '军帽 (Shako)',
xap: '战帽 (War Hat)',
cap: '便帽 (Cap)',
uit: '统治者大盾 (Monarch)',
xit: '冷酷之盾 (Grim Shield)',
kit: '轻盾 (Kite Shield)',
uui: '海蛇皮甲 (Serpentskin Armor)',
xui: '鬼魂战甲 (Ghost Armor)',
qui: '皮甲 (Leather Armor)',
aar: '古代装甲 (Ancient Armor)',
uar: '神圣盔甲 (Sacred Armor)',
xar: '法师铠甲 (Mage Plate)',
ful: '全铠甲 (Full Plate Mail)',
gth: '哥特战甲 (Gothic Plate)',
rin: '戒指 (Ring)',
amu: '项链 (Amulet)',
jew: '珠宝 (Jewel)',
cm1: '小型护身符 (Small Charm)',
cm2: '大型护身符 (Large Charm)',
cm3: '超大型护身符 (Grand Charm)',
ob4: '涡流水晶 (Swirling Crystal)',
'7cr': '幻化之刃 (Phase Blade)',
crs: '水晶剑 (Crystal Sword)',
'9cr': '空间之刃 (Dimensional Blade)',
'7wa': '狂战士斧 (Berserker Axe)',
hax: '手斧 (Hand Axe)',
'9ha': '长柄斧 (Hatchet)',
'7gd': '巨神之刃 (Colossus Blade)',
gsd: '巨剑 (Great Sword)',
'7fl': '连枷 (Flail)',
fla: '连枷 (Flail)',
'9fl': '铁刺锤 (Knout)',
'7ws': '天罚之锤 (Scourge)',
gld: '金币 (Gold)',
tsc: '城镇传送卷轴 (Town Portal Scroll)',
isc: '辨识卷轴 (Identify Scroll)',
hp1: '微型生命药剂',
hp2: '轻型生命药剂',
hp3: '中型生命药剂',
hp4: '强效生命药剂',
hp5: '超级生命药剂',
mp1: '微型法力药剂',
mp2: '轻型法力药剂',
mp3: '中型法力药剂',
mp4: '强效法力药剂',
mp5: '超级法力药剂',
rvs: '恢复药剂 (35%)',
rvl: '全面恢复药剂 (100%)',
aqv: '箭矢 (Arrows)',
cqv: '十字弓弹 (Bolts)',
key: '钥匙 (Key)',
}
// Rune Names & Socket Effects
const RUNE_INFO: Record<string, { num: number; nameCn: string; nameEn: string; lvl: number; weapon: string; armor: string }> = {
r01: { num: 1, nameCn: '艾尔 (El)', nameEn: 'El Rune', lvl: 11, weapon: '+50 命中率, +1 点照亮范围', armor: '+15 防御, +1 点照亮范围' },
r02: { num: 2, nameCn: '艾德 (Eld)', nameEn: 'Eld Rune', lvl: 11, weapon: '+75% 对不死生物伤害, +50 针对不死生物命中率', armor: '降低体力消耗 15% (盾牌: +7% 格挡率)' },
r03: { num: 3, nameCn: '特尔 (Tir)', nameEn: 'Tir Rune', lvl: 13, weapon: '+2 点法力在每杀一个敌人后获得', armor: '+2 点法力在每杀一个敌人后获得' },
r04: { num: 4, nameCn: '那夫 (Nef)', nameEn: 'Nef Rune', lvl: 13, weapon: '击退目标 (Knockback)', armor: '+30 对远距离攻击防御' },
r05: { num: 5, nameCn: '爱斯 (Eth)', nameEn: 'Eth Rune', lvl: 15, weapon: '-25% 目标防御', armor: '法力回复速度 +15%' },
r06: { num: 6, nameCn: '伊夫 (Ith)', nameEn: 'Ith Rune', lvl: 15, weapon: '+9 最大伤害', armor: '15% 受损生命转化为法力' },
r07: { num: 7, nameCn: '塔尔 (Tal)', nameEn: 'Tal Rune', lvl: 17, weapon: '+75 毒素伤害持续 5 秒', armor: '抗毒 +30% (盾牌: 抗毒 +35%)' },
r08: { num: 8, nameCn: '拉尔 (Ral)', nameEn: 'Ral Rune', lvl: 19, weapon: '增加 5-30 火焰伤害', armor: '抗火 +30% (盾牌: 抗火 +35%)' },
r09: { num: 9, nameCn: '欧特 (Ort)', nameEn: 'Ort Rune', lvl: 21, weapon: '增加 1-50 闪电伤害', armor: '抗电 +30% (盾牌: 抗电 +35%)' },
r10: { num: 10, nameCn: '书尔 (Thul)', nameEn: 'Thul Rune', lvl: 23, weapon: '增加 3-14 冰冷伤害 (持续 3 秒)', armor: '抗寒 +30% (盾牌: 抗寒 +35%)' },
r11: { num: 11, nameCn: '安姆 (Amn)', nameEn: 'Amn Rune', lvl: 25, weapon: '每次命中偷取 7% 生命', armor: '攻击者受到 14 点反弹伤害' },
r12: { num: 12, nameCn: '索尔 (Sol)', nameEn: 'Sol Rune', lvl: 27, weapon: '+9 最小伤害', armor: '物理伤害减少 7' },
r13: { num: 13, nameCn: '夏 (Shael)', nameEn: 'Shael Rune', lvl: 29, weapon: '20% 提升攻击速度 (IAS)', armor: '20% 快速打击恢复 (盾牌: 20% 快速格挡率)' },
r14: { num: 14, nameCn: '多尔 (Dol)', nameEn: 'Dol Rune', lvl: 31, weapon: '击中使怪物逃跑 (25%)', armor: '生命恢复 +7' },
r15: { num: 15, nameCn: '海尔 (Hel)', nameEn: 'Hel Rune', lvl: 0, weapon: '装备需求 -20%', armor: '装备需求 -15%' },
r16: { num: 16, nameCn: '埃欧 (Io)', nameEn: 'Io Rune', lvl: 35, weapon: '+10 体力', armor: '+10 体力' },
r17: { num: 17, nameCn: '卢姆 (Lum)', nameEn: 'Lum Rune', lvl: 37, weapon: '+10 精力', armor: '+10 精力' },
r18: { num: 18, nameCn: '科 (Ko)', nameEn: 'Ko Rune', lvl: 39, weapon: '+10 敏捷', armor: '+10 敏捷' },
r19: { num: 19, nameCn: '法尔 (Fal)', nameEn: 'Fal Rune', lvl: 41, weapon: '+10 力量', armor: '+10 力量' },
r20: { num: 20, nameCn: '蓝姆 (Lem)', nameEn: 'Lem Rune', lvl: 43, weapon: '75% 额外金币从怪物身上获得', armor: '50% 额外金币从怪物身上获得' },
r21: { num: 21, nameCn: '普尔 (Pul)', nameEn: 'Pul Rune', lvl: 45, weapon: '+75% 对恶魔伤害, +100 对恶魔命中率', armor: '+30% 增强防御' },
r22: { num: 22, nameCn: '乌姆 (Um)', nameEn: 'Um Rune', lvl: 47, weapon: '25% 几率造成伤口流血 (OW)', armor: '所有抗性 +15 (盾牌: 所有抗性 +22)' },
r23: { num: 23, nameCn: '马尔 (Mal)', nameEn: 'Mal Rune', lvl: 49, weapon: '防止怪物自愈', armor: '魔法伤害减少 7' },
r24: { num: 24, nameCn: '伊司特 (Ist)', nameEn: 'Ist Rune', lvl: 51, weapon: '30% 更加寻获魔法装备 (MF)', armor: '25% 更加寻获魔法装备 (MF)' },
r25: { num: 25, nameCn: '古尔 (Gul)', nameEn: 'Gul Rune', lvl: 53, weapon: '20% 额外攻击命中率', armor: '+5% 最大毒素抗性' },
r26: { num: 26, nameCn: '伐克斯 (Vex)', nameEn: 'Vex Rune', lvl: 55, weapon: '每次命中偷取 7% 法力', armor: '+5% 最大火焰抗性' },
r27: { num: 27, nameCn: '欧姆 (Ohm)', nameEn: 'Ohm Rune', lvl: 57, weapon: '+50% 增强伤害 (ED)', armor: '+5% 最大冰冷抗性' },
r28: { num: 28, nameCn: '罗 (Lo)', nameEn: 'Lo Rune', lvl: 59, weapon: '20% 几率造成双倍打击 (DS)', armor: '+5% 最大闪电抗性' },
r29: { num: 29, nameCn: '瑟 (Sur)', nameEn: 'Sur Rune', lvl: 61, weapon: '击中使目标目盲', armor: '最大法力提升 5% (盾牌: +50 法力)' },
r30: { num: 30, nameCn: '贝 (Ber)', nameEn: 'Ber Rune', lvl: 63, weapon: '20% 几率产生压碎打击 (CB)', armor: '物理伤害减少 8% (DR)' },
r31: { num: 31, nameCn: '乔 (Jah)', nameEn: 'Jah Rune', lvl: 65, weapon: '无视目标防御力 (ITD)', armor: '最大生命提升 5% (盾牌: +50 生命)' },
r32: { num: 32, nameCn: '查姆 (Cham)', nameEn: 'Cham Rune', lvl: 67, weapon: '击中使目标冻结 +3', armor: '无法被冻结 (Cannot be Frozen)' },
r33: { num: 33, nameCn: '萨德 (Zod)', nameEn: 'Zod Rune', lvl: 69, weapon: '无法破坏 (Indestructible)', armor: '无法破坏 (Indestructible)' },
}
// Property Code Formatter
export function formatPropertyCode(code: string, min: number, max: number, par?: string | number): string {
const isRange = max > min
const valStr = isRange ? `${min}-${max}` : `${min}`
const plusVal = min > 0 ? `+${valStr}` : `${valStr}`
switch (code.toLowerCase()) {
case 'allskills':
return `${plusVal} 所有技能`
case 'skilltab':
return `${plusVal} 技能分类 (${par ?? ''})`
case 'classskills':
return `${plusVal} 职业技能 (${par ?? ''})`
case 'skill':
return `${plusVal} 到技能 [${par ?? ''}]`
case 'str':
return `${plusVal} 力量`
case 'dex':
return `${plusVal} 敏捷`
case 'vit':
return `${plusVal} 体力`
case 'enr':
return `${plusVal} 精力`
case 'all-stats':
return `${plusVal} 所有属性`
case 'hp':
return `${plusVal} 生命`
case 'mana':
return `${plusVal} 法力`
case 'hp/lvl':
return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 生命 (依角色等级决定)`
case 'mana/lvl':
return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 法力 (依角色等级决定)`
case 'ac':
return `${plusVal} 防御力`
case 'ac%':
return `${plusVal}% 增强防御`
case 'dmg%':
return `${plusVal}% 增强伤害 (ED)`
case 'dmg-min':
return `+${valStr} 最小伤害值`
case 'dmg-max':
return `+${valStr} 最大伤害值`
case 'res-all':
return `所有抗性 ${plusVal}%`
case 'res-fire':
case 'fireresist':
return `抗火 ${plusVal}%`
case 'res-cold':
case 'coldresist':
return `抗寒 ${plusVal}%`
case 'res-ltng':
case 'lightresist':
return `抗电 ${plusVal}%`
case 'res-pois':
case 'poisonresist':
return `抗毒 ${plusVal}%`
case 'fcr':
return `${plusVal}% 快速施法速度 (FCR)`
case 'fhr':
return `${plusVal}% 快速打击恢复 (FHR)`
case 'ias':
return `${plusVal}% 提升攻击速度 (IAS)`
case 'frw':
return `${plusVal}% 快速移动/奔跑 (FRW)`
case 'fbr':
return `${plusVal}% 快速格挡率 (FBR)`
case 'mag%':
return `${plusVal}% 更加寻获魔法装备 (MF)`
case 'gold%':
return `${plusVal}% 额外金币从怪物身上获得`
case 'lifesteal':
case 'leech':
return `每次击中偷取 ${valStr}% 生命`
case 'manasteal':
return `每次击中偷取 ${valStr}% 法力`
case 'crush':
return `${valStr}% 几率产生压碎打击 (CB)`
case 'openwounds':
return `${valStr}% 几率造成伤口流血 (OW)`
case 'deadly':
return `${valStr}% 几率造成双倍打击 (DS)`
case 'dmag':
return `伤害减少 ${valStr}% (物免)`
case 'dmag-ac':
return `物理伤害减少 ${valStr}`
case 'mag-ac':
return `魔法伤害减少 ${valStr}`
case 'sock':
return `凹槽 (${valStr})`
case 'indestruct':
return `无法破坏`
case 'no-freeze':
case 'nofreeze':
return `无法被冻结`
case 'half-freeze':
return `冻结时间减半`
case 'rep-life':
case 'regen':
return `生命恢复 +${valStr}`
case 'regen-mana':
case 'manaregen':
return `法力恢复速度 +${valStr}%`
case 'pierce':
return `穿透攻击 (${valStr}%)`
case 'ignore-ac':
return `无视目标防御力`
case 'freeze':
return `击中使目标冰冻 +${valStr}`
case 'slow':
return `使目标减速 ${valStr}%`
case 'att':
return `${plusVal} 攻击命中率`
case 'att%':
return `${plusVal}% 额外攻击命中率`
case 'dmg-fire':
return `增加 ${min}-${max} 点火焰伤害`
case 'dmg-cold':
return `增加 ${min}-${max} 点冰冷伤害`
case 'dmg-ltng':
return `增加 ${min}-${max} 点闪电伤害`
case 'dmg-pois':
return `增加 ${min} 点毒素伤害,持续 ${max} 秒`
case 'dmg-mag':
return `增加 ${min}-${max} 点魔法伤害`
default:
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
}
}
/**
* Generates formatted tooltip structure for any dropped Diablo II Item.
*/
export function formatItemTooltip(item: Item): FormattedItemTooltip {
const base = item.base
const quality = (item.rarity || 'normal').toLowerCase()
const ilvl = item.level ?? item.ilvl ?? 1
let title = item.name
let subTitle: string | undefined = undefined
let qualityColor: string = '#ffffff'
const lines: FormattedStatLine[] = []
// Check if this is a Rune
const isRune = item.code?.trim().startsWith('r') && Number(item.code.trim().slice(1)) >= 1 && Number(item.code.trim().slice(1)) <= 33
const runeKey = item.code?.trim().toLowerCase()
const runeData = isRune && runeKey ? RUNE_INFO[runeKey] : undefined
if (runeData) {
title = `${runeData.num}# ${runeData.nameCn}`
subTitle = runeData.nameEn
qualityColor = '#ff8c00'
lines.push({ text: `可以镶嵌在有凹槽的装备中`, color: 'white' })
lines.push({ text: `武器: ${runeData.weapon}`, color: 'blue' })
lines.push({ text: `防具/盾牌: ${runeData.armor}`, color: 'blue' })
return {
title,
subTitle,
quality: '符文 (Rune)',
qualityColor,
baseType: '符文',
ilvl,
reqLevel: runeData.lvl > 0 ? runeData.lvl : undefined,
lines,
}
}
// Check if this is Gold
if (base.id === 'gld' || item.name.toLowerCase().includes('gold')) {
title = `${(item.stats.amount ?? item.value ?? 100).toLocaleString()} 金币 (Gold)`
qualityColor = '#ffd700'
return {
title,
quality: '金币',
qualityColor,
baseType: '金币',
ilvl: 1,
lines: [{ text: '可用于向城镇商贩交易、赌博或雇佣兵复活', color: 'white' }],
}
}
// Resolve Base Chinese Name
const baseNameCn = BASE_NAMES_CN[base.id] ?? base.name
// Determine Quality Presentation
if (quality === 'unique') {
qualityColor = '#c8a15a'
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
if (uniqDef) {
title = uniqDef.index
subTitle = `${baseNameCn}`
// Add Unique Properties
for (const prop of uniqDef.props) {
if (prop.code) {
lines.push({
text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par),
color: 'blue',
})
}
}
} else {
subTitle = baseNameCn
}
} else if (quality === 'set') {
qualityColor = '#00e600'
const setDef = item.setItemDef as SetItem | undefined
if (setDef) {
title = setDef.index
subTitle = `${baseNameCn} · [${setDef.set}]`
for (const prop of setDef.props) {
if (prop.code) {
lines.push({
text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par),
color: 'blue',
})
}
}
} else {
subTitle = baseNameCn
}
} else if (quality === 'rare') {
qualityColor = '#ffff60'
subTitle = baseNameCn
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
if (rareDef && rareDef.affixes) {
title = rareDef.name
for (const aff of rareDef.affixes) {
for (const mod of aff.mods) {
lines.push({
text: formatPropertyCode(mod.code, mod.min, mod.max),
color: 'blue',
})
}
}
}
} else if (quality === 'magic') {
qualityColor = '#4880ff'
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
if (magicDef) {
title = magicDef.name
subTitle = baseNameCn
if (magicDef.prefix) {
for (const m of magicDef.prefix.mods) {
lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' })
}
}
if (magicDef.suffix) {
for (const m of magicDef.suffix.mods) {
lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' })
}
}
} else {
subTitle = baseNameCn
}
} else if (quality === 'superior') {
qualityColor = '#ffffff'
title = `超强的 ${baseNameCn}`
lines.push({ text: '+15% 增强防御 / +15% 增强伤害', color: 'blue' })
} else {
qualityColor = '#e0d5c1'
title = baseNameCn
}
// Equipment Base Stats
const reqLevel = (base as any).levelreq > 0 ? (base as any).levelreq : undefined
const reqStr = (base as any).reqstr > 0 ? (base as any).reqstr : undefined
const reqDex = (base as any).reqdex > 0 ? (base as any).reqdex : undefined
const defense = base.defense > 0 ? (item.stats.defense ?? base.defense) : undefined
const oneHandDamage = (base as any).mindam && (base as any).maxdam
? { min: (base as any).mindam, max: (base as any).maxdam }
: undefined
const twoHandDamage = (base as any).twoHandedMindam && (base as any).twoHandedMaxdam
? { min: (base as any).twoHandedMindam, max: (base as any).twoHandedMaxdam }
: undefined
const durability = item.durability && item.maxDurability
? { current: item.durability, max: item.maxDurability }
: undefined
const sockets = (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
return {
title,
subTitle,
quality,
qualityColor,
baseType: base.kind === 'weapon' ? '武器' : base.kind === 'armor' ? '防具' : '杂项/首饰',
ilvl,
reqLevel,
reqStr,
reqDex,
defense,
oneHandDamage,
twoHandDamage,
durability,
sockets,
lines,
}
}