feat(ui): authentic Diablo II 1.13c skill quickbar & speedbar tooltips (Fixes #380)
This commit is contained in:
parent
7f810e40e3
commit
090de33935
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue