1481 lines
49 KiB
TypeScript
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',
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|