659 lines
24 KiB
TypeScript
659 lines
24 KiB
TypeScript
/**
|
||
* Diablo II Act Boss Loot Simulator (Issue #110).
|
||
*
|
||
* Implements:
|
||
* - Full Act 1-5 Boss selection (Andariel, Duriel, Mephisto, Diablo, Baal) across 3 difficulties.
|
||
* - Player count (1-8 Players / PP) scaling on NoDrop.
|
||
* - Magic Find (0-1000% MF) quality scaling with diminishing returns.
|
||
* - Quest Kill (First kill / Bug kill) TreasureClass toggle.
|
||
* - Complete 1.13c drop execution with fully rolled affixes and item properties.
|
||
* - Canonical D2 item tooltip presentation with equipment stats, blue affix mods, runes, and sets.
|
||
*/
|
||
|
||
import { getEmbeddedDropTables } from './game/embedded-drop-tables.ts'
|
||
import { executeDropPipeline, type DropTables } from './game/drop-pipeline.ts'
|
||
import { formatItemTooltip, type FormattedItemTooltip } from './game/item-tooltip.ts'
|
||
import { getTooltipLocale, setTooltipLocale, type TooltipLocale } from './game/tooltip-i18n.ts'
|
||
import type { Item } from './game/items.ts'
|
||
import { computeScaledNoDrop, computeEffectivePlayers } from './game/treasure-engine.ts'
|
||
|
||
export type Difficulty = 'normal' | 'nightmare' | 'hell'
|
||
|
||
export interface BossPreset {
|
||
readonly id: string
|
||
readonly act: 1 | 2 | 3 | 4 | 5
|
||
readonly nameCn: string
|
||
readonly nameEn: string
|
||
readonly titleCn: string
|
||
readonly areaCn: string
|
||
readonly icon: string
|
||
readonly mlvl: { normal: number; nightmare: number; hell: number }
|
||
readonly tc: { normal: string; nightmare: string; hell: string }
|
||
readonly questTc: { normal: string; nightmare: string; hell: string }
|
||
readonly baseNoDrop: number
|
||
readonly picks: number
|
||
readonly monsterType?: number
|
||
readonly descCn: string
|
||
readonly highlightDrops: string
|
||
}
|
||
|
||
export type BossDefinition = BossPreset
|
||
|
||
export const ACT_BOSSES: readonly BossPreset[] = [
|
||
{
|
||
id: 'andariel',
|
||
act: 1,
|
||
nameCn: '安达利尔 (Andariel)',
|
||
nameEn: 'Maiden of Anguish',
|
||
titleCn: '第一幕 · 痛苦之貌',
|
||
areaCn: '地下墓穴第四层 (Catacombs Level 4)',
|
||
icon: '🕷️',
|
||
mlvl: { normal: 12, nightmare: 49, hell: 75 },
|
||
tc: { normal: 'Andariel', nightmare: 'Andariel (N)', hell: 'Andariel (H)' },
|
||
questTc: { normal: 'Andarielq', nightmare: 'Andarielq (N)', hell: 'Andarielq (H)' },
|
||
baseNoDrop: 19,
|
||
picks: 7,
|
||
monsterType: 4,
|
||
descCn: '第一幕关底首领,弱火(-50%火抗)。暗黑2历史上著名的Bug首杀核心首领,乔丹之石 (SOJ) 掉率全游戏最高!',
|
||
highlightDrops: '乔丹之石 (SOJ)、暗金戒指/项链、海蛇皮甲',
|
||
},
|
||
{
|
||
id: 'duriel',
|
||
act: 2,
|
||
nameCn: '督瑞尔 (Duriel)',
|
||
nameEn: 'Lord of Pain',
|
||
titleCn: '第二幕 · 苦痛之王',
|
||
areaCn: '塔·拉夏的古墓 (Tal Rasha\'s Chamber)',
|
||
icon: '🐛',
|
||
mlvl: { normal: 22, nightmare: 55, hell: 88 },
|
||
tc: { normal: 'Duriel', nightmare: 'Duriel (N)', hell: 'Duriel (H)' },
|
||
questTc: { normal: 'Durielq', nightmare: 'Durielq (N)', hell: 'Durielq (H)' },
|
||
baseNoDrop: 19,
|
||
picks: 7,
|
||
monsterType: 4,
|
||
descCn: '第二幕关底首领,神圣冰冻光环。回城卷轴掉落特化,首杀可获得丰富的中级暗金装备与首饰。',
|
||
highlightDrops: '督瑞尔的壳、暗金首饰、高级符文',
|
||
},
|
||
{
|
||
id: 'mephisto',
|
||
act: 3,
|
||
nameCn: '墨菲斯托 (Mephisto)',
|
||
nameEn: 'Lord of Hatred',
|
||
titleCn: '第三幕 · 憎恨之王',
|
||
areaCn: '憎恨囚牢第三层 (Durance of Hate Level 3)',
|
||
icon: '💀',
|
||
mlvl: { normal: 26, nightmare: 59, hell: 87 },
|
||
tc: { normal: 'Mephisto', nightmare: 'Mephisto (N)', hell: 'Mephisto (H)' },
|
||
questTc: { normal: 'Mephistoq', nightmare: 'Mephistoq (N)', hell: 'Mephistoq (H)' },
|
||
baseNoDrop: 15,
|
||
picks: 7,
|
||
monsterType: 4,
|
||
descCn: '第三幕关底首领,全暗黑2玩家公认最受爱戴的「劳模」。地形隔河击杀简单,全游戏刷军帽、眼球、暴风盾等核心日用品效率之王!',
|
||
highlightDrops: '军帽 (Shako)、暴风之盾、眼球、海蛇皮、亚瑞特的面容',
|
||
},
|
||
{
|
||
id: 'diablo',
|
||
act: 4,
|
||
nameCn: '暗黑破坏神 (Diablo)',
|
||
nameEn: 'Lord of Terror',
|
||
titleCn: '第四幕 · 恐惧之王',
|
||
areaCn: '混沌避难所 (Chaos Sanctuary)',
|
||
icon: '👹',
|
||
mlvl: { normal: 40, nightmare: 62, hell: 94 },
|
||
tc: { normal: 'Diablo', nightmare: 'Diablo (N)', hell: 'Diablo (H)' },
|
||
questTc: { normal: 'Diabloq', nightmare: 'Diabloq (N)', hell: 'Diabloq (H)' },
|
||
baseNoDrop: 15,
|
||
picks: 7,
|
||
monsterType: 4,
|
||
descCn: '第四幕关底首领,红色闪电地狱火。mlvl高达94,能掉落大君之怒、马拉的万花筒等全系列高级暗金护身符与首饰。',
|
||
highlightDrops: '马拉的万花筒、大君之怒、丧钟、风之锤',
|
||
},
|
||
{
|
||
id: 'baal',
|
||
act: 5,
|
||
nameCn: '巴尔 (Baal)',
|
||
nameEn: 'Lord of Destruction',
|
||
titleCn: '第五幕 · 毁灭之王',
|
||
areaCn: '世界之石大厅 (Worldstone Chamber)',
|
||
icon: '👑',
|
||
mlvl: { normal: 60, nightmare: 75, hell: 99 },
|
||
tc: { normal: 'Baal', nightmare: 'Baal (N)', hell: 'Baal (H)' },
|
||
questTc: { normal: 'Baalq', nightmare: 'Baalq (N)', hell: 'Baalq (H)' },
|
||
baseNoDrop: 15,
|
||
picks: 7,
|
||
monsterType: 4,
|
||
descCn: '终局最高首领,地狱难度怪物等级 mlvl 99!能掉落全暗黑2所有最高等级的宝藏(tc87/qlvl85全部封顶物品:风之力、泰瑞尔的力量、祖父等)。',
|
||
highlightDrops: '风之力 (Windforce)、泰瑞尔的力量、祖父、死亡之网、全符文',
|
||
},
|
||
]
|
||
|
||
export const BOSS_LIST: readonly BossDefinition[] = ACT_BOSSES
|
||
|
||
// State
|
||
let dropTables: DropTables | null = null
|
||
let currentBoss: BossPreset = ACT_BOSSES[2]! // Default: Mephisto
|
||
let currentDifficulty: Difficulty = 'hell'
|
||
let isQuestKill: boolean = true
|
||
let currentPlayers: number = 1
|
||
let currentMf: number = 250
|
||
|
||
interface DropSessionRecord {
|
||
readonly killIndex: number
|
||
readonly timestamp: number
|
||
readonly boss: BossPreset
|
||
readonly difficulty: Difficulty
|
||
readonly isQuest: boolean
|
||
readonly players: number
|
||
readonly mf: number
|
||
readonly items: Item[]
|
||
}
|
||
|
||
const sessionHistory: DropSessionRecord[] = []
|
||
let latestDroppedItems: Item[] = []
|
||
let totalKills = 0
|
||
let totalItemsDropped = 0
|
||
let totalUniques = 0
|
||
let totalSets = 0
|
||
let totalRares = 0
|
||
let totalMagics = 0
|
||
let totalRunes = 0
|
||
|
||
// Initialize
|
||
export function initBossApp(): void {
|
||
// 1. Initialize Drop Tables
|
||
try {
|
||
dropTables = getEmbeddedDropTables()
|
||
console.log('Diablo II Drop Tables loaded successfully from canonical data.')
|
||
} catch (err) {
|
||
console.error('Failed to load drop tables:', err)
|
||
}
|
||
|
||
// 2. Bind DOM Events
|
||
bindBossSelector()
|
||
bindDifficultySelector()
|
||
bindSettings()
|
||
bindActionButtons()
|
||
bindLanguageSelector()
|
||
bindKeyboardShortcuts()
|
||
|
||
// 3. Initial UI update
|
||
updateBossCard()
|
||
updateParamTips()
|
||
renderLootShowcase([])
|
||
}
|
||
|
||
function bindBossSelector(): void {
|
||
const container = document.getElementById('boss-list-container')
|
||
if (!container) return
|
||
|
||
container.innerHTML = ACT_BOSSES.map(b => {
|
||
const isSelected = b.id === currentBoss.id
|
||
return `
|
||
<div class="boss-select-card ${isSelected ? 'selected' : ''}" data-boss-id="${b.id}">
|
||
<div class="boss-card-icon">${b.icon}</div>
|
||
<div class="boss-card-info">
|
||
<div class="boss-card-title">${b.nameCn}</div>
|
||
<div class="boss-card-sub">${b.titleCn}</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}).join('')
|
||
|
||
container.querySelectorAll('.boss-select-card').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const id = el.getAttribute('data-boss-id')
|
||
const found = ACT_BOSSES.find(b => b.id === id)
|
||
if (found) {
|
||
currentBoss = found
|
||
container.querySelectorAll('.boss-select-card').forEach(c => c.classList.remove('selected'))
|
||
el.classList.add('selected')
|
||
updateBossCard()
|
||
updateParamTips()
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
function bindDifficultySelector(): void {
|
||
const pills = document.querySelectorAll<HTMLButtonElement>('.diff-pill')
|
||
pills.forEach(pill => {
|
||
pill.addEventListener('click', () => {
|
||
pills.forEach(p => p.classList.remove('active'))
|
||
pill.classList.add('active')
|
||
currentDifficulty = (pill.getAttribute('data-diff') as Difficulty) || 'hell'
|
||
updateBossCard()
|
||
updateParamTips()
|
||
})
|
||
})
|
||
}
|
||
|
||
function bindSettings(): void {
|
||
// Quest Kill checkbox
|
||
const questCheck = document.getElementById('quest-kill-toggle') as HTMLInputElement | null
|
||
if (questCheck) {
|
||
questCheck.checked = isQuestKill
|
||
questCheck.addEventListener('change', () => {
|
||
isQuestKill = questCheck.checked
|
||
updateBossCard()
|
||
updateParamTips()
|
||
})
|
||
}
|
||
|
||
// Players slider & pills
|
||
const playersRange = document.getElementById('players-range') as HTMLInputElement | null
|
||
const playersVal = document.getElementById('players-val')
|
||
if (playersRange && playersVal) {
|
||
playersRange.value = String(currentPlayers)
|
||
playersVal.textContent = `${currentPlayers}P`
|
||
playersRange.addEventListener('input', () => {
|
||
currentPlayers = Number(playersRange.value)
|
||
playersVal.textContent = `${currentPlayers}P`
|
||
updatePlayersPillActive(currentPlayers)
|
||
updateParamTips()
|
||
})
|
||
}
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('.pp-pill').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const p = Number(btn.getAttribute('data-pp'))
|
||
if (p >= 1 && p <= 8) {
|
||
currentPlayers = p
|
||
if (playersRange) playersRange.value = String(p)
|
||
if (playersVal) playersVal.textContent = `${p}P`
|
||
updatePlayersPillActive(p)
|
||
updateParamTips()
|
||
}
|
||
})
|
||
})
|
||
|
||
// MF slider & input
|
||
const mfRange = document.getElementById('mf-range') as HTMLInputElement | null
|
||
const mfInput = document.getElementById('mf-input') as HTMLInputElement | null
|
||
if (mfRange && mfInput) {
|
||
mfRange.value = String(currentMf)
|
||
mfInput.value = String(currentMf)
|
||
|
||
mfRange.addEventListener('input', () => {
|
||
currentMf = Math.max(0, Math.min(1000, Number(mfRange.value)))
|
||
mfInput.value = String(currentMf)
|
||
updateParamTips()
|
||
})
|
||
|
||
mfInput.addEventListener('change', () => {
|
||
currentMf = Math.max(0, Math.min(1000, Number(mfInput.value) || 0))
|
||
mfRange.value = String(currentMf)
|
||
mfInput.value = String(currentMf)
|
||
updateParamTips()
|
||
})
|
||
}
|
||
|
||
// MF quick presets
|
||
document.querySelectorAll<HTMLButtonElement>('.mf-preset-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const v = Number(btn.getAttribute('data-mf'))
|
||
currentMf = v
|
||
if (mfRange) mfRange.value = String(v)
|
||
if (mfInput) mfInput.value = String(v)
|
||
updateParamTips()
|
||
})
|
||
})
|
||
}
|
||
|
||
function updatePlayersPillActive(p: number): void {
|
||
document.querySelectorAll<HTMLButtonElement>('.pp-pill').forEach(btn => {
|
||
btn.classList.toggle('active', Number(btn.getAttribute('data-pp')) === p)
|
||
})
|
||
}
|
||
|
||
function updateBossCard(): void {
|
||
const iconEl = document.getElementById('boss-preview-icon')
|
||
const nameEl = document.getElementById('boss-preview-name')
|
||
const titleEl = document.getElementById('boss-preview-title')
|
||
const areaEl = document.getElementById('boss-preview-area')
|
||
const mlvlEl = document.getElementById('boss-preview-mlvl')
|
||
const tcEl = document.getElementById('boss-preview-tc')
|
||
const questEl = document.getElementById('boss-preview-quest')
|
||
const descEl = document.getElementById('boss-preview-desc')
|
||
const dropsEl = document.getElementById('boss-preview-highlights')
|
||
|
||
const mlvl = currentBoss.mlvl[currentDifficulty]
|
||
const effectiveTc = isQuestKill ? currentBoss.questTc[currentDifficulty] : currentBoss.tc[currentDifficulty]
|
||
|
||
if (iconEl) iconEl.textContent = currentBoss.icon
|
||
if (nameEl) nameEl.textContent = currentBoss.nameCn
|
||
if (titleEl) titleEl.textContent = `${currentBoss.titleCn} · [${currentDifficulty.toUpperCase()}]`
|
||
if (areaEl) areaEl.textContent = currentBoss.areaCn
|
||
if (mlvlEl) mlvlEl.textContent = `mlvl ${mlvl}`
|
||
if (tcEl) tcEl.textContent = `TC: ${effectiveTc}`
|
||
if (questEl) {
|
||
questEl.textContent = isQuestKill ? '🌟 首杀判定启用 (Quest Kill)' : '常规击杀 (Standard Kill)'
|
||
questEl.className = isQuestKill ? 'boss-quest-badge quest-on' : 'boss-quest-badge quest-off'
|
||
}
|
||
if (descEl) descEl.textContent = currentBoss.descCn
|
||
if (dropsEl) dropsEl.textContent = currentBoss.highlightDrops
|
||
}
|
||
|
||
function updateParamTips(): void {
|
||
const nodropTip = document.getElementById('tip-nodrop')
|
||
const mfTip = document.getElementById('tip-mf')
|
||
|
||
const effectivePlayers = computeEffectivePlayers(currentPlayers, 1)
|
||
const scaledNoDrop = computeScaledNoDrop(currentBoss.baseNoDrop, 65, effectivePlayers)
|
||
const nodropPercent = ((scaledNoDrop / (scaledNoDrop + 65)) * 100).toFixed(1)
|
||
|
||
if (nodropTip) {
|
||
nodropTip.textContent = `${currentPlayers}P 模式下有效玩家数 ${effectivePlayers.toFixed(1)},单次挑选无掉落率: ${nodropPercent}%`
|
||
}
|
||
|
||
if (mfTip) {
|
||
// 1.13c Diminishing returns: Unique = (MF * 250) / (MF + 250)
|
||
const effectiveUniqueMf = Math.trunc((currentMf * 250) / (currentMf + 250))
|
||
const effectiveRareMf = Math.trunc((currentMf * 600) / (currentMf + 600))
|
||
mfTip.textContent = `实际寻宝率 ${currentMf}% · 暗金等效: +${effectiveUniqueMf}% · 黄金等效: +${effectiveRareMf}%`
|
||
}
|
||
}
|
||
|
||
function bindActionButtons(): void {
|
||
const killBtn = document.getElementById('btn-kill-boss')
|
||
const kill10Btn = document.getElementById('btn-kill-10')
|
||
const clearBtn = document.getElementById('btn-clear-loot')
|
||
|
||
if (killBtn) {
|
||
killBtn.addEventListener('click', () => {
|
||
executeBossKill(1)
|
||
})
|
||
}
|
||
|
||
if (kill10Btn) {
|
||
kill10Btn.addEventListener('click', () => {
|
||
executeBossKill(10)
|
||
})
|
||
}
|
||
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', () => {
|
||
latestDroppedItems = []
|
||
renderLootShowcase([])
|
||
})
|
||
}
|
||
}
|
||
|
||
function bindLanguageSelector(): void {
|
||
const pills = document.querySelectorAll<HTMLButtonElement>('.lang-pill')
|
||
pills.forEach(pill => {
|
||
pill.addEventListener('click', () => {
|
||
const lang = pill.dataset.lang as TooltipLocale | undefined
|
||
if (!lang) return
|
||
setTooltipLocale(lang)
|
||
pills.forEach(p => p.classList.toggle('active', p === pill))
|
||
renderLootShowcase(latestDroppedItems)
|
||
})
|
||
})
|
||
}
|
||
|
||
function bindKeyboardShortcuts(): void {
|
||
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||
// Don't trigger if typing in an input
|
||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||
return
|
||
}
|
||
if (e.code === 'Space' || e.code === 'Enter') {
|
||
e.preventDefault()
|
||
executeBossKill(1)
|
||
}
|
||
})
|
||
}
|
||
|
||
// Boss Kill Execution
|
||
export function executeBossKill(times: number = 1): void {
|
||
if (!dropTables) {
|
||
alert('掉落数据表仍在加载中,请稍候...')
|
||
return
|
||
}
|
||
|
||
const effectiveTc = isQuestKill ? currentBoss.questTc[currentDifficulty] : currentBoss.tc[currentDifficulty]
|
||
const nLevel = currentBoss.mlvl[currentDifficulty]
|
||
|
||
const batchItems: Item[] = []
|
||
const startTime = performance.now()
|
||
|
||
for (let i = 0; i < times; i++) {
|
||
const drops = executeDropPipeline({
|
||
tcName: effectiveTc,
|
||
nLevel,
|
||
monsterType: currentBoss.monsterType ?? 4,
|
||
dropTables,
|
||
difficulty: currentDifficulty,
|
||
playerMf: currentMf,
|
||
gamePlayers: currentPlayers,
|
||
partyPlayers: 1,
|
||
})
|
||
|
||
totalKills += 1
|
||
totalItemsDropped += drops.length
|
||
|
||
for (const item of drops) {
|
||
const q = (item.rarity || 'normal').toLowerCase()
|
||
if (q === 'unique') totalUniques++
|
||
else if (q === 'set') totalSets++
|
||
else if (q === 'rare') totalRares++
|
||
else if (q === 'magic') totalMagics++
|
||
else if (item.code?.trim().startsWith('r')) totalRunes++
|
||
|
||
batchItems.push(item)
|
||
}
|
||
|
||
sessionHistory.push({
|
||
killIndex: totalKills,
|
||
timestamp: Date.now(),
|
||
boss: currentBoss,
|
||
difficulty: currentDifficulty,
|
||
isQuest: isQuestKill,
|
||
players: currentPlayers,
|
||
mf: currentMf,
|
||
items: drops,
|
||
})
|
||
}
|
||
|
||
const durationMs = (performance.now() - startTime).toFixed(1)
|
||
|
||
// Trigger Animation
|
||
flashKillEffect()
|
||
|
||
// Update Stats & UI
|
||
updateStatsDisplay()
|
||
renderKillBanner(times, batchItems.length, durationMs)
|
||
renderLootShowcase(batchItems)
|
||
}
|
||
|
||
function flashKillEffect(): void {
|
||
const showcase = document.getElementById('loot-showcase-panel')
|
||
if (showcase) {
|
||
showcase.classList.remove('kill-flash')
|
||
void showcase.offsetWidth // force reflow
|
||
showcase.classList.add('kill-flash')
|
||
}
|
||
}
|
||
|
||
function updateStatsDisplay(): void {
|
||
const killsEl = document.getElementById('stat-total-kills')
|
||
const itemsEl = document.getElementById('stat-total-items')
|
||
const uniqEl = document.getElementById('stat-uniques')
|
||
const setEl = document.getElementById('stat-sets')
|
||
const rareEl = document.getElementById('stat-rares')
|
||
const magicEl = document.getElementById('stat-magics')
|
||
const runeEl = document.getElementById('stat-runes')
|
||
|
||
if (killsEl) killsEl.textContent = totalKills.toLocaleString()
|
||
if (itemsEl) itemsEl.textContent = totalItemsDropped.toLocaleString()
|
||
if (uniqEl) uniqEl.textContent = totalUniques.toLocaleString()
|
||
if (setEl) setEl.textContent = totalSets.toLocaleString()
|
||
if (rareEl) rareEl.textContent = totalRares.toLocaleString()
|
||
if (magicEl) magicEl.textContent = totalMagics.toLocaleString()
|
||
if (runeEl) runeEl.textContent = totalRunes.toLocaleString()
|
||
}
|
||
|
||
function renderKillBanner(times: number, dropCount: number, durationMs: string): void {
|
||
const banner = document.getElementById('kill-result-banner')
|
||
if (!banner) return
|
||
|
||
const diffNames = { normal: '普通', nightmare: '噩梦', hell: '地狱' }
|
||
const bossText = `${diffNames[currentDifficulty]}难度 · ${currentBoss.nameCn}`
|
||
const questText = isQuestKill ? '🌟 任务首杀' : '常规击杀'
|
||
|
||
banner.innerHTML = `
|
||
<div class="banner-main">
|
||
<span class="banner-icon">💥</span>
|
||
<span class="banner-title">${times > 1 ? `连续击杀 ${times} 次完成!` : `${bossText} 倒下了!`}</span>
|
||
<span class="banner-sub">获得 <strong>${dropCount}</strong> 件掉落战利品 (${questText}, 用时 ${durationMs}ms)</span>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
function renderLootShowcase(items: Item[]): void {
|
||
latestDroppedItems = items
|
||
const container = document.getElementById('loot-items-grid')
|
||
if (!container) return
|
||
|
||
const locale = getTooltipLocale()
|
||
|
||
if (items.length === 0) {
|
||
const isZh = locale === 'zh' || locale === 'bilingual'
|
||
container.innerHTML = `
|
||
<div class="empty-loot-placeholder">
|
||
<div class="placeholder-icon">⚔️</div>
|
||
<div class="placeholder-title">${isZh ? '尚未击杀首领' : 'No Boss Slain Yet'}</div>
|
||
<div class="placeholder-desc">${isZh ? '点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成' : 'Click "Kill Boss" on the left sidebar or press [Space] to simulate authentic 1.13c drops & affixes.'}</div>
|
||
</div>
|
||
`
|
||
return
|
||
}
|
||
|
||
// Render Item Cards
|
||
container.innerHTML = items.map((item, idx) => {
|
||
const tooltip = formatItemTooltip(item, locale)
|
||
return renderSingleItemCard(tooltip, idx + 1, locale)
|
||
}).join('')
|
||
}
|
||
|
||
function renderSingleItemCard(t: FormattedItemTooltip, index: number, locale: TooltipLocale = getTooltipLocale()): string {
|
||
const linesHtml = t.lines.map(l => {
|
||
const color = l.color || 'blue'
|
||
const colorClass = color === 'instruction' ? 'line-white line-instruction' : `line-${color}`
|
||
return `<div class="tooltip-stat-line ${colorClass}">${escapeHtml(l.text)}</div>`
|
||
}).join('')
|
||
|
||
const isZh = locale === 'zh' || locale === 'bilingual'
|
||
const lblDefense = isZh ? '防御: ' : 'Defense: '
|
||
const lblOneHand = isZh ? '单手伤害: ' : 'One-Hand Damage: '
|
||
const lblTwoHand = isZh ? '双手伤害: ' : 'Two-Hand Damage: '
|
||
const lblDurability = isZh ? '耐久度: ' : 'Durability: '
|
||
const lblReqLevel = isZh ? '需要等级: ' : 'Required Level: '
|
||
const lblReqStr = isZh ? '需要力量: ' : 'Required Strength: '
|
||
const lblReqDex = isZh ? '需要敏捷: ' : 'Required Dexterity: '
|
||
const wordOf = isZh ? ' / ' : ' of '
|
||
const wordTo = isZh ? ' 至 ' : ' to '
|
||
|
||
const baseStatsHtml: string[] = []
|
||
if (t.defense !== undefined) {
|
||
const defText =
|
||
t.baseDefense !== undefined && t.baseDefense !== t.defense
|
||
? `<span class="val-blue">${t.defense}</span> <span class="val-base">(${t.baseDefense})</span>`
|
||
: `<span class="val-white">${t.defense}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item">${lblDefense}${defText}</div>`)
|
||
}
|
||
if (t.oneHandDamage) {
|
||
const d = t.oneHandDamage
|
||
const dmgText =
|
||
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
|
||
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
|
||
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item">${lblOneHand}${dmgText}</div>`)
|
||
}
|
||
if (t.twoHandDamage) {
|
||
const d = t.twoHandDamage
|
||
const dmgText =
|
||
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
|
||
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
|
||
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item">${lblTwoHand}${dmgText}</div>`)
|
||
}
|
||
if (t.durability) {
|
||
const dur = t.durability
|
||
const durText =
|
||
dur.isEnhanced && dur.baseMax !== undefined
|
||
? `<span class="val-blue">${dur.current}${wordOf}${dur.max}</span> <span class="val-base">(${dur.baseMax})</span>`
|
||
: `<span class="val-white">${dur.current}${wordOf}${dur.max}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item">${lblDurability}${durText}</div>`)
|
||
}
|
||
if (t.reqLevel !== undefined && !t.isSocketable) {
|
||
const lvlText =
|
||
t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel
|
||
? `<span class="val-blue">${t.reqLevel}</span> <span class="val-base">(${t.baseReqLevel})</span>`
|
||
: `<span class="val-white">${t.reqLevel}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqLevel}${lvlText}</div>`)
|
||
}
|
||
if (t.reqStr !== undefined) {
|
||
const strText =
|
||
t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr
|
||
? `<span class="val-blue">${t.reqStr}</span> <span class="val-base">(${t.baseReqStr})</span>`
|
||
: `<span class="val-white">${t.reqStr}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqStr}${strText}</div>`)
|
||
}
|
||
if (t.reqDex !== undefined) {
|
||
const dexText =
|
||
t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex
|
||
? `<span class="val-blue">${t.reqDex}</span> <span class="val-base">(${t.baseReqDex})</span>`
|
||
: `<span class="val-white">${t.reqDex}</span>`
|
||
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqDex}${dexText}</div>`)
|
||
}
|
||
|
||
const qLower = t.quality.toLowerCase()
|
||
const qualityClass = t.isSocketable
|
||
? (t.qualityColor === '#ff8c00' ? 'quality-rune' : 'quality-gem')
|
||
: `quality-${qLower}`
|
||
|
||
return `
|
||
<div class="d2-item-card ${qualityClass} quality-${escapeHtml(qLower)}">
|
||
<div class="item-card-header">
|
||
<div class="item-title" style="color: ${t.qualityColor};">${escapeHtml(t.title)}</div>
|
||
${t.subTitle ? `<div class="item-subtitle">${escapeHtml(t.subTitle)}</div>` : ''}
|
||
<div class="item-tags">
|
||
<span class="item-quality-pill" style="border-color: ${t.qualityColor}; color: ${t.qualityColor};">${escapeHtml(t.quality.toUpperCase())}</span>
|
||
<span class="item-ilvl-pill">ilvl ${t.ilvl}</span>
|
||
</div>
|
||
</div>
|
||
|
||
${baseStatsHtml.length > 0 ? `
|
||
<div class="item-base-stats">
|
||
${baseStatsHtml.join('')}
|
||
</div>
|
||
` : ''}
|
||
|
||
${t.lines.length > 0 ? `
|
||
<div class="item-affix-lines">
|
||
${linesHtml}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`
|
||
}
|
||
|
||
function escapeHtml(str: string): string {
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
// Auto-run on DOM ready
|
||
if (typeof document !== 'undefined') {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => initBossApp())
|
||
} else {
|
||
initBossApp()
|
||
}
|
||
}
|