From 090de3393584411cce83b99446d49857592f9c3b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 10:57:58 +0000 Subject: [PATCH] feat(ui): authentic Diablo II 1.13c skill quickbar & speedbar tooltips (Fixes #380) --- src/ui/hotkeys.ts | 429 +++++++++++++++++++++++++-- src/ui/hud-manager.ts | 1 + tests/skill-quickbar-tooltip.test.ts | 346 +++++++++++++++++++++ 3 files changed, 755 insertions(+), 21 deletions(-) create mode 100644 tests/skill-quickbar-tooltip.test.ts diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 295a91c..f7fd8eb 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -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', + }) + } + }) + } } diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 8c83a21..534b0b1 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -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' } diff --git a/tests/skill-quickbar-tooltip.test.ts b/tests/skill-quickbar-tooltip.test.ts new file mode 100644 index 0000000..6766142 --- /dev/null +++ b/tests/skill-quickbar-tooltip.test.ts @@ -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) + }) + }) +})