788 lines
25 KiB
TypeScript
788 lines
25 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Skill Point Calculator Web Application.
|
|
*
|
|
* Implements full 7-class skill tree planner matching the reference layout:
|
|
* - 3 side-by-side authentic 256x432 skill trees per class
|
|
* - Left-click to allocate, right-click to deallocate (with orphan protection)
|
|
* - Authentic 1.13c synergy engine & masteries calculation
|
|
* - Dynamic tooltip with damage, mana, requirements, synergies, next-level preview
|
|
* - URL Hash serialization for build sharing
|
|
* - Chinese / English bilingual localization
|
|
*/
|
|
|
|
import {
|
|
type CharacterClassCode,
|
|
CHARACTER_CLASS_CODES,
|
|
} from './game/classes.ts'
|
|
import {
|
|
type SkillCatalogEntry,
|
|
SKILLS_BY_ID,
|
|
SKILLS_CATALOG,
|
|
} from './data/skills-catalog.ts'
|
|
import {
|
|
type SkillCalcState,
|
|
allocatePoint,
|
|
deallocatePoint,
|
|
respec,
|
|
canAllocate,
|
|
canDeallocate,
|
|
getEffectiveLevel,
|
|
getTotalPointsSpent,
|
|
getTabPointsSpent,
|
|
getRequiredCharacterLevel,
|
|
buildTooltipViewModel,
|
|
serializeBuildToHash,
|
|
deserializeBuildFromHash,
|
|
} from './game/skill-calc-engine.ts'
|
|
|
|
type Lang = 'zh' | 'en'
|
|
|
|
// Authentic Diablo II class tab orders (top-to-bottom in game UI translates to left-to-right)
|
|
// Amazon: Javelin & Spear (tab 2), Passive & Magic (tab 1), Bow & Crossbow (tab 0) -> exact match with reference image!
|
|
const CLASS_TAB_ORDER: Record<CharacterClassCode, readonly number[]> = {
|
|
ama: [2, 1, 0],
|
|
sor: [0, 1, 2],
|
|
nec: [2, 1, 0],
|
|
pal: [0, 1, 2],
|
|
bar: [0, 1, 2],
|
|
dru: [2, 1, 0],
|
|
ass: [2, 1, 0],
|
|
}
|
|
|
|
const CLASS_NAMES: Record<CharacterClassCode, { zh: string; en: string }> = {
|
|
ama: { zh: '亚马逊', en: 'Amazon' },
|
|
sor: { zh: '法师', en: 'Sorceress' },
|
|
nec: { zh: '死灵法师', en: 'Necromancer' },
|
|
pal: { zh: '圣骑士', en: 'Paladin' },
|
|
bar: { zh: '野蛮人', en: 'Barbarian' },
|
|
dru: { zh: '德鲁伊', en: 'Druid' },
|
|
ass: { zh: '刺客', en: 'Assassin' },
|
|
}
|
|
|
|
// UI State
|
|
let currentClass: CharacterClassCode = 'ama'
|
|
let currentLang: Lang = 'zh'
|
|
let activeTooltipSkillId: number | null = null
|
|
|
|
let state: SkillCalcState = {
|
|
classCode: 'ama',
|
|
hardPoints: {},
|
|
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
|
allSkillsSoftPoints: 0,
|
|
specificSoftPoints: {},
|
|
}
|
|
|
|
// DOM Elements
|
|
const classTabsContainer = document.getElementById('class-tabs-container') as HTMLElement
|
|
const treesContainer = document.getElementById('trees-container') as HTMLElement
|
|
const tooltipEl = document.getElementById('skill-tooltip') as HTMLElement
|
|
const toastEl = document.getElementById('toast') as HTMLElement
|
|
|
|
// Summary Bar Elements
|
|
const valReqLevel = document.getElementById('val-req-level') as HTMLElement
|
|
const valPointsSpent = document.getElementById('val-points-spent') as HTMLElement
|
|
const valPointsRemain = document.getElementById('val-points-remain') as HTMLElement
|
|
const valAllSkills = document.getElementById('val-all-skills') as HTMLElement
|
|
const btnAllSkillsDec = document.getElementById('btn-all-skills-dec') as HTMLButtonElement
|
|
const btnAllSkillsInc = document.getElementById('btn-all-skills-inc') as HTMLButtonElement
|
|
const btnRespec = document.getElementById('btn-respec') as HTMLButtonElement
|
|
const btnCopyLink = document.getElementById('btn-copy-link') as HTMLButtonElement
|
|
const btnLang = document.getElementById('btn-lang') as HTMLButtonElement
|
|
|
|
// Static Label Elements for I18n
|
|
const txtAppTitle = document.getElementById('txt-app-title') as HTMLElement
|
|
const lblReqLevel = document.getElementById('lbl-req-level') as HTMLElement
|
|
const lblPointsSpent = document.getElementById('lbl-points-spent') as HTMLElement
|
|
const lblPointsRemain = document.getElementById('lbl-points-remain') as HTMLElement
|
|
const lblAllSkills = document.getElementById('lbl-all-skills') as HTMLElement
|
|
const lnkLoot = document.getElementById('lnk-loot') as HTMLAnchorElement
|
|
const lnkBoss = document.getElementById('lnk-boss') as HTMLAnchorElement
|
|
const lnkActs = document.getElementById('lnk-acts') as HTMLAnchorElement
|
|
|
|
/**
|
|
* Shows a brief floating toast notification.
|
|
*/
|
|
function showToast(msg: string) {
|
|
if (!toastEl) return
|
|
toastEl.textContent = msg
|
|
toastEl.classList.add('show')
|
|
setTimeout(() => {
|
|
toastEl.classList.remove('show')
|
|
}, 2200)
|
|
}
|
|
|
|
/**
|
|
* Initializes state from URL Hash or defaults.
|
|
*/
|
|
function initFromHash() {
|
|
const hash = window.location.hash
|
|
if (hash) {
|
|
const restored = deserializeBuildFromHash(hash)
|
|
if (restored) {
|
|
currentClass = restored.classCode
|
|
state = restored
|
|
return
|
|
}
|
|
}
|
|
// Default fresh build
|
|
state = {
|
|
classCode: currentClass,
|
|
hardPoints: {},
|
|
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
|
allSkillsSoftPoints: 0,
|
|
specificSoftPoints: {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Syncs the state to URL hash without reloading.
|
|
*/
|
|
function syncHash() {
|
|
const hash = serializeBuildToHash(state)
|
|
if (window.location.hash !== hash) {
|
|
history.replaceState(null, '', hash)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates static labels according to the current language.
|
|
*/
|
|
function updateStaticI18n() {
|
|
if (currentLang === 'zh') {
|
|
txtAppTitle.textContent = '暗黑破坏神 2 · 技能加点模拟器'
|
|
lblReqLevel.textContent = '需求角色等级:'
|
|
lblPointsSpent.textContent = '已用技能点:'
|
|
lblPointsRemain.textContent = '剩余技能点:'
|
|
lblAllSkills.textContent = '+ 全部技能:'
|
|
btnRespec.textContent = '重置技能 (Respec)'
|
|
btnCopyLink.textContent = '复制配置 (Share)'
|
|
btnLang.textContent = '中 / EN'
|
|
lnkLoot.textContent = '战利品模拟'
|
|
lnkBoss.textContent = '首领挑战'
|
|
lnkActs.textContent = '大地图'
|
|
} else {
|
|
txtAppTitle.textContent = 'Diablo II · Skill Calculator (v1.13c)'
|
|
lblReqLevel.textContent = 'Required Level:'
|
|
lblPointsSpent.textContent = 'Points Spent:'
|
|
lblPointsRemain.textContent = 'Remaining Points:'
|
|
lblAllSkills.textContent = '+ to All Skills:'
|
|
btnRespec.textContent = 'Respec'
|
|
btnCopyLink.textContent = 'Copy Build Link'
|
|
btnLang.textContent = 'EN / 中'
|
|
lnkLoot.textContent = 'Loot Sim'
|
|
lnkBoss.textContent = 'Boss Arena'
|
|
lnkActs.textContent = 'Campaign Map'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the top 7 class switcher tabs.
|
|
*/
|
|
function renderClassTabs() {
|
|
classTabsContainer.innerHTML = ''
|
|
for (const code of CHARACTER_CLASS_CODES) {
|
|
const btn = document.createElement('button')
|
|
btn.className = `class-btn ${code === currentClass ? 'active' : ''}`
|
|
btn.textContent = currentLang === 'zh' ? CLASS_NAMES[code].zh : CLASS_NAMES[code].en
|
|
btn.dataset.classCode = code
|
|
btn.addEventListener('click', () => {
|
|
if (currentClass === code) return
|
|
currentClass = code
|
|
state = {
|
|
classCode: code,
|
|
hardPoints: {},
|
|
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
|
allSkillsSoftPoints: 0,
|
|
specificSoftPoints: {},
|
|
}
|
|
syncHash()
|
|
render()
|
|
})
|
|
classTabsContainer.appendChild(btn)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the 3 skill tree panels for the active character class.
|
|
*/
|
|
function renderTrees() {
|
|
treesContainer.innerHTML = ''
|
|
const tabIndices = CLASS_TAB_ORDER[currentClass]
|
|
|
|
for (const tabIndex of tabIndices) {
|
|
// Collect 10 skills for this tab
|
|
const skillsInTab = SKILLS_CATALOG.filter(
|
|
s => s.classCode === currentClass && s.tabIndex === tabIndex
|
|
)
|
|
if (skillsInTab.length === 0) continue
|
|
|
|
const tabName = currentLang === 'zh' ? skillsInTab[0]!.tabNameZh : skillsInTab[0]!.tabName
|
|
const tabSpent = getTabPointsSpent(state, tabIndex)
|
|
|
|
// Tree Column container
|
|
const colDiv = document.createElement('div')
|
|
colDiv.className = 'tree-column'
|
|
|
|
// Header
|
|
const headerDiv = document.createElement('div')
|
|
headerDiv.className = 'tree-header'
|
|
headerDiv.innerHTML = `
|
|
<div class="tree-title">${tabName}</div>
|
|
<div class="tree-points-banner">
|
|
${currentLang === 'zh' ? `该系已投入 ${tabSpent} 点` : `${tabSpent} POINTS IN THIS TREE`}
|
|
</div>
|
|
`
|
|
colDiv.appendChild(headerDiv)
|
|
|
|
// Stone Tree Canvas
|
|
const canvasDiv = document.createElement('div')
|
|
canvasDiv.className = 'tree-canvas'
|
|
canvasDiv.style.backgroundImage = `url('/skills/bg_${currentClass}_${tabIndex}.png')`
|
|
|
|
// Render 10 skill sockets
|
|
for (const skill of skillsInTab) {
|
|
const socket = createSkillSocket(skill, tabIndex)
|
|
canvasDiv.appendChild(socket)
|
|
}
|
|
|
|
colDiv.appendChild(canvasDiv)
|
|
|
|
// Footer with Tree Soft Points stepper
|
|
const footerDiv = document.createElement('div')
|
|
footerDiv.className = 'tree-footer'
|
|
const currentTabSoft = state.tabSoftPoints[tabIndex] ?? 0
|
|
footerDiv.innerHTML = `
|
|
<span>${currentLang === 'zh' ? `+ 该系技能:` : `+ to ${tabName}:`}</span>
|
|
<div class="stepper-control">
|
|
<button class="stepper-btn tab-soft-dec" data-tab="${tabIndex}">-</button>
|
|
<span class="stepper-val tab-soft-val">${currentTabSoft}</span>
|
|
<button class="stepper-btn tab-soft-inc" data-tab="${tabIndex}">+</button>
|
|
</div>
|
|
`
|
|
|
|
// Wire stepper buttons
|
|
const btnDec = footerDiv.querySelector('.tab-soft-dec') as HTMLButtonElement
|
|
const btnInc = footerDiv.querySelector('.tab-soft-inc') as HTMLButtonElement
|
|
btnDec.addEventListener('click', () => {
|
|
if ((state.tabSoftPoints[tabIndex] ?? 0) > 0) {
|
|
state.tabSoftPoints[tabIndex]!--
|
|
syncHash()
|
|
render()
|
|
if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
|
|
}
|
|
})
|
|
btnInc.addEventListener('click', () => {
|
|
if ((state.tabSoftPoints[tabIndex] ?? 0) < 99) {
|
|
state.tabSoftPoints[tabIndex] = (state.tabSoftPoints[tabIndex] ?? 0) + 1
|
|
syncHash()
|
|
render()
|
|
if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
|
|
}
|
|
})
|
|
|
|
colDiv.appendChild(footerDiv)
|
|
treesContainer.appendChild(colDiv)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an individual skill socket DOM element on the 256x432 stone canvas.
|
|
*/
|
|
function createSkillSocket(skill: SkillCatalogEntry, tabIndex: number): HTMLElement {
|
|
const socket = document.createElement('div')
|
|
socket.className = 'skill-socket'
|
|
|
|
// Diablo II Grid layout coordinates:
|
|
// x = 10 + (col - 1) * 72
|
|
// y = 14 + (row - 1) * 64
|
|
const posX = 10 + (skill.col - 1) * 72
|
|
const posY = 14 + (skill.row - 1) * 64
|
|
socket.style.left = `${posX}px`
|
|
socket.style.top = `${posY}px`
|
|
socket.dataset.skillId = String(skill.id)
|
|
|
|
const hard = state.hardPoints[skill.id] ?? 0
|
|
const eff = getEffectiveLevel(state, skill.id)
|
|
const canAlloc = canAllocate(state, skill.id)
|
|
const isMaxed = hard >= 20
|
|
const isLocked = hard === 0 && !canAlloc
|
|
|
|
if (isLocked) socket.classList.add('locked')
|
|
if (hard > 0) socket.classList.add('active')
|
|
if (isMaxed) socket.classList.add('maxed')
|
|
|
|
// Icon Image
|
|
const img = document.createElement('img')
|
|
img.className = 'skill-icon'
|
|
img.src = hard > 0 ? skill.iconActive : skill.icon
|
|
img.alt = currentLang === 'zh' ? skill.nameZh : skill.name
|
|
img.draggable = false
|
|
socket.appendChild(img)
|
|
|
|
// Point Badge Box (Positioned at right-bottom: posX + 46, posY + 26)
|
|
const badge = document.createElement('div')
|
|
badge.className = 'skill-pts-badge'
|
|
badge.style.left = '46px'
|
|
badge.style.top = '26px'
|
|
|
|
if (hard > 0) {
|
|
badge.classList.add('has-points')
|
|
if (eff > hard) {
|
|
badge.classList.add('has-soft-points')
|
|
badge.textContent = String(eff)
|
|
badge.title = `${hard} base + ${eff - hard} gear`
|
|
} else {
|
|
badge.textContent = String(hard)
|
|
}
|
|
} else {
|
|
badge.textContent = '0'
|
|
}
|
|
if (isMaxed) badge.classList.add('is-maxed')
|
|
|
|
socket.appendChild(badge)
|
|
|
|
// Interaction: Left Click (+1 or +5)
|
|
socket.addEventListener('click', (e: MouseEvent) => {
|
|
e.preventDefault()
|
|
const count = e.shiftKey ? 5 : 1
|
|
const changed = allocatePoint(state, skill.id, count)
|
|
if (changed) {
|
|
syncHash()
|
|
render()
|
|
refreshTooltip(skill.id)
|
|
}
|
|
})
|
|
|
|
// Interaction: Right Click (-1 or refund all)
|
|
socket.addEventListener('contextmenu', (e: MouseEvent) => {
|
|
e.preventDefault()
|
|
const count = e.shiftKey ? 20 : 1
|
|
const allowed = canDeallocate(state, skill.id)
|
|
if (!allowed && (state.hardPoints[skill.id] ?? 0) > 0) {
|
|
showToast(
|
|
currentLang === 'zh'
|
|
? '无法撤除技能点:有后续依赖技能已加点!'
|
|
: 'Cannot deallocate: Dependent skills require this prerequisite!'
|
|
)
|
|
return
|
|
}
|
|
const changed = deallocatePoint(state, skill.id, count)
|
|
if (changed) {
|
|
syncHash()
|
|
render()
|
|
refreshTooltip(skill.id)
|
|
}
|
|
})
|
|
|
|
// Tooltip tracking
|
|
socket.addEventListener('mouseenter', (e: MouseEvent) => {
|
|
activeTooltipSkillId = skill.id
|
|
renderTooltip(skill.id)
|
|
positionTooltip(e.clientX, e.clientY)
|
|
})
|
|
socket.addEventListener('mousemove', (e: MouseEvent) => {
|
|
positionTooltip(e.clientX, e.clientY)
|
|
})
|
|
socket.addEventListener('mouseleave', () => {
|
|
activeTooltipSkillId = null
|
|
hideTooltip()
|
|
})
|
|
|
|
return socket
|
|
}
|
|
|
|
/**
|
|
* Positions the tooltip safely within the viewport boundaries.
|
|
*/
|
|
function positionTooltip(mouseX: number, mouseY: number) {
|
|
if (!tooltipEl || tooltipEl.style.display !== 'block') return
|
|
|
|
const tooltipWidth = tooltipEl.offsetWidth || 320
|
|
const tooltipHeight = tooltipEl.offsetHeight || 300
|
|
const pad = 16
|
|
|
|
let left = mouseX + pad
|
|
let top = mouseY + pad
|
|
|
|
// Right edge boundary clamp
|
|
if (left + tooltipWidth > window.innerWidth - 10) {
|
|
left = mouseX - tooltipWidth - pad
|
|
}
|
|
// Bottom edge boundary clamp
|
|
if (top + tooltipHeight > window.innerHeight - 10) {
|
|
top = window.innerHeight - tooltipHeight - 10
|
|
}
|
|
// Top boundary clamp (keep below header and stats bar at 94px)
|
|
if (top < 96) top = 96
|
|
if (left < 10) left = 10
|
|
|
|
tooltipEl.style.left = `${left}px`
|
|
tooltipEl.style.top = `${top}px`
|
|
}
|
|
|
|
/**
|
|
* Hides the tooltip.
|
|
*/
|
|
function hideTooltip() {
|
|
if (tooltipEl) tooltipEl.style.display = 'none'
|
|
}
|
|
|
|
/**
|
|
* Re-renders the tooltip content if active.
|
|
*/
|
|
function refreshTooltip(skillId: number) {
|
|
if (activeTooltipSkillId === skillId) {
|
|
renderTooltip(skillId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds and displays the authentic Diablo II hover tooltip.
|
|
*/
|
|
function renderTooltip(skillId: number) {
|
|
const reqCharLvl = getRequiredCharacterLevel(state)
|
|
const vm = buildTooltipViewModel(state, skillId, reqCharLvl)
|
|
if (!vm) {
|
|
hideTooltip()
|
|
return
|
|
}
|
|
|
|
const skillName = currentLang === 'zh' ? vm.skill.nameZh : vm.skill.name
|
|
const className = currentLang === 'zh' ? CLASS_NAMES[currentClass].zh : CLASS_NAMES[currentClass].en
|
|
const tabName = currentLang === 'zh' ? vm.skill.tabNameZh : vm.skill.tabName
|
|
const soft = vm.effectiveLevel > vm.hardPoints ? vm.effectiveLevel - vm.hardPoints : 0
|
|
|
|
let levelText = ''
|
|
if (vm.isLearned) {
|
|
if (soft > 0) {
|
|
levelText = currentLang === 'zh'
|
|
? `当前技能等级: <span class="soft-highlight">${vm.effectiveLevel}</span> (基础: ${vm.hardPoints}, 装备: +${soft})`
|
|
: `Current Skill Level: <span class="soft-highlight">${vm.effectiveLevel}</span> (Base: ${vm.hardPoints}, Gear: +${soft})`
|
|
} else {
|
|
levelText = currentLang === 'zh'
|
|
? `当前技能等级: ${vm.effectiveLevel}`
|
|
: `Current Skill Level: ${vm.effectiveLevel}`
|
|
}
|
|
} else {
|
|
levelText = currentLang === 'zh' ? `未习得 (预览 1 级效果)` : `Not Learned (Preview Level 1)`
|
|
}
|
|
|
|
let html = `
|
|
<div class="tt-header">
|
|
<div class="tt-title">${skillName}</div>
|
|
<div class="tt-subtitle">${className} · ${tabName}</div>
|
|
<div class="tt-level-tag">${levelText}</div>
|
|
</div>
|
|
|
|
<div class="tt-section">
|
|
<div class="tt-row">
|
|
<span class="tt-label">${currentLang === 'zh' ? '需求角色等级:' : 'Required Character Level:'}</span>
|
|
<span class="tt-val ${vm.levelMet ? 'ok' : 'err'}">${vm.reqlevel}</span>
|
|
</div>
|
|
`
|
|
|
|
// Prerequisites
|
|
if (vm.prereqs.length > 0) {
|
|
html += `
|
|
<div class="tt-row" style="margin-top: 4px;">
|
|
<span class="tt-label">${currentLang === 'zh' ? '前置技能需求:' : 'Prerequisites:'}</span>
|
|
</div>
|
|
`
|
|
for (const prereq of vm.prereqs) {
|
|
const pName = currentLang === 'zh' ? prereq.nameZh : prereq.name
|
|
const statusIcon = prereq.met ? '✓' : '✗'
|
|
const statusClass = prereq.met ? 'ok' : 'err'
|
|
const statusDesc = prereq.met
|
|
? (currentLang === 'zh' ? '已达成' : 'Satisfied')
|
|
: (currentLang === 'zh' ? '未投入点数' : 'Missing')
|
|
html += `
|
|
<div class="tt-row" style="padding-left: 8px;">
|
|
<span class="tt-val ${statusClass}">${statusIcon} ${pName}</span>
|
|
<span class="tt-val ${statusClass}" style="font-size: 10px;">${statusDesc}</span>
|
|
</div>
|
|
`
|
|
}
|
|
}
|
|
|
|
// Mana Cost & Delay
|
|
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
|
|
html += `
|
|
<div class="tt-row" style="margin-top: 4px;">
|
|
<span class="tt-label">${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'}</span>
|
|
<span class="tt-val">${vm.current.manaCost}</span>
|
|
</div>
|
|
`
|
|
}
|
|
if (vm.current.delaySeconds > 0) {
|
|
html += `
|
|
<div class="tt-row">
|
|
<span class="tt-label">${currentLang === 'zh' ? '施法延迟:' : 'Casting Delay:'}</span>
|
|
<span class="tt-val">${vm.current.delaySeconds} ${currentLang === 'zh' ? '秒' : 'sec'}</span>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
html += `</div>` // close tt-section
|
|
|
|
// Damage & Mechanics Box
|
|
const dmg = vm.current.damage
|
|
if (dmg.hasDamage || dmg.weaponDamagePercent) {
|
|
html += `<div class="tt-damage-box">`
|
|
|
|
// Final Damage
|
|
if (dmg.hasDamage) {
|
|
const typeLabel = currentLang === 'zh' ? dmg.typeZh : `${dmg.type.toUpperCase()} Damage`
|
|
html += `
|
|
<div class="tt-damage-main">
|
|
${dmg.finalMin} - ${dmg.finalMax} ${typeLabel}
|
|
</div>
|
|
`
|
|
if (dmg.durationSeconds) {
|
|
html += `
|
|
<div class="tt-damage-sub">
|
|
${currentLang === 'zh' ? `持续时间: ${dmg.durationSeconds} 秒` : `Duration: ${dmg.durationSeconds} seconds`}
|
|
</div>
|
|
`
|
|
}
|
|
}
|
|
|
|
// Weapon Damage %
|
|
if (dmg.weaponDamagePercent) {
|
|
html += `
|
|
<div class="tt-damage-sub">
|
|
${currentLang === 'zh' ? `武器伤害: ${dmg.weaponDamagePercent}%` : `Weapon Damage: ${dmg.weaponDamagePercent}%`}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
// Base damage breakdown if multiplier applied
|
|
if (dmg.hasDamage && (dmg.synergyMultiplier > 1.0 || dmg.masteryMultiplier > 1.0)) {
|
|
html += `
|
|
<div class="tt-damage-sub" style="margin-top: 2px;">
|
|
${currentLang === 'zh' ? `基础伤害: ${dmg.baseMin} - ${dmg.baseMax}` : `Base: ${dmg.baseMin} - ${dmg.baseMax}`}
|
|
${dmg.synergyBonusPct > 0 ? ` · 协同 +${dmg.synergyBonusPct}%` : ''}
|
|
${dmg.masteryBonusPct > 0 ? ` · 精通 +${dmg.masteryBonusPct}%` : ''}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
html += `</div>`
|
|
}
|
|
|
|
// Skill Effects & Mechanics Box (Stats, Auras, Minions, Buffs, Curses)
|
|
if (vm.current.effects && vm.current.effects.length > 0) {
|
|
html += `<div class="tt-effects-box">`
|
|
for (const eff of vm.current.effects) {
|
|
const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
|
|
html += `<div class="tt-effect-line">${lineText}</div>`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
|
|
// Synergies Received List
|
|
if (vm.synergiesReceived.length > 0) {
|
|
html += `
|
|
<div class="tt-synergies">
|
|
<div class="tt-syn-title">
|
|
${currentLang === 'zh' ? '接收协同加成 (Synergies Received):' : 'Synergies Received:'}
|
|
</div>
|
|
`
|
|
for (const syn of vm.synergiesReceived) {
|
|
const pName = currentLang === 'zh' ? syn.providerNameZh : syn.providerName
|
|
const activeClass = syn.hardPoints > 0 ? 'active' : ''
|
|
html += `
|
|
<div class="tt-syn-item ${activeClass}">
|
|
• ${pName}: +${syn.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
|
|
(${currentLang === 'zh' ? `当前 ${syn.hardPoints} 点: +${syn.currentBonusPct}%` : `Current ${syn.hardPoints} pts: +${syn.currentBonusPct}%`})
|
|
</div>
|
|
`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
|
|
// Mastery info if applicable
|
|
if (vm.masteryInfo && vm.masteryInfo.active) {
|
|
html += `
|
|
<div class="tt-synergies">
|
|
<div class="tt-syn-title">
|
|
${currentLang === 'zh' ? '精通加成 (Mastery Applied):' : 'Mastery Applied:'}
|
|
</div>
|
|
<div class="tt-syn-item active">
|
|
• ${currentLang === 'zh' ? vm.masteryInfo.descriptionZh : vm.masteryInfo.descriptionEn}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
// Synergies Given List (为此技能提供协同)
|
|
if (vm.synergiesGiven.length > 0) {
|
|
html += `
|
|
<div class="tt-synergies">
|
|
<div class="tt-syn-title">
|
|
${currentLang === 'zh' ? '输出协同加成 (Synergies Given):' : 'Synergies Given:'}
|
|
</div>
|
|
`
|
|
for (const given of vm.synergiesGiven) {
|
|
const tName = currentLang === 'zh' ? given.targetNameZh : given.targetName
|
|
html += `
|
|
<div class="tt-syn-item">
|
|
• ${tName}: +${given.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
|
|
</div>
|
|
`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
|
|
// Next Level Preview
|
|
if (vm.nextLevel) {
|
|
const nextDmg = vm.nextLevel.damage
|
|
const hasNextDmg = nextDmg.hasDamage
|
|
const hasNextEffects = vm.nextLevel.effects && vm.nextLevel.effects.length > 0
|
|
const hasManaChange = vm.nextLevel.manaCost !== vm.current.manaCost
|
|
|
|
if (hasNextDmg || hasNextEffects || hasManaChange) {
|
|
html += `
|
|
<div class="tt-next-level">
|
|
<div class="tt-next-title">${currentLang === 'zh' ? '[下一级效果]' : '[Next Level]'} (Lv ${vm.nextLevel.level})</div>
|
|
`
|
|
if (hasNextDmg) {
|
|
const typeLabel = currentLang === 'zh' ? nextDmg.typeZh : `${nextDmg.type.toUpperCase()} Damage`
|
|
html += `<div>${nextDmg.finalMin} - ${nextDmg.finalMax} ${typeLabel}</div>`
|
|
}
|
|
if (hasNextEffects) {
|
|
for (const eff of vm.nextLevel.effects) {
|
|
const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
|
|
html += `<div class="tt-effect-line">${lineText}</div>`
|
|
}
|
|
}
|
|
if (hasManaChange) {
|
|
html += `<div>${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'} ${vm.nextLevel.manaCost}</div>`
|
|
}
|
|
html += `</div>`
|
|
}
|
|
}
|
|
|
|
// Description / Lore
|
|
const desc = currentLang === 'zh' ? (vm.shortDescZh || vm.shortDesc) : vm.shortDesc
|
|
if (desc) {
|
|
html += `<div class="tt-desc">${desc}</div>`
|
|
}
|
|
|
|
// Helper Hint
|
|
html += `
|
|
<div class="tt-hint">
|
|
${currentLang === 'zh'
|
|
? '左键: +1点 (Shift+点击: +5) | 右键: -1点 (Shift+右键: 快速撤点)'
|
|
: 'Left-click: +1 (Shift: +5) | Right-click: -1 (Shift: Refund max)'}
|
|
</div>
|
|
`
|
|
|
|
tooltipEl.innerHTML = html
|
|
tooltipEl.style.display = 'block'
|
|
}
|
|
|
|
/**
|
|
* Updates the summary statistics bar.
|
|
*/
|
|
function updateSummaryBar() {
|
|
const reqLevel = getRequiredCharacterLevel(state)
|
|
const spent = getTotalPointsSpent(state)
|
|
const remain = Math.max(0, 110 - spent)
|
|
|
|
valReqLevel.textContent = String(reqLevel)
|
|
if (reqLevel > 99) {
|
|
valReqLevel.classList.add('warning')
|
|
} else {
|
|
valReqLevel.classList.remove('warning')
|
|
}
|
|
|
|
valPointsSpent.textContent = `${spent} / 110`
|
|
valPointsRemain.textContent = String(remain)
|
|
valAllSkills.textContent = String(state.allSkillsSoftPoints || 0)
|
|
}
|
|
|
|
/**
|
|
* Full page render pass.
|
|
*/
|
|
function render() {
|
|
updateStaticI18n()
|
|
renderClassTabs()
|
|
renderTrees()
|
|
updateSummaryBar()
|
|
}
|
|
|
|
/**
|
|
* Setup event handlers.
|
|
*/
|
|
function setupEvents() {
|
|
// All Skills Stepper
|
|
btnAllSkillsDec.addEventListener('click', () => {
|
|
if (state.allSkillsSoftPoints > 0) {
|
|
state.allSkillsSoftPoints--
|
|
syncHash()
|
|
render()
|
|
if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
|
|
}
|
|
})
|
|
btnAllSkillsInc.addEventListener('click', () => {
|
|
if (state.allSkillsSoftPoints < 99) {
|
|
state.allSkillsSoftPoints++
|
|
syncHash()
|
|
render()
|
|
if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
|
|
}
|
|
})
|
|
|
|
// Respec
|
|
btnRespec.addEventListener('click', () => {
|
|
const refunded = respec(state)
|
|
state.allSkillsSoftPoints = 0
|
|
state.tabSoftPoints = { 0: 0, 1: 0, 2: 0 }
|
|
syncHash()
|
|
render()
|
|
hideTooltip()
|
|
showToast(
|
|
currentLang === 'zh'
|
|
? `重置完成!已退还 ${refunded} 点技能点。`
|
|
: `Respec complete! Refunded ${refunded} skill points.`
|
|
)
|
|
})
|
|
|
|
// Copy Build Link
|
|
btnCopyLink.addEventListener('click', async () => {
|
|
syncHash()
|
|
const url = window.location.href
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
showToast(
|
|
currentLang === 'zh'
|
|
? '已复制配点链接到剪贴板!'
|
|
: 'Build link copied to clipboard!'
|
|
)
|
|
} catch {
|
|
prompt(
|
|
currentLang === 'zh' ? '请复制以下配点链接:' : 'Copy build link below:',
|
|
url
|
|
)
|
|
}
|
|
})
|
|
|
|
// Language Toggle
|
|
btnLang.addEventListener('click', () => {
|
|
currentLang = currentLang === 'zh' ? 'en' : 'zh'
|
|
render()
|
|
if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
|
|
})
|
|
|
|
// Window Hash Change (Back/Forward navigation)
|
|
window.addEventListener('hashchange', () => {
|
|
initFromHash()
|
|
render()
|
|
})
|
|
}
|
|
|
|
// Initialization
|
|
initFromHash()
|
|
setupEvents()
|
|
render()
|