feat(skills): add normal attack icon and enable dual left/right slot binding (Fixes #151)

This commit is contained in:
troytt 2026-09-22 06:19:19 +00:00
parent efc14b9925
commit bc6b5cd331
8 changed files with 1039 additions and 27 deletions

BIN
public/skills/icon_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/ui/icon_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -373,6 +373,22 @@ async function main(): Promise<void> {
const genericSkills = await decodeUiDc6('data/global/ui/SPELLS/Skillicon.DC6')
const gsStrip = stitchHorizontalStrip(genericSkills.groups[0]!.frames)
savePng('generic-skills.png', gsStrip.width, gsStrip.height, gsStrip.pixels)
const attackFrame = genericSkills.groups[0]!.frames[0]!
savePng('icon_0.png', attackFrame.width, attackFrame.height, attackFrame.indices)
try {
const skillsOut = join(process.cwd(), 'public', 'skills')
mkdirSync(skillsOut, { recursive: true })
const attackPng = encodeIndexedPng({
width: attackFrame.width,
height: attackFrame.height,
pixels: attackFrame.indices,
palette: pl2.rgb,
transparentIndex: 0,
})
writeFileSync(join(skillsOut, 'icon_0.png'), attackPng)
} catch {
// optional copy to public/skills
}
const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6')
savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices)
@ -662,6 +678,7 @@ async function main(): Promise<void> {
levelSocket: '/ui/level-socket.png',
skillPoints: '/ui/skillpoints.png',
genericSkills: '/ui/generic-skills.png',
attackIcon: '/ui/icon_0.png',
cursorHand: '/ui/cursor-hand.png',
cursorAtlas: '/ui/cursor.png',
questTabs: '/ui/quest-tabs.png',

View File

@ -66,6 +66,7 @@ import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } f
import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { InventoryPanel } from '../ui/inventory.ts'
/** Base locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
@ -687,8 +688,214 @@ export function isPassiveSkill(skillId: number): boolean {
return def?.type === 'passive'
}
export function getSkillRange(skillId: number): number {
if (skillId === 0) return 52 // playerReach melee
export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
export type AttackWeaponSubtype =
| 'fist'
| 'sword'
| 'axe'
| 'mace'
| 'dagger'
| 'staff'
| 'polearm'
| 'spear'
| 'bow'
| 'crossbow'
| 'other'
export interface AttackWeaponInfo {
readonly kind: AttackWeaponKind
readonly subtype: AttackWeaponSubtype
readonly isRanged: boolean
readonly ammoType?: 'arrow' | 'bolt' | undefined
readonly damageMin: number
readonly damageMax: number
readonly reach: number
readonly cooldown: number
readonly swingTicks: number
}
const BOW_CODES = new Set([
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
'am1', 'am2', 'am6', 'am7', 'amb',
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
'amc', 'amd', 'ame', 'amj', 'amk',
])
const XBOW_CODES = new Set([
'lxb', 'mxb', 'hxb', 'rxb',
'8lx', '8mx', '8hx', '8rx',
'6lx', '6mx', '6hx', '6rx',
])
export interface AttackWeaponInputItem {
code?: string | undefined
type?: string | undefined
wclass?: string | undefined
name?: string | undefined
nameZh?: string | undefined
damage?: string | undefined
}
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
if (!item || !item.code) {
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: 1,
damageMax: 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
const code = item.code.trim().toLowerCase()
const type = (item.type ?? '').trim().toLowerCase()
const wclass = (item.wclass ?? '').trim().toLowerCase()
const name = (item.name ?? '').toLowerCase()
const nameZh = (item.nameZh ?? '').toLowerCase()
let dMin = 3
let dMax = 8
if (item.damage) {
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
if (m) {
dMin = parseInt(m[1], 10)
dMax = parseInt(m[2], 10)
}
}
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'crossbow',
isRanged: true,
ammoType: 'bolt',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 16,
swingTicks: 8,
}
}
// 2. Bow
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
return {
kind: 'ranged',
subtype: 'bow',
isRanged: true,
ammoType: 'arrow',
damageMin: dMin,
damageMax: dMax,
reach: 450,
cooldown: 14,
swingTicks: 7,
}
}
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
let subtype: AttackWeaponSubtype | null = null
let reach = 52
if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
subtype = 'sword'
reach = 52
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
subtype = 'axe'
reach = 52
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
subtype = 'mace'
reach = 52
} else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) {
subtype = 'dagger'
reach = 48
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
subtype = 'staff'
reach = 52
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
subtype = 'polearm'
reach = 60
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
subtype = 'spear'
reach = 60
}
if (subtype !== null) {
return {
kind: 'melee',
subtype,
isRanged: false,
damageMin: dMin,
damageMax: dMax,
reach,
cooldown: 14,
swingTicks: 7,
}
}
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
return {
kind: 'unarmed',
subtype: 'fist',
isRanged: false,
damageMin: dMin > 3 ? dMin : 1,
damageMax: dMax > 8 ? dMax : 2,
reach: 52,
cooldown: 12,
swingTicks: 6,
}
}
export function checkAmmunition(
ammoType: 'arrow' | 'bolt',
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | InventoryPanel | null | undefined,
): boolean {
// 1. Check offhand equipment (weapon2)
if (offhandItem && offhandItem.code) {
const code = offhandItem.code.toLowerCase().trim()
const name = (offhandItem.name ?? '').toLowerCase()
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
// 2. Check inventory grid / items
if (inventory) {
const invAny = inventory as any
const list: any[] = invAny.gridItems
? invAny.gridItems.map((g: any) => g.item ?? g)
: invAny.grid
? invAny.grid.map((g: any) => g.item ?? g)
: (invAny.items ?? [])
for (const it of list) {
if (!it || !it.code) continue
const code = String(it.code).toLowerCase().trim()
const name = String(it.name ?? '').toLowerCase()
const nameZh = String(it.nameZh ?? '').toLowerCase()
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
}
}
return false
}
export function getSkillRange(
skillId: number,
hudManager?: HudManager | null | undefined,
equippedWeapon?: AttackWeaponInputItem | null | undefined,
): number {
if (skillId === 0) {
const hud = hudManager ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance : null)
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
const info = getAttackWeaponInfo(weapon)
return info.reach
}
const def = getBatch1SkillDef(skillId)
if (def !== undefined) {
if (def.type === 'melee') return 52
@ -739,6 +946,9 @@ export function castSkill(
status: HTMLElement
playerAnimator?: ActorAnimator | undefined
character?: LoadedCharacter | null | undefined
equippedWeapon?: any | null | undefined
equippedAmmo?: any | null | undefined
hasAmmo?: boolean | undefined
},
): boolean {
const { engine, runtime, hudManager, status, playerAnimator, character } = context
@ -783,16 +993,58 @@ export function castSkill(
// 5. Dispatch skill
if (skillId === 0) {
// Normal Melee Attack
player.cooldown = 12
player.swingTicks = 6
const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null)
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
// Ranged Attack (Bow / Crossbow)
const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory)
if (!hasAmmo) {
status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'
return false
}
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const speed = 500
const range = weaponInfo.reach
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
const projectile: Projectile = {
skillId: '0',
x: player.x,
y: player.y,
vx: len > 0 ? (dx / len) * perTick : perTick,
vy: len > 0 ? (dy / len) * perTick : 0,
damage,
ttl,
fromPlayer: true,
}
engine.projectiles.push(projectile)
engine.metrics.casts += 1
status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'
return true
}
// Unarmed or Melee Attack
player.cooldown = weaponInfo.cooldown
player.swingTicks = weaponInfo.swingTicks
let targetMonster: Monster | null = null
// Prioritize monster near target cursor within melee reach of player
let bestTargetDist = 36
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
if (distToPlayer <= 52) {
if (distToPlayer <= weaponInfo.reach) {
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
const minD = Math.min(distFeet, distBody)
@ -802,9 +1054,8 @@ export function castSkill(
}
}
}
// Fallback: closest monster to player within melee reach
if (targetMonster === null) {
let bestDist = 52
let bestDist = weaponInfo.reach
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
@ -814,10 +1065,13 @@ export function castSkill(
}
}
}
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
if (targetMonster !== null) {
damageMonster(engine.world, targetMonster.index, 7)
damageMonster(engine.world, targetMonster.index, damage)
engine.metrics.playerHits += 1
status.textContent = `普通攻击命中:${targetMonster.stats.name || targetMonster.stats.id}`
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
} else {
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
}
@ -985,7 +1239,7 @@ export type PendingInteraction =
| { readonly kind: 'npc'; readonly npc: NpcEntity }
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
| { readonly kind: 'monster'; readonly monster: Monster }
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
declare global {
interface HTMLCanvasElement {
@ -1125,7 +1379,17 @@ export class SceneMouseController {
}
} else if (e.button === 2) {
this.isRightMouseDown = true
this.handleRightClickWorld(worldX, worldY)
if (this.input.shiftHeld) {
this.activeClickType = 'none'
this.navTarget = null
this.pendingInteraction = null
const player = this.engine.world.player
player.facing = facingOf(worldX - player.x, worldY - player.y)
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
this.castSkill(rightSkillId, worldX, worldY)
} else {
this.handleRightClickWorld(worldX, worldY)
}
}
}
@ -1144,7 +1408,7 @@ export class SceneMouseController {
this.navTarget = { x: worldX, y: worldY }
} else if (this.isRightMouseDown) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
this.navTarget = { x: worldX, y: worldY }
}
@ -1166,14 +1430,63 @@ export class SceneMouseController {
handleRightClickWorld(worldX: number, worldY: number): void {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId)) {
// Walk/Run fallback
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y)
this.stuckTicks = 0
return
}
const player = this.engine.world.player
const range = getSkillRange(rightSkillId, this.hudManager)
// 1. Check Monster: living monster within ~36 px
let clickedMonster: Monster | null = null
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
}
}
if (clickedMonster !== null) {
this.activeClickType = 'monster'
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
this.castSkill(rightSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
}
// 2. Ground clicked with active right skill
if (rightSkillId === 0) {
const weapon = this.hudManager?.inventory?.equipped?.weapon1
const weaponInfo = getAttackWeaponInfo(weapon)
if (weaponInfo.isRanged) {
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
} else {
this.activeClickType = 'ground'
this.pendingInteraction = null
this.navTarget = { x: worldX, y: worldY }
this.lastDist = Math.hypot(worldX - player.x, worldY - player.y)
this.stuckTicks = 0
}
} else {
// Active skill execution: halt movement and cancel pending interactions
this.navTarget = null
this.pendingInteraction = null
this.castSkill(rightSkillId, worldX, worldY)
@ -1280,7 +1593,7 @@ export class SceneMouseController {
if (clickedMonster !== null) {
this.activeClickType = 'monster'
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const range = getSkillRange(leftSkillId, this.hudManager)
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
@ -1288,7 +1601,7 @@ export class SceneMouseController {
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y)
} else {
this.pendingInteraction = { kind: 'monster', monster: clickedMonster }
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId }
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
}
return
@ -1318,7 +1631,16 @@ export class SceneMouseController {
})
}
castSkill(skillId: number, targetX: number, targetY: number): boolean {
castSkill(
skillId: number,
targetX: number,
targetY: number,
options?: {
equippedWeapon?: any
equippedAmmo?: any
hasAmmo?: boolean
}
): boolean {
return castSkill(skillId, targetX, targetY, {
engine: this.engine,
runtime: this.getRuntime(),
@ -1326,6 +1648,9 @@ export class SceneMouseController {
status: this.status,
playerAnimator: this.playerAnimator,
character: this.getCharacter ? this.getCharacter() : undefined,
equippedWeapon: options?.equippedWeapon,
equippedAmmo: options?.equippedAmmo,
hasAmmo: options?.hasAmmo,
})
}
@ -1379,7 +1704,10 @@ export class SceneMouseController {
}
} else if (this.isRightMouseDown && this.lastClientPos !== null) {
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
if (isPassiveSkill(rightSkillId)) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY }
} else if (player.cooldown === 0) {
@ -1437,14 +1765,14 @@ export class SceneMouseController {
this.navTarget = null
this.pendingInteraction = null
} else {
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId)
const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47)
const range = getSkillRange(skillId, this.hudManager)
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
if (dist <= range) {
this.navTarget = null
this.pendingInteraction = null
player.facing = facingOf(monster.x - player.x, monster.y - player.y)
this.castSkill(leftSkillId, monster.x, monster.y)
this.castSkill(skillId, monster.x, monster.y)
} else {
this.navTarget = { x: monster.x, y: monster.y }
}

View File

@ -21,11 +21,57 @@ export interface HotkeySkillEntry {
readonly rightUsable: boolean
}
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 0).
*/
export function resolveSkillIcon(skillId: number): SkillIconMapping {
if (skillId === 0) {
return {
skillId: 0,
name: 'Attack',
iconPath: '/skills/icon_0.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 },
}
}
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 },
}
}
export function getAvailableSkillsForSide(
side: 'left' | 'right',
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
): readonly HotkeySkillEntry[] {
return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
}
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_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: false },
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, 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 },
@ -46,6 +92,18 @@ export class SkillHotkeysHud {
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
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 }],
@ -64,7 +122,7 @@ export class SkillHotkeysHud {
preloadSkillIcons(): void {
if (typeof Image === 'undefined') return
for (const s of this.availableSkills) {
if (s.skillId > 0 && !this.iconImages.has(s.skillId)) {
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)
@ -83,6 +141,42 @@ export class SkillHotkeysHud {
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): void {
if (side === 'left') this.leftSkillId = skillId
else this.rightSkillId = skillId
}
setDualSkill(skillId: number): void {
this.leftSkillId = skillId
this.rightSkillId = skillId
}
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
return this.availableSkills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
}
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
const key = fKey.toUpperCase()
if (this.openPopup !== null && this.hoveredPopupSkill !== null) {
@ -96,14 +190,14 @@ export class SkillHotkeysHud {
return bound
}
private getPopupCells(side: 'left' | 'right'): {
getPopupCells(side: 'left' | 'right'): {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
}[] {
const list = this.availableSkills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
const list = this.getAvailableSkillsForSide(side)
const cols = 5
const cellW = 48
const cellH = 48
@ -117,13 +211,47 @@ export class SkillHotkeysHud {
})
}
getSpeedbarSkills(side: 'left' | 'right'): {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
}[] {
return this.getPopupCells(side)
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoverSkillId = null
this.hoverSpeedbarSkillId = null
this.hoveredPopupSkill = 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
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
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
}
}

392
tests/attack-skill.test.ts Normal file
View File

@ -0,0 +1,392 @@
/**
* Tests for Normal Attack Icon Completion & Dual Slot Binding Mechanics (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_SORCERESS_SKILLS,
SkillHotkeysHud,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
import {
getAttackWeaponInfo,
checkAmmunition,
getSkillRange,
castSkill,
type AttackWeaponInputItem,
} from '../src/scene/act-scene.ts'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import type { Monster } from '../src/game/combat.ts'
describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
describe('Skill 0 Definition & Dual-Palette Usability', () => {
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => {
const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
expect(attackSkill).toBeDefined()
expect(attackSkill!.name).toBe('Attack')
expect(attackSkill!.nameZh).toBe('普通攻击')
expect(attackSkill!.leftUsable).toBe(true)
expect(attackSkill!.rightUsable).toBe(true)
expect(attackSkill!.manaCost).toBe(0)
})
it('includes Skill 0 in both left and right available skill palettes', () => {
const leftSkills = getAvailableSkillsForSide('left')
const rightSkills = getAvailableSkillsForSide('right')
const leftAttack = leftSkills.find((s) => s.skillId === 0)
const rightAttack = rightSkills.find((s) => s.skillId === 0)
expect(leftAttack).toBeDefined()
expect(rightAttack).toBeDefined()
expect(leftAttack!.name).toBe('Attack')
expect(rightAttack!.name).toBe('Attack')
})
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
const hud = new SkillHotkeysHud()
// Initial defaults
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
// Assign Attack to left slot
hud.assignSkill('left', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(64)
// Assign Attack to right slot
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Change left slot back to Fire Ball
hud.assignSkill('left', 47)
expect(hud.leftSkillId).toBe(47)
expect(hud.rightSkillId).toBe(0)
// Use helper setDualSkill
hud.setDualSkill(0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
})
})
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => {
const iconMapping = resolveSkillIcon(0)
expect(iconMapping).toBeDefined()
expect(iconMapping.skillId).toBe(0)
expect(iconMapping.name).toBe('Attack')
expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(iconMapping.frameIndex).toBe(0)
expect(iconMapping.atlasRect.x).toBe(0)
expect(iconMapping.atlasRect.y).toBe(0)
expect(iconMapping.atlasRect.width).toBe(48)
expect(iconMapping.atlasRect.height).toBe(48)
})
it('verifies public/skills/icon_0.png exists and is a valid 48x48 PNG image', () => {
const iconPath = join(process.cwd(), 'public', 'skills', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
// Check PNG magic header: \x89PNG\r\n\x1a\n
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50) // P
expect(buf[2]).toBe(0x4e) // N
expect(buf[3]).toBe(0x47) // G
expect(buf[4]).toBe(0x0d) // \r
expect(buf[5]).toBe(0x0a) // \n
expect(buf[6]).toBe(0x1a)
expect(buf[7]).toBe(0x0a) // \n
// Check IHDR dimensions (width at bytes 16-19, height at bytes 20-23)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(48)
expect(height).toBe(48)
})
it('verifies public/ui/icon_0.png exists for baked UI references', () => {
const iconPath = join(process.cwd(), 'public', 'ui', 'icon_0.png')
expect(existsSync(iconPath)).toBe(true)
const buf = readFileSync(iconPath)
expect(buf.readUInt32BE(16)).toBe(48)
expect(buf.readUInt32BE(20)).toBe(48)
})
})
describe('Weapon Timing, Damage, and Ammunition Checks', () => {
it('evaluates unarmed attack characteristics correctly', () => {
const unarmed = getAttackWeaponInfo(null)
expect(unarmed.kind).toBe('unarmed')
expect(unarmed.subtype).toBe('fist')
expect(unarmed.isRanged).toBe(false)
expect(unarmed.damageMin).toBe(1)
expect(unarmed.damageMax).toBe(2)
expect(unarmed.reach).toBe(52)
expect(unarmed.cooldown).toBe(12)
expect(unarmed.swingTicks).toBe(6)
})
it('evaluates melee weapon attack timing and damage correctly', () => {
const crystalSword: AttackWeaponInputItem = {
code: 'crs',
name: 'Crystal Sword',
damage: '5 to 15',
}
const swordInfo = getAttackWeaponInfo(crystalSword)
expect(swordInfo.kind).toBe('melee')
expect(swordInfo.subtype).toBe('sword')
expect(swordInfo.isRanged).toBe(false)
expect(swordInfo.damageMin).toBe(5)
expect(swordInfo.damageMax).toBe(15)
expect(swordInfo.reach).toBe(52)
expect(swordInfo.cooldown).toBe(14)
expect(swordInfo.swingTicks).toBe(7)
// Test extended reach for polearm
const polearm: AttackWeaponInputItem = {
code: 'vou',
name: 'Voulge',
damage: '6 - 21',
}
const polearmInfo = getAttackWeaponInfo(polearm)
expect(polearmInfo.subtype).toBe('polearm')
expect(polearmInfo.reach).toBe(60)
})
it('evaluates ranged weapon (bow/crossbow) timing and ammo requirement', () => {
const shortBow: AttackWeaponInputItem = {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
}
const bowInfo = getAttackWeaponInfo(shortBow)
expect(bowInfo.kind).toBe('ranged')
expect(bowInfo.subtype).toBe('bow')
expect(bowInfo.isRanged).toBe(true)
expect(bowInfo.ammoType).toBe('arrow')
expect(bowInfo.reach).toBe(450)
expect(bowInfo.cooldown).toBe(14)
const lightCrossbow: AttackWeaponInputItem = {
code: 'lxb',
name: 'Light Crossbow',
damage: '6 to 9',
}
const xbowInfo = getAttackWeaponInfo(lightCrossbow)
expect(xbowInfo.kind).toBe('ranged')
expect(xbowInfo.subtype).toBe('crossbow')
expect(xbowInfo.isRanged).toBe(true)
expect(xbowInfo.ammoType).toBe('bolt')
expect(xbowInfo.reach).toBe(450)
expect(xbowInfo.cooldown).toBe(16)
})
it('verifies checkAmmunition for bows and crossbows', () => {
// Offhand arrow equipped
expect(checkAmmunition('arrow', { code: 'aqv', name: 'Arrows' })).toBe(true)
// Offhand bolt equipped when bow needs arrow -> false
expect(checkAmmunition('arrow', { code: 'cqv', name: 'Bolts' })).toBe(false)
// Offhand bolt equipped when crossbow needs bolt -> true
expect(checkAmmunition('bolt', { code: 'cqv', name: 'Bolts' })).toBe(true)
// Ammo in inventory grid
const invWithArrows = {
gridItems: [{ item: { code: 'aqv', nameZh: '箭矢' } }],
}
expect(checkAmmunition('arrow', null, invWithArrows)).toBe(true)
expect(checkAmmunition('bolt', null, invWithArrows)).toBe(false)
const invWithBolts = {
items: [{ code: 'cqv', nameZh: '十字弓弹' }],
}
expect(checkAmmunition('bolt', null, invWithBolts)).toBe(true)
expect(checkAmmunition('arrow', null, invWithBolts)).toBe(false)
// Empty inventory & offhand
expect(checkAmmunition('arrow', null, null)).toBe(false)
expect(checkAmmunition('bolt', null, { items: [] })).toBe(false)
})
})
describe('Combat Execution via castSkill', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = {
level: 'test',
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
waypoints: [],
}
beforeEach(() => {
const dummyTerrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.cooldown = 0
statusElem = { textContent: '' } as any
})
it('executes unarmed attack hitting a nearby monster with base physical damage and unarmed timing', () => {
const monster: Monster = {
index: 0,
x: 120,
y: 100,
hp: 50,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie', name: 'Zombie', hp: 50, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
expect(engine.world.player.swingTicks).toBe(6)
expect(monster.hp).toBeLessThan(50) // damaged
expect(engine.metrics.playerHits).toBe(1)
expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中')
})
it('executes melee weapon attack with equipped weapon timing and damage', () => {
const monster: Monster = {
index: 0,
x: 130,
y: 100,
hp: 100,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'skeleton', name: 'Skeleton', hp: 100, damage: 5, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 15 },
}
engine.world.monsters = [monster]
const success = castSkill(0, 130, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'crs',
name: 'Crystal Sword',
damage: '20 to 30',
},
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.world.player.swingTicks).toBe(7)
// Damage should be (20 + 30) / 2 = 25
expect(monster.hp).toBe(75)
expect(statusElem.textContent).toContain('普通攻击命中')
})
it('fails fast on ranged attack when ammunition is missing', () => {
const success = castSkill(0, 200, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '1 to 4',
},
hasAmmo: false,
})
expect(success).toBe(false)
expect(engine.projectiles.length).toBe(0)
expect(statusElem.textContent).toBe('缺少箭矢!')
})
it('launches basic arrow projectile on ranged attack when ammunition is present', () => {
const success = castSkill(0, 300, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: {
code: 'sbw',
name: 'Short Bow',
damage: '10 to 20',
},
hasAmmo: true,
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(14)
expect(engine.projectiles.length).toBe(1)
const proj = engine.projectiles[0]
expect(proj.skillId).toBe('0')
expect(proj.fromPlayer).toBe(true)
expect(proj.damage).toBe(15) // average of 10 and 20
expect(proj.vx).toBeGreaterThan(0)
expect(statusElem.textContent).toContain('发射箭矢')
})
it('records whiff event when melee attack is performed with no monster in reach', () => {
engine.world.monsters = [] // no monsters
const success = castSkill(0, 120, 100, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
equippedWeapon: null, // unarmed
})
expect(success).toBe(true)
expect(engine.world.player.cooldown).toBe(12)
const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff')
expect(whiffEvent).toBeDefined()
})
})
})

147
tests/hotkeys.test.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* Tests for SkillHotkeysHud, speedbars, keybindings, and Attack slot binding (Issue #151).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
DEFAULT_SORCERESS_SKILLS,
resolveSkillIcon,
getAvailableSkillsForSide,
} from '../src/ui/hotkeys.ts'
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
it('initializes with expected default skills and state', () => {
expect(hud.leftSkillId).toBe(47) // Fire Ball
expect(hud.rightSkillId).toBe(64) // Frozen Orb
expect(hud.selectionOpen).toBeNull()
expect(hud.availableSkills.length).toBeGreaterThanOrEqual(10)
// Skill 0 is available
const attack = hud.availableSkills.find((s) => s.skillId === 0)
expect(attack).toBeDefined()
expect(attack!.leftUsable).toBe(true)
expect(attack!.rightUsable).toBe(true)
})
it('opens and closes left selection speedbar on click', () => {
// Click center of left skill icon
const leftX = LEFT_SKILL_BOUNDS.x + 24
const leftY = LEFT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(leftX, leftY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('left')
// Click again to close
hud.handleClick(leftX, leftY)
expect(hud.selectionOpen).toBeNull()
})
it('opens and closes right selection speedbar on click', () => {
// Click center of right skill icon
const rightX = RIGHT_SKILL_BOUNDS.x + 24
const rightY = RIGHT_SKILL_BOUNDS.y + 24
const handled = hud.handleClick(rightX, rightY)
expect(handled).toBe(true)
expect(hud.selectionOpen).toBe('right')
// Click outside to close
hud.handleClick(100, 100)
expect(hud.selectionOpen).toBeNull()
})
it('allows selecting Normal Attack from left speedbar palette', () => {
hud.openSpeedbar('left')
expect(hud.selectionOpen).toBe('left')
// Find Attack in speedbar layout
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
// Click on Attack icon in speedbar
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.leftSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull() // closed after selection
})
it('allows selecting Normal Attack from right speedbar palette', () => {
hud.openSpeedbar('right')
expect(hud.selectionOpen).toBe('right')
const speedbarSkills = hud.getSpeedbarSkills('right')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
expect(clicked).toBe(true)
expect(hud.rightSkillId).toBe(0)
expect(hud.selectionOpen).toBeNull()
})
it('supports dual slot binding with Attack on both slots', () => {
hud.assignSkill('left', 0)
hud.assignSkill('right', 0)
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
// Verify resolveSkillIcon works for both slots
const leftIcon = resolveSkillIcon(hud.leftSkillId)
const rightIcon = resolveSkillIcon(hud.rightSkillId)
expect(leftIcon.skillId).toBe(0)
expect(rightIcon.skillId).toBe(0)
expect(leftIcon.name).toBe('Attack')
expect(rightIcon.name).toBe('Attack')
expect(leftIcon.iconPath).toBe('/skills/icon_0.png')
expect(rightIcon.iconPath).toBe('/skills/icon_0.png')
})
it('manages F1-F8 hotkey bindings and key presses', () => {
// Bind F1 to Attack (0)
hud.setHotkey('F1', 0)
expect(hud.getHotkey('F1')).toBe(0)
// Bind F2 to Frozen Orb (64)
hud.setHotkey('F2', 64)
expect(hud.getHotkey('F2')).toBe(64)
// Pressing F1 assigns right skill to 0
const handledF1 = hud.handleKeyDown('F1')
expect(handledF1).toBe(true)
expect(hud.rightSkillId).toBe(0)
// Pressing F2 assigns right skill to 64
const handledF2 = hud.handleKeyDown('F2')
expect(handledF2).toBe(true)
expect(hud.rightSkillId).toBe(64)
})
it('handles mouse hover over skill buttons and speedbar icons', () => {
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
hud.openSpeedbar('left')
const speedbarSkills = hud.getSpeedbarSkills('left')
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
expect(attackEntry).toBeDefined()
hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10)
expect(hud.hoverSpeedbarSkillId).toBe(0)
})
})