diablo2-web/src/ui/hotkeys.ts

1481 lines
49 KiB
TypeScript

/**
* Diablo II v1.13c Skill Hotkeys & Speedbar Popup (`spellsel.cpp`).
*
* Per D2Client.dll 0x6fad7440..0x6fad7536:
* - Frame 0 ends at x=117 and Frame 1 begins at x=165 -> 48x48 Left-Click Skill Slot at (117, 551)
* - Frame 4 ends at x=635 and Frame 5 begins at x=683 -> 48x48 Right-Click Skill Slot at (635, 551)
* - Clicking either slot opens the upward-expanding Speedbar grid of available skills
* - Pressing F1..F8 while hovering a skill in the Speedbar binds that F-key; pressing F1..F8
* during gameplay switches the bound side's active skill immediately.
*/
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
readonly name: string
readonly nameZh: string
readonly level: number
readonly manaCost: number
readonly leftUsable: boolean
readonly rightUsable: boolean
readonly isPassive?: boolean
readonly isAura?: boolean
readonly charges?: number
readonly maxCharges?: number
readonly cooldownMs?: number
readonly listRow?: number
readonly reqlevel?: number
readonly isItemSkill?: boolean
readonly tabIndex?: number
}
/**
* Diablo II v1.13c Canonical Passive Skills (Skills.txt `passive = 1`).
* Passive skills provide permanent bonuses and CANNOT be assigned to
* left/right action buttons or appear in the Speedbar popup.
*/
export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Amazon (Tab 1 Passives: Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33)
9, // Critical Strike
13, // Dodge
18, // Avoid
23, // Penetrate
29, // Evade
33, // Pierce
// Sorceress Passives
37, // Warmth
61, // Fire Mastery
63, // Lightning Mastery
65, // Cold Mastery
// Necromancer Passives
69, // Skeleton Mastery
79, // Golem Mastery
89, // Summon Resist
// Barbarian Combat Masteries & Passives
127, // Sword Mastery
128, // Axe Mastery
129, // Mace Mastery
134, // Polearm Mastery
135, // Throwing Mastery
136, // Spear Mastery
141, // Increased Stamina
145, // Iron Skin
148, // Increased Speed
153, // Natural Resistance
// Druid Passives
224, // Lycanthropy
// Assassin Passives
252, // Claw Mastery
263, // Weapon Block
])
export function isPassiveSkill(skillId: number): boolean {
if (skillId === 0) return false
return PASSIVE_SKILL_IDS.has(skillId)
}
/**
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`).
*/
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5)
0, 2, 4, 5,
// Amazon (20 active skills with leftskill = 1)
6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
// Sorceress (10 active skills with leftskill = 1)
36, 38, 39, 41, 45, 47, 49, 53, 55, 64,
// Necromancer (4 active skills with leftskill = 1)
67, 73, 84, 93,
// Paladin (9 active combat skills with leftskill = 1)
96, 97, 101, 106, 107, 111, 112, 116, 121,
// Barbarian (10 active combat skills with leftskill = 1)
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
// Druid (12 active combat/elemental skills with leftskill = 1)
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
// Assassin (14 active martial/trap skills with leftskill = 1)
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
])
export function isLeftUsableSkill(skillId: number): boolean {
if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false
return LEFT_USABLE_SKILL_IDS.has(skillId)
}
/**
* Diablo II v1.13c Paladin Aura Skills (`Skills.txt` `aura = 1`, Tabs 1 & 2).
* Paladin Auras are highlighted in yellow on the Speedbar and Quickbar slots,
* and can only be assigned to the Right-Click slot in authentic Diablo II.
*/
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Defensive Auras (Tab 2)
99, // Prayer
100, // Resist Fire
104, // Defiance
105, // Resist Cold
109, // Cleansing
110, // Resist Lightning
115, // Vigor
120, // Meditation
124, // Redemption
125, // Salvation
// Offensive Auras (Tab 1)
98, // Might
102, // Holy Fire
103, // Thorns
108, // Blessed Aim
113, // Concentration
114, // Holy Freeze
118, // Holy Shock
119, // Sanctuary
122, // Fanaticism
123, // Conviction
])
export function isAuraSkill(skillId: number): boolean {
if (PALADIN_AURA_SKILL_IDS.has(skillId)) return true
const info = SKILLS_BY_ID[skillId]
if (info && info.classCode === 'pal' && (info.tabIndex === 1 || info.tabIndex === 2)) {
return true
}
return false
}
export interface SkillIconMapping {
readonly skillId: number
readonly name: string
readonly iconPath: string
readonly isGeneric: boolean
readonly dc6Path: string
readonly dc6Source: string
readonly frameIndex: number
readonly atlasRect: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly w?: number; readonly h?: number }
}
/**
* Resolves authentic icon mapping for skillId (Parity with 1.13c Skillicon.DC6 frame 2 sword / frame 0 fist, frame 6 throw, frame 4 unsummon).
*/
export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMapping {
if (skillId === 0) {
return {
skillId: 0,
name: 'Attack',
iconPath: hasWeapon ? '/skills/icon_0.png' : '/skills/icon_0_fist.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: hasWeapon ? 2 : 0,
atlasRect: { x: hasWeapon ? 96 : 0, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 2) {
return {
skillId: 2,
name: 'Throw',
iconPath: '/skills/icon_2.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 6,
atlasRect: { x: 288, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 3) {
return {
skillId: 3,
name: 'Unsummon',
iconPath: '/skills/icon_3.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 4,
atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 4) {
return {
skillId: 4,
name: 'Left Hand Throw',
iconPath: '/skills/icon_4.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 12,
atlasRect: { x: 576, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 217 || skillId === 218) {
return {
skillId,
name: skillId === 217 ? 'Scroll of Identify' : 'Book of Identify',
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 14,
atlasRect: { x: 672, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 219 || skillId === 220) {
return {
skillId,
name: skillId === 219 ? 'Scroll of Townportal' : 'Book of Townportal',
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 18,
atlasRect: { x: 864, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
return {
skillId,
name: `Skill ${skillId}`,
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: false,
dc6Path: '',
dc6Source: '',
frameIndex: skillId,
atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
/**
* Resolves authentic 1.13c ListRow for Speedbar popup (0..4).
* Row 0 (bottom): Basic universal actions (Attack 0, Throw 2, Unsummon 3, etc.)
* Rows 1..3: Character skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3)
* Row 4 (top): Item skills (Scrolls/Books of TP & ID, or items marked as item skills)
*/
export function getSkillListRow(skill: HotkeySkillEntry): number {
if (typeof (skill as any).listRow === 'number') {
return (skill as any).listRow
}
const id = skill.skillId
// 1. Item skills (Scrolls/Books of TP & ID, charged items, or marked as item skills)
if (id === 217 || id === 218 || id === 219 || id === 220 || (skill as any).isItemSkill || skill.charges !== undefined) {
return 4
}
// 2. Universal / basic actions
if (id === 0 || id === 1 || id === 2 || id === 3 || id === 4 || id === 5 || id === 350 || (skill as any).isGeneric) {
return 0
}
// 3. Class skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3)
const catalogEntry = SKILLS_BY_ID[id]
if (catalogEntry) {
return catalogEntry.tabIndex + 1
}
if (typeof (skill as any).tabIndex === 'number') {
return (skill as any).tabIndex + 1
}
return 0
}
/**
* Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup.
*/
export function getSkillReqLevel(skill: HotkeySkillEntry): number {
if (typeof (skill as any).reqlevel === 'number') {
return (skill as any).reqlevel
}
const catalogEntry = SKILLS_BY_ID[skill.skillId]
if (catalogEntry && typeof catalogEntry.reqlevel === 'number') {
return catalogEntry.reqlevel
}
return 1
}
export function getAvailableSkillsForSide(
side: 'left' | 'right',
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
): readonly HotkeySkillEntry[] {
return skills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false
if (side === 'left') {
if (isAuraSkill(s.skillId) || s.isAura) return false
if (!isLeftUsableSkill(s.skillId)) return false
return s.leftUsable !== false
} else {
return s.rightUsable !== false
}
})
}
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 DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
]
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
// Universal Skills
...DEFAULT_UNIVERSAL_SKILLS,
// Fire Spells (8 active)
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
{ skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true },
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
// Lightning Spells (9 active)
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
{ skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
{ skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
{ skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
{ skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
// Cold Spells (9 active)
{ skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true },
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
]
export class SkillHotkeysHud {
leftSkillId = 47 // Fire Ball
rightSkillId = 64 // Frozen Orb
/** Which Speedbar popup is currently expanded ('left', 'right', or null). */
openPopup: 'left' | 'right' | null = null
/** Hovered skill inside the Speedbar popup. */
hoveredPopupSkill: { side: 'left' | 'right'; skillId: number } | null = null
/** Currently hovered skill ID in left or right slot. */
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
}
set selectionOpen(val: 'left' | 'right' | null) {
this.openPopup = val
}
/** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */
readonly bindings = new Map<string, { side: 'left' | 'right'; skillId: number }>([
['F1', { side: 'right', skillId: 64 }],
['F2', { side: 'right', skillId: 59 }],
['F3', { side: 'right', skillId: 56 }],
['F4', { side: 'right', skillId: 54 }],
['F5', { side: 'left', skillId: 47 }],
['F6', { side: 'left', skillId: 49 }],
['F7', { side: 'right', skillId: 40 }],
['F8', { side: 'right', skillId: 42 }],
])
availableSkills: HotkeySkillEntry[] = [...DEFAULT_SORCERESS_SKILLS]
hasEquippedWeapon = true
fistImage: HTMLImageElement | null = null
swordImage: HTMLImageElement | null = null
private iconImages = new Map<number, HTMLImageElement>()
private cooldowns = new Map<number, { untilMs: number; totalMs: number }>()
private skillCharges = new Map<number, { charges: number; maxCharges?: number | undefined }>()
setSkillCooldown(skillId: number, durationMs: number, nowMs = performance.now()): void {
if (durationMs <= 0) {
this.cooldowns.delete(skillId)
} else {
this.cooldowns.set(skillId, { untilMs: nowMs + durationMs, totalMs: durationMs })
}
}
clearSkillCooldown(skillId: number): void {
this.cooldowns.delete(skillId)
}
clearAllCooldowns(): void {
this.cooldowns.clear()
}
isSkillOnCooldown(skillId: number, nowMs = performance.now()): boolean {
const cd = this.cooldowns.get(skillId)
if (!cd) return false
if (nowMs >= cd.untilMs) {
this.cooldowns.delete(skillId)
return false
}
return true
}
getSkillCooldownRemaining(skillId: number, nowMs = performance.now()): number {
const cd = this.cooldowns.get(skillId)
if (!cd) return 0
const rem = cd.untilMs - nowMs
if (rem <= 0) {
this.cooldowns.delete(skillId)
return 0
}
return rem
}
setSkillCharges(skillId: number, charges: number, maxCharges?: number | undefined): void {
if (maxCharges !== undefined) {
this.skillCharges.set(skillId, { charges, maxCharges })
} else {
this.skillCharges.set(skillId, { charges })
}
}
getSkillCharges(skillId: number): number | undefined {
const explicit = this.skillCharges.get(skillId)
if (explicit !== undefined) return explicit.charges
const found = this.availableSkills.find(s => s.skillId === skillId)
return found?.charges
}
getSkillMaxCharges(skillId: number): number | undefined {
const explicit = this.skillCharges.get(skillId)
if (explicit !== undefined) return explicit.maxCharges
const found = this.availableSkills.find(s => s.skillId === skillId)
return found?.maxCharges
}
clearSkillCharges(skillId: number): void {
this.skillCharges.delete(skillId)
}
preloadSkillIcons(): void {
if (typeof Image === 'undefined') return
for (const s of this.availableSkills) {
if (s.skillId >= 0 && !this.iconImages.has(s.skillId)) {
const img = new Image()
img.src = `/skills/icon_${s.skillId}.png`
this.iconImages.set(s.skillId, img)
}
}
if (!this.fistImage) {
const fImg = new Image()
fImg.src = '/skills/icon_0_fist.png'
this.fistImage = fImg
}
if (!this.swordImage) {
const sImg = new Image()
sImg.src = '/skills/icon_0_sword.png'
this.swordImage = sImg
}
}
setAvailableSkills(
skills: readonly HotkeySkillEntry[],
defaultLeft?: number,
defaultRight?: number,
): void {
// Passive skills strictly cannot enter the quickbar
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) {
this.leftSkillId = defaultLeft
}
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) {
this.rightSkillId = defaultRight
}
this.preloadSkillIcons()
}
addOrUpdateSkill(entry: HotkeySkillEntry): void {
if (isPassiveSkill(entry.skillId) || entry.isPassive) return
const idx = this.availableSkills.findIndex(s => s.skillId === entry.skillId)
if (idx >= 0) {
this.availableSkills[idx] = entry
} else {
this.availableSkills.push(entry)
}
this.preloadSkillIcons()
}
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
for (const [key, binding] of this.bindings.entries()) {
if (binding.side === side && binding.skillId === skillId) return key
}
return null
}
bindHotkey(fKey: string, side: 'left' | 'right', skillId: number): void {
this.bindings.set(fKey.toUpperCase(), { side, skillId })
}
getHotkey(key: string): number | null {
const bound = this.bindings.get(key.toUpperCase())
return bound ? bound.skillId : null
}
setHotkey(key: string, skillId: number, side: 'left' | 'right' = 'right'): void {
this.bindHotkey(key, side, skillId)
}
handleKeyDown(key: string): boolean {
const res = this.triggerFunctionKey(key)
return res !== null
}
openSpeedbar(side: 'left' | 'right'): void {
this.openPopup = side
}
closeSpeedbar(): void {
this.openPopup = null
}
assignSkill(side: 'left' | 'right', skillId: number): boolean {
if (isPassiveSkill(skillId)) return false
if (side === 'left') {
if (isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
this.leftSkillId = skillId
} else {
this.rightSkillId = skillId
}
return true
}
setDualSkill(skillId: number): boolean {
if (isPassiveSkill(skillId) || isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
this.leftSkillId = skillId
this.rightSkillId = skillId
return true
}
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
return getAvailableSkillsForSide(side, this.availableSkills)
}
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
const key = fKey.toUpperCase()
if (this.openPopup !== null && this.hoveredPopupSkill !== null) {
this.bindHotkey(key, this.hoveredPopupSkill.side, this.hoveredPopupSkill.skillId)
return this.hoveredPopupSkill
}
const bound = this.bindings.get(key)
if (!bound) return null
if (bound.side === 'left') {
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
this.leftSkillId = bound.skillId
}
} else {
if (!isPassiveSkill(bound.skillId)) {
this.rightSkillId = bound.skillId
}
}
return bound
}
getPopupCells(side: 'left' | 'right'): {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] {
const list = this.getAvailableSkillsForSide(side)
const cellW = 48
const cellH = 48
const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
// 1. Group available skills by authentic ListRow (0..4)
const rowMap = new Map<number, HotkeySkillEntry[]>()
for (const skill of list) {
const explicitCharges = this.getSkillCharges(skill.skillId)
const effectiveSkill = (explicitCharges !== undefined && skill.charges === undefined)
? { ...skill, charges: explicitCharges }
: skill
const row = getSkillListRow(effectiveSkill)
let group = rowMap.get(row)
if (!group) {
group = []
rowMap.set(row, group)
}
group.push(effectiveSkill)
}
// 2. Intra-row ordering: sort each row by reqlevel ascending, then skillId ascending
for (const group of rowMap.values()) {
group.sort((a, b) => {
const reqA = getSkillReqLevel(a)
const reqB = getSkillReqLevel(b)
if (reqA !== reqB) return reqA - reqB
return a.skillId - b.skillId
})
}
// 3. Compaction: only consider rows with >= 1 skill, ordered from bottom (Row 0) to top (Row 4)
// Row 0: Basic actions (bottom, closest to slot)
// Rows 1..3: Class skill tree tabs
// Row 4: Item skills (top)
// Unlearned or inactive rows are compacted without blank gaps
const populatedRows = Array.from(rowMap.keys())
.filter(r => (rowMap.get(r)?.length ?? 0) > 0)
.sort((a, b) => a - b)
const cells: {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] = []
populatedRows.forEach((listRow, visualRow) => {
const skillsInRow = rowMap.get(listRow)!
skillsInRow.forEach((skill, col) => {
const rawX = side === 'left' ? base.x + col * cellW : base.x - col * cellW
const rawY = base.y - (visualRow + 1) * cellH - 6
// Clamp strictly within 800x600 viewport canvas bounds
const x = Math.max(0, Math.min(800 - cellW, rawX))
const y = Math.max(0, Math.min(600 - cellH, rawY))
cells.push({ skill, x, y, w: cellW, h: cellH, listRow, visualRow, col })
})
})
return cells
}
getSpeedbarSkills(side: 'left' | 'right'): {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] {
return this.getPopupCells(side)
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoverSkillId = null
this.hoverSpeedbarSkillId = null
this.hoveredPopupSkill = null
this.hoveredSlot = null
if (
logicalX >= LEFT_SKILL_BOUNDS.x &&
logicalX <= LEFT_SKILL_BOUNDS.x + LEFT_SKILL_BOUNDS.width &&
logicalY >= LEFT_SKILL_BOUNDS.y &&
logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height
) {
this.hoverSkillId = this.leftSkillId
this.hoveredSlot = 'left'
return
}
if (
logicalX >= RIGHT_SKILL_BOUNDS.x &&
logicalX <= RIGHT_SKILL_BOUNDS.x + RIGHT_SKILL_BOUNDS.width &&
logicalY >= RIGHT_SKILL_BOUNDS.y &&
logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height
) {
this.hoverSkillId = this.rightSkillId
this.hoveredSlot = 'right'
return
}
if (!this.openPopup) return
const cells = this.getPopupCells(this.openPopup)
for (const c of cells) {
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
this.hoveredPopupSkill = { side: this.openPopup, skillId: c.skill.skillId }
this.hoverSpeedbarSkillId = c.skill.skillId
return
}
}
}
handleClick(logicalX: number, logicalY: number): boolean {
// 1. Check click inside open Speedbar popup
if (this.openPopup) {
const cells = this.getPopupCells(this.openPopup)
for (const c of cells) {
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
if (this.openPopup === 'left') {
if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) {
this.leftSkillId = c.skill.skillId
}
} else {
if (!isPassiveSkill(c.skill.skillId)) {
this.rightSkillId = c.skill.skillId
}
}
this.openPopup = null
return true
}
}
}
// 2. Check click on Left Skill Slot
if (
logicalX >= LEFT_SKILL_BOUNDS.x &&
logicalX <= LEFT_SKILL_BOUNDS.x + LEFT_SKILL_BOUNDS.width &&
logicalY >= LEFT_SKILL_BOUNDS.y &&
logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height
) {
this.openPopup = this.openPopup === 'left' ? null : 'left'
return true
}
// 3. Check click on Right Skill Slot
if (
logicalX >= RIGHT_SKILL_BOUNDS.x &&
logicalX <= RIGHT_SKILL_BOUNDS.x + RIGHT_SKILL_BOUNDS.width &&
logicalY >= RIGHT_SKILL_BOUNDS.y &&
logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height
) {
this.openPopup = this.openPopup === 'right' ? null : 'right'
return true
}
if (this.openPopup) {
this.openPopup = null
return true
}
return false
}
draw(
ctx: CanvasRenderingContext2D,
genericSkillsImg: HTMLImageElement | null,
font: D2FontRenderer,
hasEquippedWeapon = this.hasEquippedWeapon,
nowMs = performance.now(),
): void {
this.hasEquippedWeapon = hasEquippedWeapon
this.drawSkillButton(ctx, LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y, 'left', this.leftSkillId, genericSkillsImg, font, false, nowMs)
this.drawSkillButton(ctx, RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y, 'right', this.rightSkillId, genericSkillsImg, font, false, nowMs)
if (this.openPopup) {
const cells = this.getPopupCells(this.openPopup)
for (const c of cells) {
this.drawSkillButton(
ctx,
c.x,
c.y,
this.openPopup,
c.skill.skillId,
genericSkillsImg,
font,
this.hoveredPopupSkill?.skillId === c.skill.skillId,
nowMs,
)
}
}
// Authentic Diablo II v1.13c Skill Tooltip on Quickbar / Speedbar
this.drawSkillTooltip(ctx, font)
}
private drawSkillButton(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
side: 'left' | 'right',
skillId: number,
genericSkillsImg: HTMLImageElement | null,
font: D2FontRenderer,
highlighted = false,
nowMs = performance.now(),
): void {
ctx.fillStyle = '#14110d'
ctx.fillRect(x, y, 48, 48)
if (skillId === 0) {
if (this.hasEquippedWeapon) {
if (this.swordImage && this.swordImage.complete && this.swordImage.naturalWidth > 0) {
ctx.drawImage(this.swordImage, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 2 * 48, 0, 48, 48, x, y, 48, 48)
} else {
const icon = this.iconImages.get(0)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
}
} else {
if (this.fistImage && this.fistImage.complete && this.fistImage.naturalWidth > 0) {
ctx.drawImage(this.fistImage, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 0, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
}
} else if (skillId === 2) {
const icon = this.iconImages.get(2)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 6 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 3) {
const icon = this.iconImages.get(3)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 4 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 4) {
const icon = this.iconImages.get(4)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 12 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 217 || skillId === 218) {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 14 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 219 || skillId === 220) {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 18 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
}
// 1. Aura skill: yellow border and golden/yellow tint (光环技能图标黄色)
const isAura = isAuraSkill(skillId) || Boolean(this.availableSkills.find(s => s.skillId === skillId)?.isAura)
if (isAura) {
ctx.fillStyle = 'rgba(255, 215, 0, 0.18)'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
// 2. Button Border (yellow for aura, gold/brown for normal)
if (isAura) {
ctx.strokeStyle = highlighted ? '#ffffff' : '#ffd700'
ctx.lineWidth = highlighted ? 2 : 1.5
} else {
ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34'
ctx.lineWidth = highlighted ? 2 : 1
}
ctx.strokeRect(x + 0.5, y + 0.5, 47, 47)
// 3. Cooldown (CD) & Out-of-Charges Mask (技能在cd中的时候显示红色不可用遮罩)
const onCd = this.isSkillOnCooldown(skillId, nowMs)
const charges = this.getSkillCharges(skillId)
const outOfCharges = charges !== undefined && charges <= 0
if (onCd || outOfCharges) {
ctx.fillStyle = 'rgba(220, 20, 20, 0.45)'
ctx.fillRect(x + 1, y + 1, 46, 46)
}
// 4. Hotkey label (F1..F8) at bottom-right
const fKey = this.getHotkeyLabelForSkill(side, skillId)
if (fKey) {
font.drawText(ctx, fKey, x + 44, y + 44, {
font: 'font8',
color: 'gold',
align: 'right',
})
}
// 5. Charges display (充能类技能显示数字)
if (charges !== undefined) {
const chargeColor = charges > 0 ? (isAura ? 'yellow' : 'white') : 'red'
font.drawText(ctx, String(charges), x + 4, y + 44, {
font: 'font8',
color: chargeColor,
})
}
}
/**
* 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
}
if (skillId === 4) {
lines.push({
text: '左手投掷 (Left Hand 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 === 217 || skillId === 218) {
const isBook = skillId === 218
lines.push({
text: isBook ? '辨识之书 (Tome of Identify)' : '辨识卷轴 (Scroll of Identify)',
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',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
})
}
return lines
}
if (skillId === 219 || skillId === 220) {
const isBook = skillId === 220
lines.push({
text: isBook ? '城镇传送之书 (Tome of Town Portal)' : '城镇传送卷轴 (Scroll of Town Portal)',
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',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
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',
})
}
// Aura Tag
const isAura = isAuraSkill(skillId) || entry?.isAura
if (isAura) {
lines.push({
text: '灵气技能 (Aura · 仅右键生效)',
color: 'yellow',
font: 'font8',
align: 'center',
})
}
// Cooldown Tag
if (this.isSkillOnCooldown(skillId)) {
const rem = this.getSkillCooldownRemaining(skillId)
lines.push({
text: `[冷却中: ${(rem / 1000).toFixed(1)} 秒]`,
color: 'red',
font: 'font8',
align: 'center',
})
}
// Charges Tag
const charges = this.getSkillCharges(skillId)
const maxCharges = this.getSkillMaxCharges(skillId)
if (charges !== undefined) {
lines.push({
text: `剩余充能: ${charges}${maxCharges !== undefined ? ` / ${maxCharges}` : ''}`,
color: charges > 0 ? 'blue' : 'red',
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',
})
}
})
}
}