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.
|
* 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 {
|
export interface HotkeySkillEntry {
|
||||||
readonly skillId: number
|
readonly skillId: number
|
||||||
|
|
@ -91,8 +94,8 @@ export function getAvailableSkillsForSide(
|
||||||
return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
|
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 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 } 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[] = [
|
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||||
|
|
@ -122,6 +125,10 @@ export class SkillHotkeysHud {
|
||||||
hoverSkillId: number | null = null
|
hoverSkillId: number | null = null
|
||||||
/** Currently hovered skill ID inside speedbar. */
|
/** Currently hovered skill ID inside speedbar. */
|
||||||
hoverSpeedbarSkillId: number | null = null
|
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 {
|
get selectionOpen(): 'left' | 'right' | null {
|
||||||
return this.openPopup
|
return this.openPopup
|
||||||
|
|
@ -275,6 +282,7 @@ export class SkillHotkeysHud {
|
||||||
this.hoverSkillId = null
|
this.hoverSkillId = null
|
||||||
this.hoverSpeedbarSkillId = null
|
this.hoverSpeedbarSkillId = null
|
||||||
this.hoveredPopupSkill = null
|
this.hoveredPopupSkill = null
|
||||||
|
this.hoveredSlot = null
|
||||||
|
|
||||||
if (
|
if (
|
||||||
logicalX >= LEFT_SKILL_BOUNDS.x &&
|
logicalX >= LEFT_SKILL_BOUNDS.x &&
|
||||||
|
|
@ -283,6 +291,7 @@ export class SkillHotkeysHud {
|
||||||
logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height
|
logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height
|
||||||
) {
|
) {
|
||||||
this.hoverSkillId = this.leftSkillId
|
this.hoverSkillId = this.leftSkillId
|
||||||
|
this.hoveredSlot = 'left'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +302,7 @@ export class SkillHotkeysHud {
|
||||||
logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height
|
logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height
|
||||||
) {
|
) {
|
||||||
this.hoverSkillId = this.rightSkillId
|
this.hoverSkillId = this.rightSkillId
|
||||||
|
this.hoveredSlot = 'right'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,25 +384,10 @@ export class SkillHotkeysHud {
|
||||||
this.hoveredPopupSkill?.skillId === c.skill.skillId,
|
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(
|
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
|
onDropItemToGround?: (name: string) => void
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
|
||||||
if (this.hudCanvas) {
|
if (this.hudCanvas) {
|
||||||
this.hudCanvas.style.cursor = 'none'
|
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