feat(ui): dynamic attack icon (fist/sword) and add throw & unsummon default skills (Fixes #376)
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -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<number, HTMLImageElement>()
|
||||
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||