feat(ui): authentic Diablo II 1.13c skill quickbar & speedbar tooltips (Fixes #380)

This commit is contained in:
troytt 2026-09-22 10:57:58 +00:00
parent 7f810e40e3
commit 090de33935
3 changed files with 755 additions and 21 deletions

View File

@ -9,7 +9,10 @@
* during gameplay switches the bound side's active skill immediately.
*/
import type { D2FontRenderer } from './font.ts'
import type { D2FontRenderer, D2ColorCode } from './font.ts'
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
import type { TooltipViewModel } from '../game/skill-calc-engine.ts'
import type { FormattedTooltipLine } from './skill-tree-panel.ts'
export interface HotkeySkillEntry {
readonly skillId: number
@ -91,8 +94,8 @@ export function getAvailableSkillsForSide(
return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
}
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48, w: 48, h: 48 } as const
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48, w: 48, h: 48 } as const
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
@ -122,6 +125,10 @@ export class SkillHotkeysHud {
hoverSkillId: number | null = null
/** Currently hovered skill ID inside speedbar. */
hoverSpeedbarSkillId: number | null = null
/** Currently hovered slot on the control panel ('left' | 'right' | null). */
hoveredSlot: 'left' | 'right' | null = null
/** Optional custom tooltip resolver returning TooltipViewModel from the skill engine. */
tooltipResolver?: ((skillId: number) => TooltipViewModel | null) | undefined
get selectionOpen(): 'left' | 'right' | null {
return this.openPopup
@ -275,6 +282,7 @@ export class SkillHotkeysHud {
this.hoverSkillId = null
this.hoverSpeedbarSkillId = null
this.hoveredPopupSkill = null
this.hoveredSlot = null
if (
logicalX >= LEFT_SKILL_BOUNDS.x &&
@ -283,6 +291,7 @@ export class SkillHotkeysHud {
logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height
) {
this.hoverSkillId = this.leftSkillId
this.hoveredSlot = 'left'
return
}
@ -293,6 +302,7 @@ export class SkillHotkeysHud {
logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height
) {
this.hoverSkillId = this.rightSkillId
this.hoveredSlot = 'right'
return
}
@ -374,25 +384,10 @@ export class SkillHotkeysHud {
this.hoveredPopupSkill?.skillId === c.skill.skillId,
)
}
if (this.hoveredPopupSkill) {
const entry = this.availableSkills.find(s => s.skillId === this.hoveredPopupSkill!.skillId)
if (entry) {
const anchorX = this.openPopup === 'left' ? LEFT_SKILL_BOUNDS.x : RIGHT_SKILL_BOUNDS.x - 140
ctx.fillStyle = 'rgba(8, 8, 8, 0.92)'
ctx.fillRect(anchorX, 412, 210, 38)
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(anchorX, 412, 210, 38)
font.drawText(ctx, `${entry.nameZh} (${entry.name}) Lv.${entry.level}`, anchorX + 8, 428, {
font: 'fontexocet10',
color: 'gold',
})
font.drawText(ctx, `法力消耗: ${entry.manaCost} · 按 F1-F8 绑定`, anchorX + 8, 444, {
font: 'font8',
color: 'white',
})
}
}
}
// Authentic Diablo II v1.13c Skill Tooltip on Quickbar / Speedbar
this.drawSkillTooltip(ctx, font)
}
private drawSkillButton(
@ -476,4 +471,396 @@ export class SkillHotkeysHud {
})
}
}
/**
* Formats authentic Diablo II 1.13c multi-line tooltip lines for quickbar and speedbar popups.
*/
formatTooltipLines(skillId: number, side: 'left' | 'right', isSpeedbar: boolean): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = []
// 1. Check if special default skill (Attack 0, Throw 2, Unsummon 3)
if (skillId === 0) {
lines.push({
text: '普通攻击 (Attack)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '使用当前装备的武器或赤手空拳进行基础物理攻击。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 2) {
lines.push({
text: '投掷 (Throw)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 3) {
lines.push({
text: '取消召唤 (Unsummon)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '点击你所召唤的仆从或宠物以将其解散。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
// 2. Class Skills
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
const entry = this.availableSkills.find(s => s.skillId === skillId)
const catalogEntry = SKILLS_BY_ID[skillId]
const nameZh = vm?.skill.nameZh ?? entry?.nameZh ?? catalogEntry?.nameZh ?? `技能 ${skillId}`
const nameEn = vm?.skill.name ?? entry?.name ?? catalogEntry?.name ?? `Skill ${skillId}`
// Title
lines.push({
text: `${nameZh} (${nameEn})`,
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
// Hotkey Info
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
if (vm) {
// Current level
const soft = vm.effectiveLevel - vm.hardPoints
const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({
text: lvlText,
color: soft > 0 ? 'blue' : 'white',
font: 'font8',
align: 'center',
})
// Mana cost
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({
text: `法力消耗: ${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
// Cast delay
if (vm.current.delaySeconds > 0) {
lines.push({
text: `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
// Description narrative
const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
if (desc) {
lines.push({
text: desc,
color: 'white',
font: 'font8',
align: 'center',
})
}
// Damage / stats
if (vm.current.damage.hasDamage) {
const d = vm.current.damage
lines.push({
text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
color: 'blue',
font: 'font8',
align: 'center',
})
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
lines.push({
text: `持续时间: ${d.durationSeconds} 秒`,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
lines.push({
text: `武器伤害: ${d.weaponDamagePercent}%`,
color: 'white',
font: 'font8',
align: 'center',
})
}
}
// Effects
for (const eff of vm.current.effects) {
if (eff.textZh) {
lines.push({
text: eff.textZh,
color: 'blue',
font: 'font8',
align: 'center',
})
}
}
// Synergies received
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
lines.push({
text: '[协同技能加成]',
color: 'blue',
font: 'fontexocet10',
align: 'center',
})
for (const syn of vm.synergiesReceived) {
const text = syn.hardPoints > 0
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
lines.push({
text,
color: syn.hardPoints > 0 ? 'blue' : 'gray',
font: 'font8',
align: 'left',
})
}
}
} else {
// Fallback without ViewModel
const level = entry?.level ?? 1
const mana = entry?.manaCost ?? catalogEntry?.mana ?? 0
lines.push({
text: `当前技能等级: ${level}`,
color: 'white',
font: 'font8',
align: 'center',
})
if (mana > 0) {
lines.push({
text: `法力消耗: ${mana}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
const desc = catalogEntry?.shortDescZh || catalogEntry?.longDescZh
if (desc) {
lines.push({
text: desc,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (catalogEntry && catalogEntry.minDam > 0) {
lines.push({
text: `基础伤害: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`,
color: 'blue',
font: 'font8',
align: 'center',
})
}
}
return lines
}
/**
* Computes placement for quickbar tooltips clamped safely inside the viewport.
*/
static computeTooltipPlacement(
anchor: { x: number; y: number; w?: number; h?: number; width?: number; height?: number },
lines: readonly FormattedTooltipLine[],
font: D2FontRenderer,
viewportWidth = 800,
viewportHeight = 600,
): { x: number; y: number; w: number; h: number } {
const maxLineWidth = lines.reduce((max, l) => {
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
return Math.max(max, w)
}, 0)
const boxW = Math.max(220, maxLineWidth + 28)
const boxH = lines.length * 17 + 22
const anchorW = anchor.w ?? (anchor as any).width ?? 48
const anchorH = anchor.h ?? (anchor as any).height ?? 48
// Center horizontally on anchor
const rawX = Math.round(anchor.x + anchorW / 2 - boxW / 2)
const clampedX = Math.max(8, Math.min(viewportWidth - boxW - 8, rawX))
// Position vertically above anchor
let clampedY = anchor.y - boxH - 6
if (clampedY < 8) {
if (anchor.y + anchorH + boxH + 6 <= 552) {
clampedY = anchor.y + anchorH + 6
} else {
clampedY = 8
}
}
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
}
/**
* Renders the authentic Diablo II 1.13c skill tooltip on the quickbar or speedbar.
*/
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
let skillId: number | null = null
let side: 'left' | 'right' = 'left'
let anchor: { x: number; y: number; w?: number; h?: number; width?: number; height?: number } | null = null
let isSpeedbar = false
if (this.openPopup && this.hoveredPopupSkill) {
skillId = this.hoveredPopupSkill.skillId
side = this.hoveredPopupSkill.side
const cells = this.getPopupCells(this.openPopup)
const cell = cells.find(c => c.skill.skillId === skillId)
if (cell) {
anchor = cell
isSpeedbar = true
}
} else if (this.hoverSkillId !== null && this.hoveredSlot !== null) {
skillId = this.hoverSkillId
side = this.hoveredSlot
anchor = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
isSpeedbar = false
}
if (skillId === null || !anchor) return
const lines = this.formatTooltipLines(skillId, side, isSpeedbar)
if (lines.length === 0) return
const placement = SkillHotkeysHud.computeTooltipPlacement(anchor, lines, font)
// Diablo II dark translucent background
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
// Outer border
ctx.strokeStyle = '#8c7244'
ctx.lineWidth = 1
ctx.strokeRect(placement.x + 0.5, placement.y + 0.5, placement.w - 1, placement.h - 1)
// Inner subtle border
ctx.strokeStyle = '#3d2b1a'
ctx.strokeRect(placement.x + 2.5, placement.y + 2.5, placement.w - 5, placement.h - 5)
// Render lines
lines.forEach((l, idx) => {
const lineY = placement.y + 12 + 10 + idx * 17
const lineX = l.align === 'center' ? placement.x + Math.round(placement.w / 2) : placement.x + 14
if (typeof font?.drawText === 'function') {
font.drawText(ctx, l.text, lineX, lineY, {
font: l.font,
color: l.color,
align: l.align ?? 'center',
})
}
})
}
}

View File

@ -291,6 +291,7 @@ export class HudManager {
onDropItemToGround?: (name: string) => void
},
) {
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) {
this.hudCanvas.style.cursor = 'none'
}

View File

@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
} from '../src/ui/hotkeys.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
import { SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
let hud: SkillHotkeysHud
const mockFont = {
measureText: (txt: string, font?: string) => {
const charW = font === 'fontexocet10' ? 11 : 8
return txt.length * charW
},
drawText: () => {},
} as unknown as D2FontRenderer
beforeEach(() => {
hud = new SkillHotkeysHud()
})
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
it('formats authentic Attack tooltip with weapon/barehanded narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(0, 'left', false)
expect(lines.length).toBeGreaterThanOrEqual(3)
// Title
expect(lines[0]!.text).toBe('普通攻击 (Attack)')
expect(lines[0]!.color).toBe('gold')
expect(lines[0]!.font).toBe('fontexocet10')
// Narrative
const narrativeLine = lines.find(l => l.text.includes('物理攻击'))
expect(narrativeLine).toBeDefined()
expect(narrativeLine!.color).toBe('white')
// Mana
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
expect(manaLine!.color).toBe('white')
})
it('formats authentic Throw tooltip with throw weapon narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(2, 'left', false)
expect(lines[0]!.text).toBe('投掷 (Throw)')
expect(lines[0]!.color).toBe('gold')
const narrativeLine = lines.find(l => l.text.includes('投掷类武器'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
it('formats authentic Unsummon tooltip with minion dismissal narrative and 0 mana', () => {
const lines = hud.formatTooltipLines(3, 'right', false)
expect(lines[0]!.text).toBe('取消召唤 (Unsummon)')
expect(lines[0]!.color).toBe('gold')
const narrativeLine = lines.find(l => l.text.includes('解散'))
expect(narrativeLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
expect(manaLine).toBeDefined()
})
})
describe('2. Hotkey Binding Prompts & Labels', () => {
it('displays hotkey binding prompt in speedbar popup when unbound', () => {
// Skill 36 (Fire Bolt) not bound initially
const lines = hud.formatTooltipLines(36, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('按 F1-F8 键设置快捷键'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey and rebind prompt in speedbar popup when bound', () => {
// F5 is bound to Fire Ball (47) on left side by default
const lines = hud.formatTooltipLines(47, 'left', true)
const hotkeyLine = lines.find(l => l.text.includes('当前快捷键: F5 (按 F1-F8 键重新绑定)'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
it('displays bound hotkey on main control bar slot', () => {
// Left slot is Fire Ball (47) with F5
const lines = hud.formatTooltipLines(47, 'left', false)
const hotkeyLine = lines.find(l => l.text.includes('快捷键: F5'))
expect(hotkeyLine).toBeDefined()
expect(hotkeyLine!.color).toBe('tan')
})
})
describe('3. Class Skills & ViewModel Integration', () => {
it('integrates with TooltipViewModel from skill engine displaying level, mana, damage, and synergies', () => {
// Mock tooltip resolver
hud.tooltipResolver = (skillId: number) => {
if (skillId === 47) {
return {
skill: SKILLS_BY_ID[47]!,
hardPoints: 15,
effectiveLevel: 23,
isLearned: true,
canAllocateNow: true,
reqlevel: 12,
levelMet: true,
prereqs: [],
current: {
level: 23,
manaCost: 11,
delaySeconds: 0,
damage: {
hasDamage: true,
typeZh: '火焰伤害',
finalMin: 345,
finalMax: 388,
},
effects: [{ textZh: '爆炸半径: 2.6 码' }],
},
nextLevel: null,
synergiesReceived: [
{
providerId: 36,
providerName: 'Fire Bolt',
providerNameZh: '火弹',
percentPerLevel: 14,
hardPoints: 5,
currentBonusPct: 70,
},
],
masteryInfo: null,
} as any
}
return null
}
const lines = hud.formatTooltipLines(47, 'left', false)
// Title
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
expect(lines[0]!.color).toBe('gold')
// Level with soft bonus
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 23 (基础 15 + 装备 +8)'))
expect(lvlLine).toBeDefined()
expect(lvlLine!.color).toBe('blue')
// Mana cost
const manaLine = lines.find(l => l.text.includes('法力消耗: 11'))
expect(manaLine).toBeDefined()
// Narrative description
const descLine = lines.find(l => l.text.includes('爆炸') || l.text.includes('火球'))
expect(descLine).toBeDefined()
// Damage
const dmgLine = lines.find(l => l.text.includes('火焰伤害: 345 - 388'))
expect(dmgLine).toBeDefined()
expect(dmgLine!.color).toBe('blue')
// Effect
const effLine = lines.find(l => l.text.includes('爆炸半径: 2.6 码'))
expect(effLine).toBeDefined()
// Synergy section header and lines
const synHeader = lines.find(l => l.text.includes('[协同技能加成]'))
expect(synHeader).toBeDefined()
expect(synHeader!.color).toBe('blue')
const synLine = lines.find(l => l.text.includes('火弹: 每个有效技能点增加 14% 伤害 (当前 +70%)'))
expect(synLine).toBeDefined()
expect(synLine!.color).toBe('blue')
})
it('falls back cleanly to catalog data when no TooltipViewModel is supplied', () => {
hud.tooltipResolver = undefined
const lines = hud.formatTooltipLines(47, 'left', false)
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
expect(lines[0]!.color).toBe('gold')
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 8'))
expect(lvlLine).toBeDefined()
const manaLine = lines.find(l => l.text.includes('法力消耗: 8.5'))
expect(manaLine).toBeDefined()
})
})
describe('4. Tooltip Viewport Boundary Clamping & Placement', () => {
it('centers and clamps tooltip over Left Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(47, 'left', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(LEFT_SKILL_BOUNDS, lines, mockFont)
// Horizontally clamped inside [8, 800 - 8]
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertically placed strictly above the control bar slot (y <= 551 - 6 - h)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('centers and clamps tooltip over Right Skill Slot without spilling off screen', () => {
const lines = hud.formatTooltipLines(64, 'right', false)
const placement = SkillHotkeysHud.computeTooltipPlacement(RIGHT_SKILL_BOUNDS, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
it('places tooltip above speedbar popup cell and clamps within viewport', () => {
hud.openSpeedbar('left')
const cells = hud.getPopupCells('left')
const cell = cells[0]!
const lines = hud.formatTooltipLines(cell.skill.skillId, 'left', true)
const placement = SkillHotkeysHud.computeTooltipPlacement(cell, lines, mockFont)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
expect(placement.y).toBeGreaterThanOrEqual(8)
})
})
describe('5. Mouse Hover Transitions & Drawing', () => {
it('sets hoveredSlot and hoverSkillId when hovering left skill button', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
expect(hud.hoveredSlot).toBe('left')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredSlot and hoverSkillId when hovering right skill button', () => {
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
expect(hud.hoveredSlot).toBe('right')
expect(hud.hoveredPopupSkill).toBeNull()
// Move away
hud.handleMouseMove(400, 300)
expect(hud.hoverSkillId).toBeNull()
expect(hud.hoveredSlot).toBeNull()
})
it('sets hoveredPopupSkill when hovering inside open speedbar', () => {
hud.openSpeedbar('right')
const cells = hud.getPopupCells('right')
const cell = cells[1]!
hud.handleMouseMove(cell.x + 10, cell.y + 10)
expect(hud.hoveredPopupSkill).toEqual({
side: 'right',
skillId: cell.skill.skillId,
})
// Move away
hud.handleMouseMove(10, 10)
expect(hud.hoveredPopupSkill).toBeNull()
})
it('renders authentic D2 double borders and dark translucent fill when tooltip drawn', () => {
const fillRects: any[] = []
const strokeRects: any[] = []
const drawnTexts: any[] = []
const mockCtx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
fillRect: (...args: any[]) => fillRects.push({ style: mockCtx.fillStyle, args }),
strokeRect: (...args: any[]) => strokeRects.push({ style: mockCtx.strokeStyle, args }),
} as unknown as CanvasRenderingContext2D
const testFont = {
measureText: () => 120,
drawText: (...args: any[]) => drawnTexts.push(args),
} as unknown as D2FontRenderer
// Hover left slot
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
hud.drawSkillTooltip(mockCtx, testFont)
// Verify dark background
const darkFill = fillRects.find(f => f.style === 'rgba(4, 4, 4, 0.94)')
expect(darkFill).toBeDefined()
// Verify outer bronze border (#8c7244) and inner dark border (#3d2b1a)
const outerBorder = strokeRects.find(s => s.style === '#8c7244')
const innerBorder = strokeRects.find(s => s.style === '#3d2b1a')
expect(outerBorder).toBeDefined()
expect(innerBorder).toBeDefined()
// Verify texts drawn (title in gold)
expect(drawnTexts.length).toBeGreaterThanOrEqual(3)
const titleDraw = drawnTexts.find(d => d[1].includes('火球') && d[4].color === 'gold')
expect(titleDraw).toBeDefined()
})
})
describe('6. HudManager End-to-End Class Tooltip Resolution', () => {
const mockCanvas = {
width: 800,
height: 600,
style: { cursor: '' },
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
} as unknown as HTMLCanvasElement
it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on class switch', () => {
const hudMgr = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Switch to Paladin: Left is Sacrifice (96), Right is Blessed Hammer (112)
hudMgr.switchClass('pal')
expect(hudMgr.hotkeys.tooltipResolver).toBeDefined()
const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false)
expect(sacLines[0]!.text).toContain('Sacrifice')
expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true)
const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false)
expect(hammerLines[0]!.text).toContain('Blessed Hammer')
expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
// Switch to Barbarian: Whirlwind (151)
hudMgr.switchClass('bar')
const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false)
expect(wwLines[0]!.text).toContain('Whirlwind')
expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
})
})
})