diablo2-web/src/skills.ts

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()