feat(ui): organize left and right speedbar skill selection by skill tree rows (closes #402)
- Group speedbar skills into dedicated horizontal rows by 1.13c ListRow/SkillPage categories: - Row 0: Universal actions (Attack 0, Throw 2, Unsummon 3, Left Hand Throw 4) - Rows 1..3: Class skill tree tabs (Tab 1, Tab 2, Tab 3) - Row 4: Item and charged skills (Townportal, Identify, charged items) - Compact unlearned or empty tree rows contiguously without blank spacing - Enforce strict 1.13c left-click filtering (83 active leftskill=1 skills; 26 passives and all auras excluded) - Orient left popup upward-rightward and right popup upward-leftward with 800x600 canvas clamping - Sort intra-row skills by reqlevel ascending then skillId ascending - Add comprehensive 7-class parity test suite covering grouping, compaction, filtering, and geometry
This commit is contained in:
parent
375f3296f9
commit
2ac10a38e3
|
|
@ -27,6 +27,10 @@ export interface HotkeySkillEntry {
|
||||||
readonly charges?: number
|
readonly charges?: number
|
||||||
readonly maxCharges?: number
|
readonly maxCharges?: number
|
||||||
readonly cooldownMs?: number
|
readonly cooldownMs?: number
|
||||||
|
readonly listRow?: number
|
||||||
|
readonly reqlevel?: number
|
||||||
|
readonly isItemSkill?: boolean
|
||||||
|
readonly tabIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,17 +82,26 @@ export function isPassiveSkill(skillId: number): boolean {
|
||||||
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`).
|
* 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>([
|
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
|
||||||
0, 2, 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
|
// Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5)
|
||||||
36, 38, 39, 41, 45, 47, 49, 53, 55, 56, 59, 64,
|
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,
|
67, 73, 84, 93,
|
||||||
|
// Paladin (9 active combat skills with leftskill = 1)
|
||||||
96, 97, 101, 106, 107, 111, 112, 116, 121,
|
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,
|
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
|
||||||
225, 229, 230, 232, 238, 239, 240, 242, 243, 245, 248,
|
// 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,
|
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
|
||||||
])
|
])
|
||||||
|
|
||||||
export function isLeftUsableSkill(skillId: number): boolean {
|
export function isLeftUsableSkill(skillId: number): boolean {
|
||||||
if (isPassiveSkill(skillId)) return false
|
if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false
|
||||||
return LEFT_USABLE_SKILL_IDS.has(skillId)
|
return LEFT_USABLE_SKILL_IDS.has(skillId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +195,42 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa
|
||||||
atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 },
|
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 {
|
return {
|
||||||
skillId,
|
skillId,
|
||||||
name: `Skill ${skillId}`,
|
name: `Skill ${skillId}`,
|
||||||
|
|
@ -194,24 +243,78 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
export function getAvailableSkillsForSide(
|
||||||
side: 'left' | 'right',
|
side: 'left' | 'right',
|
||||||
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
|
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
|
||||||
): readonly HotkeySkillEntry[] {
|
): readonly HotkeySkillEntry[] {
|
||||||
return skills.filter(s => {
|
return skills.filter(s => {
|
||||||
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
||||||
return side === 'left' ? s.leftUsable : s.rightUsable
|
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 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 RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
|
||||||
|
|
||||||
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
// Universal Skills
|
|
||||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
{ 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: 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: 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)
|
// Fire Spells (8 active)
|
||||||
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
|
{ 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: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
|
||||||
|
|
@ -219,7 +322,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, 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: 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: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
|
||||||
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, 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 },
|
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
|
||||||
// Lightning Spells (9 active)
|
// Lightning Spells (9 active)
|
||||||
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
|
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
|
||||||
|
|
@ -238,7 +341,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
|
||||||
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, 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: 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: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
|
||||||
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, 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: 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 },
|
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
|
||||||
]
|
]
|
||||||
|
|
@ -377,8 +480,12 @@ export class SkillHotkeysHud {
|
||||||
): void {
|
): void {
|
||||||
// Passive skills strictly cannot enter the quickbar
|
// Passive skills strictly cannot enter the quickbar
|
||||||
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
|
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
|
||||||
if (defaultLeft !== undefined && !isPassiveSkill(defaultLeft)) this.leftSkillId = defaultLeft
|
if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) {
|
||||||
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) this.rightSkillId = defaultRight
|
this.leftSkillId = defaultLeft
|
||||||
|
}
|
||||||
|
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) {
|
||||||
|
this.rightSkillId = defaultRight
|
||||||
|
}
|
||||||
this.preloadSkillIcons()
|
this.preloadSkillIcons()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,23 +535,24 @@ export class SkillHotkeysHud {
|
||||||
|
|
||||||
assignSkill(side: 'left' | 'right', skillId: number): boolean {
|
assignSkill(side: 'left' | 'right', skillId: number): boolean {
|
||||||
if (isPassiveSkill(skillId)) return false
|
if (isPassiveSkill(skillId)) return false
|
||||||
if (side === 'left') this.leftSkillId = skillId
|
if (side === 'left') {
|
||||||
else this.rightSkillId = skillId
|
if (isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
|
||||||
|
this.leftSkillId = skillId
|
||||||
|
} else {
|
||||||
|
this.rightSkillId = skillId
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
setDualSkill(skillId: number): boolean {
|
setDualSkill(skillId: number): boolean {
|
||||||
if (isPassiveSkill(skillId)) return false
|
if (isPassiveSkill(skillId) || isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
|
||||||
this.leftSkillId = skillId
|
this.leftSkillId = skillId
|
||||||
this.rightSkillId = skillId
|
this.rightSkillId = skillId
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
|
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
|
||||||
return this.availableSkills.filter(s => {
|
return getAvailableSkillsForSide(side, this.availableSkills)
|
||||||
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
|
||||||
return side === 'left' ? s.leftUsable : s.rightUsable
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
|
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
|
||||||
|
|
@ -455,8 +563,15 @@ export class SkillHotkeysHud {
|
||||||
}
|
}
|
||||||
const bound = this.bindings.get(key)
|
const bound = this.bindings.get(key)
|
||||||
if (!bound) return null
|
if (!bound) return null
|
||||||
if (bound.side === 'left') this.leftSkillId = bound.skillId
|
if (bound.side === 'left') {
|
||||||
else this.rightSkillId = bound.skillId
|
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
|
||||||
|
this.leftSkillId = bound.skillId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isPassiveSkill(bound.skillId)) {
|
||||||
|
this.rightSkillId = bound.skillId
|
||||||
|
}
|
||||||
|
}
|
||||||
return bound
|
return bound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -466,19 +581,74 @@ export class SkillHotkeysHud {
|
||||||
y: number
|
y: number
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
|
listRow: number
|
||||||
|
visualRow: number
|
||||||
|
col: number
|
||||||
}[] {
|
}[] {
|
||||||
const list = this.getAvailableSkillsForSide(side)
|
const list = this.getAvailableSkillsForSide(side)
|
||||||
const cols = 5
|
|
||||||
const cellW = 48
|
const cellW = 48
|
||||||
const cellH = 48
|
const cellH = 48
|
||||||
const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
|
const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
|
||||||
return list.map((skill, idx) => {
|
|
||||||
const col = idx % cols
|
// 1. Group available skills by authentic ListRow (0..4)
|
||||||
const row = Math.floor(idx / cols)
|
const rowMap = new Map<number, HotkeySkillEntry[]>()
|
||||||
const x = side === 'left' ? base.x + col * cellW : base.x - col * cellW
|
for (const skill of list) {
|
||||||
const y = base.y - (row + 1) * cellH - 6
|
const explicitCharges = this.getSkillCharges(skill.skillId)
|
||||||
return { skill, x, y, w: cellW, h: cellH }
|
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'): {
|
getSpeedbarSkills(side: 'left' | 'right'): {
|
||||||
|
|
@ -487,6 +657,9 @@ export class SkillHotkeysHud {
|
||||||
y: number
|
y: number
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
|
listRow: number
|
||||||
|
visualRow: number
|
||||||
|
col: number
|
||||||
}[] {
|
}[] {
|
||||||
return this.getPopupCells(side)
|
return this.getPopupCells(side)
|
||||||
}
|
}
|
||||||
|
|
@ -536,8 +709,15 @@ export class SkillHotkeysHud {
|
||||||
const cells = this.getPopupCells(this.openPopup)
|
const cells = this.getPopupCells(this.openPopup)
|
||||||
for (const c of cells) {
|
for (const c of cells) {
|
||||||
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
|
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
|
||||||
if (this.openPopup === 'left') this.leftSkillId = c.skill.skillId
|
if (this.openPopup === 'left') {
|
||||||
else this.rightSkillId = c.skill.skillId
|
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
|
this.openPopup = null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +844,36 @@ export class SkillHotkeysHud {
|
||||||
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
|
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
|
||||||
ctx.fillRect(x + 2, y + 2, 44, 44)
|
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 {
|
} else {
|
||||||
const icon = this.iconImages.get(skillId)
|
const icon = this.iconImages.get(skillId)
|
||||||
if (icon && icon.complete && icon.naturalWidth > 0) {
|
if (icon && icon.complete && icon.naturalWidth > 0) {
|
||||||
|
|
@ -841,6 +1051,128 @@ export class SkillHotkeysHud {
|
||||||
return lines
|
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
|
// 2. Class Skills
|
||||||
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
|
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
|
||||||
const entry = this.availableSkills.find(s => s.skillId === skillId)
|
const entry = this.availableSkills.find(s => s.skillId === skillId)
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,13 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
||||||
hud.draw(ctx, mockAtlas, mockFont, true)
|
hud.draw(ctx, mockAtlas, mockFont, true)
|
||||||
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
|
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
|
||||||
expect(unsummonDraw).toBeDefined()
|
expect(unsummonDraw).toBeDefined()
|
||||||
|
|
||||||
|
// 5. Left Hand Throw (skill 4): should draw left hand throw (sx = 576, frame 12)
|
||||||
|
hud.leftSkillId = 4
|
||||||
|
drawImageCalls.length = 0
|
||||||
|
hud.draw(ctx, mockAtlas, mockFont, true)
|
||||||
|
const leftHandThrowDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 576)
|
||||||
|
expect(leftHandThrowDraw).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {
|
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,580 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
SkillHotkeysHud,
|
||||||
|
DEFAULT_UNIVERSAL_SKILLS,
|
||||||
|
DEFAULT_SORCERESS_SKILLS,
|
||||||
|
isLeftUsableSkill,
|
||||||
|
isPassiveSkill,
|
||||||
|
isAuraSkill,
|
||||||
|
getSkillListRow,
|
||||||
|
getSkillReqLevel,
|
||||||
|
LEFT_SKILL_BOUNDS,
|
||||||
|
RIGHT_SKILL_BOUNDS,
|
||||||
|
} from '../src/ui/hotkeys.ts'
|
||||||
|
import type { HotkeySkillEntry } from '../src/ui/hotkeys.ts'
|
||||||
|
import { SKILLS_CATALOG, SKILLS_BY_CLASS } from '../src/data/skills-catalog.ts'
|
||||||
|
import type { SkillCatalogEntry } from '../src/data/skills-catalog.ts'
|
||||||
|
import { CHARACTER_CLASS_CODES } from '../src/game/classes.ts'
|
||||||
|
|
||||||
|
describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => {
|
||||||
|
let hud: SkillHotkeysHud
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hud = new SkillHotkeysHud()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to convert catalog entries into complete HotkeySkillEntry objects
|
||||||
|
function createClassSkillEntries(entries: readonly SkillCatalogEntry[]): HotkeySkillEntry[] {
|
||||||
|
return entries.map(s => ({
|
||||||
|
skillId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
nameZh: s.nameZh,
|
||||||
|
level: 1,
|
||||||
|
manaCost: s.mana,
|
||||||
|
leftUsable: isLeftUsableSkill(s.id),
|
||||||
|
rightUsable: !isPassiveSkill(s.id),
|
||||||
|
isAura: isAuraSkill(s.id),
|
||||||
|
isPassive: isPassiveSkill(s.id),
|
||||||
|
tabIndex: s.tabIndex,
|
||||||
|
reqlevel: s.reqlevel,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Ground Truth Catalog Mapping: ListRow & ReqLevel
|
||||||
|
// =========================================================================
|
||||||
|
describe('1. Ground Truth Catalog Mapping', () => {
|
||||||
|
it('maps all 210 class skills in SKILLS_CATALOG to ListRow = tabIndex + 1 with 100% parity', () => {
|
||||||
|
expect(SKILLS_CATALOG.length).toBe(210)
|
||||||
|
for (const s of SKILLS_CATALOG) {
|
||||||
|
const entry: HotkeySkillEntry = {
|
||||||
|
skillId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
nameZh: s.nameZh,
|
||||||
|
level: 1,
|
||||||
|
manaCost: s.mana,
|
||||||
|
leftUsable: isLeftUsableSkill(s.id),
|
||||||
|
rightUsable: !isPassiveSkill(s.id),
|
||||||
|
tabIndex: s.tabIndex,
|
||||||
|
}
|
||||||
|
const row = getSkillListRow(entry)
|
||||||
|
expect(row).toBe(s.tabIndex + 1)
|
||||||
|
expect(row).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(row).toBeLessThanOrEqual(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps universal / basic actions to ListRow = 0', () => {
|
||||||
|
const basicActions = [
|
||||||
|
{ skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 5, name: 'Left Hand Swing', leftUsable: true, rightUsable: true },
|
||||||
|
{ skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true },
|
||||||
|
]
|
||||||
|
for (const action of basicActions) {
|
||||||
|
const row = getSkillListRow({
|
||||||
|
skillId: action.skillId,
|
||||||
|
name: action.name,
|
||||||
|
nameZh: action.name,
|
||||||
|
level: 1,
|
||||||
|
manaCost: 0,
|
||||||
|
leftUsable: action.leftUsable,
|
||||||
|
rightUsable: action.rightUsable,
|
||||||
|
})
|
||||||
|
expect(row).toBe(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => {
|
||||||
|
const itemSkills = [
|
||||||
|
{ skillId: 217, name: 'Scroll of Identify' },
|
||||||
|
{ skillId: 218, name: 'Tome of Identify' },
|
||||||
|
{ skillId: 219, name: 'Scroll of Town Portal' },
|
||||||
|
{ skillId: 220, name: 'Tome of Town Portal' },
|
||||||
|
{ skillId: 999, name: 'Teleport Charge Item', isItemSkill: true },
|
||||||
|
]
|
||||||
|
for (const item of itemSkills) {
|
||||||
|
const row = getSkillListRow({
|
||||||
|
skillId: item.skillId,
|
||||||
|
name: item.name,
|
||||||
|
nameZh: item.name,
|
||||||
|
level: 1,
|
||||||
|
manaCost: 0,
|
||||||
|
leftUsable: false,
|
||||||
|
rightUsable: true,
|
||||||
|
isItemSkill: (item as any).isItemSkill,
|
||||||
|
})
|
||||||
|
expect(row).toBe(4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps charged class skills (e.g. Teleport 54, Life Tap 82, Lower Resist 91) to ListRow = 4', () => {
|
||||||
|
const chargedSkills: HotkeySkillEntry[] = [
|
||||||
|
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 1, manaCost: 24, charges: 20, maxCharges: 20, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 82, name: 'Life Tap', nameZh: '偷取生命', level: 1, manaCost: 9, charges: 15, maxCharges: 15, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 91, name: 'Lower Resist', nameZh: '降低抵抗', level: 1, manaCost: 22, charges: 10, maxCharges: 10, leftUsable: false, rightUsable: true },
|
||||||
|
]
|
||||||
|
for (const skill of chargedSkills) {
|
||||||
|
expect(getSkillListRow(skill)).toBe(4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retrieves accurate reqlevel from catalog for all skills', () => {
|
||||||
|
for (const s of SKILLS_CATALOG) {
|
||||||
|
const entry: HotkeySkillEntry = {
|
||||||
|
skillId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
nameZh: s.nameZh,
|
||||||
|
level: 1,
|
||||||
|
manaCost: s.mana,
|
||||||
|
leftUsable: true,
|
||||||
|
rightUsable: true,
|
||||||
|
}
|
||||||
|
expect(getSkillReqLevel(entry)).toBe(s.reqlevel)
|
||||||
|
}
|
||||||
|
// Basic actions default to reqlevel 1
|
||||||
|
expect(getSkillReqLevel({ skillId: 0, name: 'Attack', nameZh: '攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1)
|
||||||
|
expect(getSkillReqLevel({ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1)
|
||||||
|
expect(getSkillReqLevel({ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true })).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Usability Filters (Left vs Right) Across All 7 Classes
|
||||||
|
// =========================================================================
|
||||||
|
describe('2. Usability Filters (Left vs Right) Across All 7 Classes', () => {
|
||||||
|
CHARACTER_CLASS_CODES.forEach(cls => {
|
||||||
|
it(`enforces authentic 1.13c left and right filters for ${cls.toUpperCase()}`, () => {
|
||||||
|
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
|
||||||
|
const allSkills: HotkeySkillEntry[] = [...DEFAULT_UNIVERSAL_SKILLS, ...classSkills]
|
||||||
|
hud.setAvailableSkills(allSkills)
|
||||||
|
|
||||||
|
// --- Left Click Speedbar ---
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
for (const cell of leftCells) {
|
||||||
|
const id = cell.skill.skillId
|
||||||
|
// 1. Passive skills strictly filtered out
|
||||||
|
expect(isPassiveSkill(id)).toBe(false)
|
||||||
|
expect(cell.skill.isPassive).toBeFalsy()
|
||||||
|
// 2. Aura skills strictly filtered out
|
||||||
|
expect(isAuraSkill(id)).toBe(false)
|
||||||
|
expect(cell.skill.isAura).toBeFalsy()
|
||||||
|
// 3. Must be an official 1.13c left skill
|
||||||
|
expect(isLeftUsableSkill(id)).toBe(true)
|
||||||
|
// 4. Unsummon (3) is right-only
|
||||||
|
expect(id).not.toBe(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Right Click Speedbar ---
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
for (const cell of rightCells) {
|
||||||
|
const id = cell.skill.skillId
|
||||||
|
// 1. Passive skills strictly filtered out
|
||||||
|
expect(isPassiveSkill(id)).toBe(false)
|
||||||
|
expect(cell.skill.isPassive).toBeFalsy()
|
||||||
|
// 2. Right click allows both active skills and auras
|
||||||
|
expect(cell.skill.rightUsable !== false).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly excludes Meteor (56) and Blizzard (59) from Sorceress left speedbar', () => {
|
||||||
|
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
const leftIds = leftCells.map(c => c.skill.skillId)
|
||||||
|
|
||||||
|
expect(leftIds).not.toContain(56) // Meteor
|
||||||
|
expect(leftIds).not.toContain(59) // Blizzard
|
||||||
|
expect(leftIds).toContain(47) // Fire Ball
|
||||||
|
expect(leftIds).toContain(55) // Glacial Spike
|
||||||
|
expect(leftIds).toContain(38) // Charged Bolt
|
||||||
|
expect(leftIds).toContain(0) // Normal Attack
|
||||||
|
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const rightIds = rightCells.map(c => c.skill.skillId)
|
||||||
|
expect(rightIds).toContain(56) // Meteor present on right
|
||||||
|
expect(rightIds).toContain(59) // Blizzard present on right
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly includes Maul (233) on Druid left speedbar', () => {
|
||||||
|
const druidSkills = createClassSkillEntries(SKILLS_BY_CLASS.dru)
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...druidSkills])
|
||||||
|
const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId)
|
||||||
|
expect(leftIds).toContain(233) // Maul
|
||||||
|
expect(leftIds).not.toContain(228) // Cyclone Armor (buff, leftskill=0)
|
||||||
|
expect(leftIds).not.toContain(226) // Oak Sage (summon, leftskill=0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly excludes all Paladin auras and Holy Shield from left speedbar', () => {
|
||||||
|
const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal)
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills])
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
const leftIds = leftCells.map(c => c.skill.skillId)
|
||||||
|
|
||||||
|
// No aura allowed
|
||||||
|
expect(leftIds).not.toContain(98) // Might
|
||||||
|
expect(leftIds).not.toContain(102) // Holy Fire
|
||||||
|
expect(leftIds).not.toContain(113) // Holy Freeze
|
||||||
|
expect(leftIds).not.toContain(118) // Holy Shock
|
||||||
|
expect(leftIds).not.toContain(122) // Fanaticism
|
||||||
|
expect(leftIds).not.toContain(125) // Salvation
|
||||||
|
expect(leftIds).not.toContain(99) // Prayer
|
||||||
|
expect(leftIds).not.toContain(109) // Cleansing
|
||||||
|
expect(leftIds).not.toContain(115) // Vigor
|
||||||
|
expect(leftIds).not.toContain(124) // Redemption
|
||||||
|
expect(leftIds).not.toContain(117) // Holy Shield (self-buff, leftskill=0)
|
||||||
|
|
||||||
|
// Combat skills allowed
|
||||||
|
expect(leftIds).toContain(96) // Sacrifice
|
||||||
|
expect(leftIds).toContain(97) // Smite
|
||||||
|
expect(leftIds).toContain(101) // Holy Bolt
|
||||||
|
expect(leftIds).toContain(106) // Zeal
|
||||||
|
expect(leftIds).toContain(107) // Charge
|
||||||
|
expect(leftIds).toContain(111) // Vengeance
|
||||||
|
expect(leftIds).toContain(112) // Blessed Hammer
|
||||||
|
expect(leftIds).toContain(116) // Conversion
|
||||||
|
expect(leftIds).toContain(121) // Fist of the Heavens
|
||||||
|
|
||||||
|
// Right speedbar includes auras with yellow border marker
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const rightAuras = rightCells.filter(c => isAuraSkill(c.skill.skillId))
|
||||||
|
expect(rightAuras.length).toBe(20) // All 20 Paladin auras
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly excludes Barbarian combat mastery passives from both speedbars', () => {
|
||||||
|
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills])
|
||||||
|
const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId)
|
||||||
|
const rightIds = hud.getPopupCells('right').map(c => c.skill.skillId)
|
||||||
|
|
||||||
|
const passives = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153]
|
||||||
|
for (const p of passives) {
|
||||||
|
expect(leftIds).not.toContain(p)
|
||||||
|
expect(rightIds).not.toContain(p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Multi-Row Grouping & Row Compaction
|
||||||
|
// =========================================================================
|
||||||
|
describe('3. Multi-Row Grouping & Row Compaction', () => {
|
||||||
|
it('compacts Barbarian right-click speedbar from [0, 1, 3] into contiguous visualRows [0, 1, 2]', () => {
|
||||||
|
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills])
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
|
||||||
|
// Barbarian has:
|
||||||
|
// ListRow 0: Attack (0), Throw (2), Unsummon (3)
|
||||||
|
// ListRow 1: Combat Skills (10 active)
|
||||||
|
// ListRow 2: Combat Masteries (10 PASSIVES -> 0 active)
|
||||||
|
// ListRow 3: Warcries (10 active)
|
||||||
|
const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b)
|
||||||
|
expect(listRowsPresent).toEqual([0, 1, 3])
|
||||||
|
|
||||||
|
const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
|
||||||
|
expect(visualRowsPresent).toEqual([0, 1, 2])
|
||||||
|
|
||||||
|
// Verify that every cell in ListRow 3 is compacted into visualRow 2
|
||||||
|
const warcriesCells = rightCells.filter(c => c.listRow === 3)
|
||||||
|
expect(warcriesCells.length).toBe(10)
|
||||||
|
for (const cell of warcriesCells) {
|
||||||
|
expect(cell.visualRow).toBe(2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compacts Paladin left-click speedbar to contiguous visualRows [0, 1]', () => {
|
||||||
|
const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal)
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills])
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
|
||||||
|
// ListRow 0 (Universal), ListRow 1 (Combat skills). ListRow 2 and 3 are auras (filtered out).
|
||||||
|
const listRowsPresent = Array.from(new Set(leftCells.map(c => c.listRow))).sort((a, b) => a - b)
|
||||||
|
expect(listRowsPresent).toEqual([0, 1])
|
||||||
|
|
||||||
|
const visualRowsPresent = Array.from(new Set(leftCells.map(c => c.visualRow))).sort((a, b) => a - b)
|
||||||
|
expect(visualRowsPresent).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compacts single-tree character without gap rows', () => {
|
||||||
|
// Character that only learned Lightning Spells (Tab 2 => ListRow 2)
|
||||||
|
const lightSpells = createClassSkillEntries(SKILLS_BY_CLASS.sor.filter(s => s.tabIndex === 1 && !isPassiveSkill(s.id)))
|
||||||
|
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...lightSpells])
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
|
||||||
|
// ListRow 0 (Universal) and ListRow 2 (Lightning)
|
||||||
|
const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b)
|
||||||
|
expect(listRowsPresent).toEqual([0, 2])
|
||||||
|
|
||||||
|
// Compacted to visualRow 0 and 1
|
||||||
|
const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
|
||||||
|
expect(visualRowsPresent).toEqual([0, 1])
|
||||||
|
|
||||||
|
const lightCells = rightCells.filter(c => c.listRow === 2)
|
||||||
|
for (const cell of lightCells) {
|
||||||
|
expect(cell.visualRow).toBe(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maintains contiguous visualRow compaction with item skills across all 7 classes', () => {
|
||||||
|
const itemSkills: HotkeySkillEntry[] = [
|
||||||
|
{ skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
|
||||||
|
]
|
||||||
|
for (const cls of CHARACTER_CLASS_CODES) {
|
||||||
|
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills])
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const visualRows = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
|
||||||
|
for (let i = 0; i < visualRows.length; i++) {
|
||||||
|
expect(visualRows[i]).toBe(i)
|
||||||
|
}
|
||||||
|
const itemCells = rightCells.filter(c => c.listRow === 4)
|
||||||
|
const maxRow = Math.max(...visualRows)
|
||||||
|
for (const cell of itemCells) {
|
||||||
|
expect(cell.visualRow).toBe(maxRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('correctly places charged skills in ListRow 4 on a non-native class (Barbarian with Teleport charges)', () => {
|
||||||
|
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
|
||||||
|
const tpChargedItem: HotkeySkillEntry = {
|
||||||
|
skillId: 54,
|
||||||
|
name: 'Teleport',
|
||||||
|
nameZh: '传送',
|
||||||
|
level: 1,
|
||||||
|
manaCost: 24,
|
||||||
|
charges: 20,
|
||||||
|
maxCharges: 20,
|
||||||
|
leftUsable: false,
|
||||||
|
rightUsable: true,
|
||||||
|
}
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem])
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const tpCell = rightCells.find(c => c.skill.skillId === 54)
|
||||||
|
expect(tpCell).toBeDefined()
|
||||||
|
expect(tpCell!.listRow).toBe(4)
|
||||||
|
// Visual rows: 0 (Universal), 1 (Combat skills), 2 (Warcries), 3 (Item row with Teleport)
|
||||||
|
expect(tpCell!.visualRow).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. Intra-Row Ordering
|
||||||
|
// =========================================================================
|
||||||
|
describe('4. Intra-Row Ordering', () => {
|
||||||
|
it('strictly sorts skills within each row by reqlevel ascending, then skillId ascending', () => {
|
||||||
|
for (const cls of CHARACTER_CLASS_CODES) {
|
||||||
|
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
|
||||||
|
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills])
|
||||||
|
|
||||||
|
for (const side of ['left', 'right'] as const) {
|
||||||
|
const cells = hud.getPopupCells(side)
|
||||||
|
// Group cells by visualRow
|
||||||
|
const rows = new Map<number, (typeof cells)[number][]>()
|
||||||
|
for (const c of cells) {
|
||||||
|
let list = rows.get(c.visualRow)
|
||||||
|
if (!list) {
|
||||||
|
list = []
|
||||||
|
rows.set(c.visualRow, list)
|
||||||
|
}
|
||||||
|
list.push(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rowCells of rows.values()) {
|
||||||
|
for (let i = 0; i < rowCells.length - 1; i++) {
|
||||||
|
const a = rowCells[i]
|
||||||
|
const b = rowCells[i + 1]
|
||||||
|
const reqA = getSkillReqLevel(a.skill)
|
||||||
|
const reqB = getSkillReqLevel(b.skill)
|
||||||
|
|
||||||
|
if (reqA === reqB) {
|
||||||
|
expect(a.skill.skillId).toBeLessThan(b.skill.skillId)
|
||||||
|
} else {
|
||||||
|
expect(reqA).toBeLessThan(reqB)
|
||||||
|
}
|
||||||
|
// Verify column index is sequential 0, 1, 2...
|
||||||
|
expect(a.col).toBe(i)
|
||||||
|
expect(b.col).toBe(i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 5. Adaptive Alignment & Canvas Bounds Clamping
|
||||||
|
// =========================================================================
|
||||||
|
describe('5. Adaptive Alignment & Canvas Bounds Clamping', () => {
|
||||||
|
it('expands left speedbar rightward and right speedbar leftward', () => {
|
||||||
|
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||||
|
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
|
||||||
|
// In left popup, col 0 is at base.x (117), col 1 is at 117 + 48 = 165
|
||||||
|
const leftRow0 = leftCells.filter(c => c.visualRow === 0)
|
||||||
|
if (leftRow0.length >= 2) {
|
||||||
|
expect(leftRow0[0].x).toBe(LEFT_SKILL_BOUNDS.x)
|
||||||
|
expect(leftRow0[1].x).toBe(LEFT_SKILL_BOUNDS.x + 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In right popup, col 0 is at base.x (635), col 1 is at 635 - 48 = 587
|
||||||
|
const rightRow0 = rightCells.filter(c => c.visualRow === 0)
|
||||||
|
if (rightRow0.length >= 2) {
|
||||||
|
expect(rightRow0[0].x).toBe(RIGHT_SKILL_BOUNDS.x)
|
||||||
|
expect(rightRow0[1].x).toBe(RIGHT_SKILL_BOUNDS.x - 48)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly clamps all cells within [0, 800 - 48] and [0, 600 - 48]', () => {
|
||||||
|
// Simulate extreme row with 25 skills
|
||||||
|
const megaSkills: HotkeySkillEntry[] = []
|
||||||
|
for (let i = 1; i <= 25; i++) {
|
||||||
|
megaSkills.push({
|
||||||
|
skillId: 1000 + i,
|
||||||
|
name: `Custom Skill ${i}`,
|
||||||
|
nameZh: `自定义技能 ${i}`,
|
||||||
|
level: 1,
|
||||||
|
manaCost: 10,
|
||||||
|
leftUsable: true,
|
||||||
|
rightUsable: true,
|
||||||
|
tabIndex: 0,
|
||||||
|
reqlevel: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hud.setAvailableSkills(megaSkills)
|
||||||
|
|
||||||
|
for (const side of ['left', 'right'] as const) {
|
||||||
|
const cells = hud.getPopupCells(side)
|
||||||
|
for (const cell of cells) {
|
||||||
|
expect(cell.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(cell.x).toBeLessThanOrEqual(800 - 48)
|
||||||
|
expect(cell.y).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(cell.y).toBeLessThanOrEqual(600 - 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 6. Item Skills & Tooltips
|
||||||
|
// =========================================================================
|
||||||
|
describe('6. Item Skills & Tooltips', () => {
|
||||||
|
it('positions item skills at the top row (ListRow = 4)', () => {
|
||||||
|
const skillsWithItems: HotkeySkillEntry[] = [
|
||||||
|
...DEFAULT_SORCERESS_SKILLS,
|
||||||
|
{ skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
|
||||||
|
{ skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
|
||||||
|
]
|
||||||
|
hud.setAvailableSkills(skillsWithItems)
|
||||||
|
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const itemCells = rightCells.filter(c => c.listRow === 4)
|
||||||
|
expect(itemCells.length).toBe(2)
|
||||||
|
|
||||||
|
const maxVisualRow = Math.max(...rightCells.map(c => c.visualRow))
|
||||||
|
for (const cell of itemCells) {
|
||||||
|
expect(cell.visualRow).toBe(maxVisualRow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => {
|
||||||
|
hud.setSkillCharges(219, 12, 20)
|
||||||
|
const tpLines = hud.formatTooltipLines(219, 'right', true)
|
||||||
|
expect(tpLines[0].text).toContain('城镇传送卷轴')
|
||||||
|
expect(tpLines.some(l => l.text.includes('12'))).toBe(true)
|
||||||
|
|
||||||
|
hud.setSkillCharges(218, 20, 20)
|
||||||
|
const idLines = hud.formatTooltipLines(218, 'right', true)
|
||||||
|
expect(idLines[0].text).toContain('辨识之书')
|
||||||
|
expect(idLines.some(l => l.text.includes('20'))).toBe(true)
|
||||||
|
|
||||||
|
const lhtLines = hud.formatTooltipLines(4, 'left', true)
|
||||||
|
expect(lhtLines[0].text).toContain('左手投掷')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 7. Interactive Assignments & Guardrails
|
||||||
|
// =========================================================================
|
||||||
|
describe('7. Interactive Assignments & Guardrails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects assigning Meteor (56) and Blizzard (59) to left slot', () => {
|
||||||
|
expect(hud.assignSkill('left', 56)).toBe(false)
|
||||||
|
expect(hud.assignSkill('left', 59)).toBe(false)
|
||||||
|
expect(hud.leftSkillId).not.toBe(56)
|
||||||
|
expect(hud.leftSkillId).not.toBe(59)
|
||||||
|
|
||||||
|
// Right slot succeeds
|
||||||
|
expect(hud.assignSkill('right', 56)).toBe(true)
|
||||||
|
expect(hud.rightSkillId).toBe(56)
|
||||||
|
expect(hud.assignSkill('right', 59)).toBe(true)
|
||||||
|
expect(hud.rightSkillId).toBe(59)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects assigning auras to left slot', () => {
|
||||||
|
expect(hud.assignSkill('left', 113)).toBe(false) // Holy Freeze
|
||||||
|
expect(hud.leftSkillId).not.toBe(113)
|
||||||
|
|
||||||
|
expect(hud.assignSkill('right', 113)).toBe(true)
|
||||||
|
expect(hud.rightSkillId).toBe(113)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects assigning passive skills to any slot', () => {
|
||||||
|
expect(hud.assignSkill('left', 61)).toBe(false) // Fire Mastery
|
||||||
|
expect(hud.assignSkill('right', 61)).toBe(false) // Fire Mastery
|
||||||
|
expect(hud.assignSkill('left', 127)).toBe(false) // Sword Mastery
|
||||||
|
expect(hud.assignSkill('right', 127)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces left-usability on setDualSkill', () => {
|
||||||
|
expect(hud.setDualSkill(0)).toBe(true) // Attack
|
||||||
|
expect(hud.leftSkillId).toBe(0)
|
||||||
|
expect(hud.rightSkillId).toBe(0)
|
||||||
|
|
||||||
|
expect(hud.setDualSkill(56)).toBe(false) // Meteor
|
||||||
|
expect(hud.setDualSkill(113)).toBe(false) // Holy Freeze
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles speedbar popup opening, cell click selection, and slot closing', () => {
|
||||||
|
expect(hud.openPopup).toBeNull()
|
||||||
|
|
||||||
|
// 1. Click Left Slot opens left popup
|
||||||
|
const leftBounds = LEFT_SKILL_BOUNDS
|
||||||
|
hud.handleClick(leftBounds.x + 10, leftBounds.y + 10)
|
||||||
|
expect(hud.openPopup).toBe('left')
|
||||||
|
|
||||||
|
// 2. Select a cell in left popup (e.g. Fire Ball 47)
|
||||||
|
const leftCells = hud.getPopupCells('left')
|
||||||
|
const fbCell = leftCells.find(c => c.skill.skillId === 47)!
|
||||||
|
expect(fbCell).toBeDefined()
|
||||||
|
|
||||||
|
hud.handleClick(fbCell.x + 5, fbCell.y + 5)
|
||||||
|
expect(hud.leftSkillId).toBe(47)
|
||||||
|
expect(hud.openPopup).toBeNull()
|
||||||
|
|
||||||
|
// 3. Click Right Slot opens right popup
|
||||||
|
const rightBounds = RIGHT_SKILL_BOUNDS
|
||||||
|
hud.handleClick(rightBounds.x + 10, rightBounds.y + 10)
|
||||||
|
expect(hud.openPopup).toBe('right')
|
||||||
|
|
||||||
|
// 4. Select Blizzard (59) in right popup
|
||||||
|
const rightCells = hud.getPopupCells('right')
|
||||||
|
const blizCell = rightCells.find(c => c.skill.skillId === 59)!
|
||||||
|
expect(blizCell).toBeDefined()
|
||||||
|
|
||||||
|
hud.handleClick(blizCell.x + 5, blizCell.y + 5)
|
||||||
|
expect(hud.rightSkillId).toBe(59)
|
||||||
|
expect(hud.openPopup).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue