Merge branch 'feat/issue-376' into main (Fixes #376)

This commit is contained in:
troytt 2026-09-22 10:14:19 +00:00
commit fb43185990
13 changed files with 214 additions and 13 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/skills/icon_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/skills/icon_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_0_fist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/ui/icon_0_sword.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/ui/icon_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -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'

View File

@ -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(

View File

@ -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)
})
})