diff --git a/public/skills/icon_0.png b/public/skills/icon_0.png index ca65c84..a141a92 100644 Binary files a/public/skills/icon_0.png and b/public/skills/icon_0.png differ diff --git a/public/skills/icon_0_fist.png b/public/skills/icon_0_fist.png new file mode 100644 index 0000000..2691bf0 Binary files /dev/null and b/public/skills/icon_0_fist.png differ diff --git a/public/skills/icon_0_sword.png b/public/skills/icon_0_sword.png new file mode 100644 index 0000000..a141a92 Binary files /dev/null and b/public/skills/icon_0_sword.png differ diff --git a/public/skills/icon_2.png b/public/skills/icon_2.png new file mode 100644 index 0000000..6367c9c Binary files /dev/null and b/public/skills/icon_2.png differ diff --git a/public/skills/icon_3.png b/public/skills/icon_3.png new file mode 100644 index 0000000..a9118d0 Binary files /dev/null and b/public/skills/icon_3.png differ diff --git a/public/ui/icon_0.png b/public/ui/icon_0.png index ca65c84..a141a92 100644 Binary files a/public/ui/icon_0.png and b/public/ui/icon_0.png differ diff --git a/public/ui/icon_0_fist.png b/public/ui/icon_0_fist.png new file mode 100644 index 0000000..2691bf0 Binary files /dev/null and b/public/ui/icon_0_fist.png differ diff --git a/public/ui/icon_0_sword.png b/public/ui/icon_0_sword.png new file mode 100644 index 0000000..a141a92 Binary files /dev/null and b/public/ui/icon_0_sword.png differ diff --git a/public/ui/icon_2.png b/public/ui/icon_2.png new file mode 100644 index 0000000..6367c9c Binary files /dev/null and b/public/ui/icon_2.png differ diff --git a/public/ui/icon_3.png b/public/ui/icon_3.png new file mode 100644 index 0000000..a9118d0 Binary files /dev/null and b/public/ui/icon_3.png differ diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 1778dd9..01edaab 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -33,19 +33,43 @@ export interface SkillIconMapping { } /** - * Resolves authentic icon mapping for skillId (Parity with 1.13c Skillicon.DC6 frame 0). + * 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): SkillIconMapping { +export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMapping { if (skillId === 0) { return { skillId: 0, name: 'Attack', - iconPath: '/skills/icon_0.png', + 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: 0, - atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 }, + 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 }, } } return { @@ -72,6 +96,8 @@ export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as c export const DEFAULT_SORCERESS_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 }, { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true }, @@ -117,6 +143,9 @@ export class SkillHotkeysHud { ]) availableSkills: HotkeySkillEntry[] = [...DEFAULT_SORCERESS_SKILLS] + hasEquippedWeapon = true + fistImage: HTMLImageElement | null = null + swordImage: HTMLImageElement | null = null private iconImages = new Map() preloadSkillIcons(): void { @@ -128,6 +157,16 @@ export class SkillHotkeysHud { 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 + } } getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null { @@ -304,7 +343,9 @@ export class SkillHotkeysHud { ctx: CanvasRenderingContext2D, genericSkillsImg: HTMLImageElement | null, font: D2FontRenderer, + hasEquippedWeapon = this.hasEquippedWeapon, ): void { + this.hasEquippedWeapon = hasEquippedWeapon this.drawSkillButton(ctx, LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y, 'left', this.leftSkillId, genericSkillsImg, font) this.drawSkillButton(ctx, RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y, 'right', this.rightSkillId, genericSkillsImg, font) @@ -356,14 +397,59 @@ export class SkillHotkeysHud { ctx.fillStyle = '#14110d' ctx.fillRect(x, y, 48, 48) - const icon = this.iconImages.get(skillId) - if (icon && icon.complete && icon.naturalWidth > 0) { - ctx.drawImage(icon, x, y, 48, 48) - } else if (skillId === 0 && genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { - ctx.drawImage(genericSkillsImg, 0, 0, 48, 48, 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 { - ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' - ctx.fillRect(x + 2, y + 2, 44, 44) + 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) + } } ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34' diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 013df93..8c9637f 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -940,7 +940,12 @@ export class HudManager { ) // 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) - this.hotkeys.draw(ctx, this.images.get('genericSkills') ?? null, this.font) + this.hotkeys.draw( + ctx, + this.images.get('genericSkills') ?? null, + this.font, + Boolean(this.inventory.equipped.weapon1), + ) // 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`) this.belt.draw( diff --git a/tests/hotkeys.test.ts b/tests/hotkeys.test.ts index 35c08a6..487cb55 100644 --- a/tests/hotkeys.test.ts +++ b/tests/hotkeys.test.ts @@ -144,4 +144,114 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => { hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10) expect(hud.hoverSpeedbarSkillId).toBe(0) }) + + it('includes Throw and Unsummon as default universal skills with correct usability (Issue #376)', () => { + const throwSkill = hud.availableSkills.find((s) => s.skillId === 2) + const unsummonSkill = hud.availableSkills.find((s) => s.skillId === 3) + + expect(throwSkill).toBeDefined() + expect(throwSkill!.name).toBe('Throw') + expect(throwSkill!.nameZh).toBe('投掷') + expect(throwSkill!.leftUsable).toBe(true) + expect(throwSkill!.rightUsable).toBe(true) + + expect(unsummonSkill).toBeDefined() + expect(unsummonSkill!.name).toBe('Unsummon') + expect(unsummonSkill!.nameZh).toBe('取消召唤') + expect(unsummonSkill!.leftUsable).toBe(false) + expect(unsummonSkill!.rightUsable).toBe(true) + }) + + it('resolves authentic D2 icons: sword with weapon, fist unarmed, throw and unsummon (Issue #376)', () => { + // Attack with weapon: sword (frame 2) + const swordIcon = resolveSkillIcon(0, true) + expect(swordIcon.iconPath).toBe('/skills/icon_0.png') + expect(swordIcon.frameIndex).toBe(2) + expect(swordIcon.atlasRect.x).toBe(96) + + // Attack without weapon: fist (frame 0) + const fistIcon = resolveSkillIcon(0, false) + expect(fistIcon.iconPath).toBe('/skills/icon_0_fist.png') + expect(fistIcon.frameIndex).toBe(0) + expect(fistIcon.atlasRect.x).toBe(0) + + // Throw (skill 2): frame 6 + const throwIcon = resolveSkillIcon(2) + expect(throwIcon.iconPath).toBe('/skills/icon_2.png') + expect(throwIcon.frameIndex).toBe(6) + expect(throwIcon.atlasRect.x).toBe(288) + + // Unsummon (skill 3): frame 4 + const unsummonIcon = resolveSkillIcon(3) + expect(unsummonIcon.iconPath).toBe('/skills/icon_3.png') + expect(unsummonIcon.frameIndex).toBe(4) + expect(unsummonIcon.atlasRect.x).toBe(192) + }) + + it('renders sword when weapon equipped and fist when unarmed (Issue #376)', () => { + const drawImageCalls: any[] = [] + const ctx = { + fillRect: () => {}, + strokeRect: () => {}, + drawImage: (...args: any[]) => { + drawImageCalls.push(args) + }, + } as unknown as CanvasRenderingContext2D + + const mockAtlas = { + complete: true, + naturalWidth: 1152, + naturalHeight: 48, + } as unknown as HTMLImageElement + + const mockFont = { drawText: () => {} } as any + + hud.leftSkillId = 0 + + // 1. Armed attack: should draw sword (sx = 96, frame 2) + drawImageCalls.length = 0 + hud.draw(ctx, mockAtlas, mockFont, true) + const armedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 96) + expect(armedDraw).toBeDefined() + + // 2. Unarmed attack: should draw fist (sx = 0, frame 0) + drawImageCalls.length = 0 + hud.draw(ctx, mockAtlas, mockFont, false) + const unarmedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 0) + expect(unarmedDraw).toBeDefined() + + // 3. Throw (skill 2): should draw throw (sx = 288, frame 6) + hud.leftSkillId = 2 + drawImageCalls.length = 0 + hud.draw(ctx, mockAtlas, mockFont, true) + const throwDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 288) + expect(throwDraw).toBeDefined() + + // 4. Unsummon (skill 3): should draw unsummon (sx = 192, frame 4) + hud.rightSkillId = 3 + drawImageCalls.length = 0 + hud.draw(ctx, mockAtlas, mockFont, true) + const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192) + expect(unsummonDraw).toBeDefined() + }) + + it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => { + // Left speedbar should include Throw but NOT Unsummon + const leftSkills = hud.getSpeedbarSkills('left') + expect(leftSkills.some((e) => e.skill.skillId === 2)).toBe(true) + expect(leftSkills.some((e) => e.skill.skillId === 3)).toBe(false) + + // Right speedbar should include both Throw and Unsummon + const rightSkills = hud.getSpeedbarSkills('right') + expect(rightSkills.some((e) => e.skill.skillId === 2)).toBe(true) + expect(rightSkills.some((e) => e.skill.skillId === 3)).toBe(true) + + // Assign Throw to left + hud.assignSkill('left', 2) + expect(hud.leftSkillId).toBe(2) + + // Assign Unsummon to right + hud.assignSkill('right', 3) + expect(hud.rightSkillId).toBe(3) + }) })