feat(ui): authentic Diablo II 1.13c quickbar cd mask, aura styling, charges and passive filtering (Fixes #382)
This commit is contained in:
parent
775f4113c9
commit
4f6eadea6c
|
|
@ -22,6 +22,95 @@ export interface HotkeySkillEntry {
|
|||
readonly manaCost: number
|
||||
readonly leftUsable: boolean
|
||||
readonly rightUsable: boolean
|
||||
readonly isPassive?: boolean
|
||||
readonly isAura?: boolean
|
||||
readonly charges?: number
|
||||
readonly maxCharges?: number
|
||||
readonly cooldownMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Diablo II v1.13c Canonical Passive Skills (Skills.txt `passive = 1`).
|
||||
* Passive skills provide permanent bonuses and CANNOT be assigned to
|
||||
* left/right action buttons or appear in the Speedbar popup.
|
||||
*/
|
||||
export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
|
||||
// Amazon (Tab 1 Passives)
|
||||
9, // Critical Strike
|
||||
14, // Penetrate
|
||||
17, // Dodge
|
||||
22, // Avoid
|
||||
27, // Evade
|
||||
29, // Pierce
|
||||
// Sorceress Passives
|
||||
37, // Warmth
|
||||
61, // Fire Mastery
|
||||
63, // Lightning Mastery
|
||||
65, // Cold Mastery
|
||||
// Necromancer Passives
|
||||
69, // Skeleton Mastery
|
||||
79, // Golem Mastery
|
||||
89, // Summon Resist
|
||||
// Barbarian Combat Masteries & Passives
|
||||
127, // Sword Mastery
|
||||
128, // Axe Mastery
|
||||
129, // Mace Mastery
|
||||
134, // Polearm Mastery
|
||||
135, // Throwing Mastery
|
||||
136, // Spear Mastery
|
||||
141, // Increased Stamina
|
||||
145, // Iron Skin
|
||||
148, // Increased Speed
|
||||
153, // Natural Resistance
|
||||
// Druid Passives
|
||||
224, // Lycanthropy
|
||||
// Assassin Passives
|
||||
252, // Claw Mastery
|
||||
263, // Weapon Block
|
||||
])
|
||||
|
||||
export function isPassiveSkill(skillId: number): boolean {
|
||||
if (skillId === 0) return false
|
||||
return PASSIVE_SKILL_IDS.has(skillId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Diablo II v1.13c Paladin Aura Skills (`Skills.txt` `aura = 1`, Tabs 1 & 2).
|
||||
* Paladin Auras are highlighted in yellow on the Speedbar and Quickbar slots,
|
||||
* and can only be assigned to the Right-Click slot in authentic Diablo II.
|
||||
*/
|
||||
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set<number>([
|
||||
// Defensive Auras (Tab 2)
|
||||
99, // Prayer
|
||||
100, // Resist Fire
|
||||
104, // Defiance
|
||||
105, // Resist Cold
|
||||
109, // Cleansing
|
||||
110, // Resist Lightning
|
||||
115, // Vigor
|
||||
120, // Meditation
|
||||
124, // Redemption
|
||||
125, // Salvation
|
||||
// Offensive Auras (Tab 1)
|
||||
98, // Might
|
||||
102, // Holy Fire
|
||||
103, // Thorns
|
||||
108, // Blessed Aim
|
||||
113, // Concentration
|
||||
114, // Holy Freeze
|
||||
118, // Holy Shock
|
||||
119, // Sanctuary
|
||||
122, // Fanaticism
|
||||
123, // Conviction
|
||||
])
|
||||
|
||||
export function isAuraSkill(skillId: number): boolean {
|
||||
if (PALADIN_AURA_SKILL_IDS.has(skillId)) return true
|
||||
const info = SKILLS_BY_ID[skillId]
|
||||
if (info && info.classCode === 'pal' && (info.tabIndex === 1 || info.tabIndex === 2)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface SkillIconMapping {
|
||||
|
|
@ -91,7 +180,10 @@ export function getAvailableSkillsForSide(
|
|||
side: 'left' | 'right',
|
||||
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
|
||||
): readonly HotkeySkillEntry[] {
|
||||
return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
|
||||
return skills.filter(s => {
|
||||
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
||||
return side === 'left' ? s.leftUsable : s.rightUsable
|
||||
})
|
||||
}
|
||||
|
||||
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const
|
||||
|
|
@ -154,6 +246,71 @@ export class SkillHotkeysHud {
|
|||
fistImage: HTMLImageElement | null = null
|
||||
swordImage: HTMLImageElement | null = null
|
||||
private iconImages = new Map<number, HTMLImageElement>()
|
||||
private cooldowns = new Map<number, { untilMs: number; totalMs: number }>()
|
||||
private skillCharges = new Map<number, { charges: number; maxCharges?: number | undefined }>()
|
||||
|
||||
setSkillCooldown(skillId: number, durationMs: number, nowMs = performance.now()): void {
|
||||
if (durationMs <= 0) {
|
||||
this.cooldowns.delete(skillId)
|
||||
} else {
|
||||
this.cooldowns.set(skillId, { untilMs: nowMs + durationMs, totalMs: durationMs })
|
||||
}
|
||||
}
|
||||
|
||||
clearSkillCooldown(skillId: number): void {
|
||||
this.cooldowns.delete(skillId)
|
||||
}
|
||||
|
||||
clearAllCooldowns(): void {
|
||||
this.cooldowns.clear()
|
||||
}
|
||||
|
||||
isSkillOnCooldown(skillId: number, nowMs = performance.now()): boolean {
|
||||
const cd = this.cooldowns.get(skillId)
|
||||
if (!cd) return false
|
||||
if (nowMs >= cd.untilMs) {
|
||||
this.cooldowns.delete(skillId)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
getSkillCooldownRemaining(skillId: number, nowMs = performance.now()): number {
|
||||
const cd = this.cooldowns.get(skillId)
|
||||
if (!cd) return 0
|
||||
const rem = cd.untilMs - nowMs
|
||||
if (rem <= 0) {
|
||||
this.cooldowns.delete(skillId)
|
||||
return 0
|
||||
}
|
||||
return rem
|
||||
}
|
||||
|
||||
setSkillCharges(skillId: number, charges: number, maxCharges?: number | undefined): void {
|
||||
if (maxCharges !== undefined) {
|
||||
this.skillCharges.set(skillId, { charges, maxCharges })
|
||||
} else {
|
||||
this.skillCharges.set(skillId, { charges })
|
||||
}
|
||||
}
|
||||
|
||||
getSkillCharges(skillId: number): number | undefined {
|
||||
const explicit = this.skillCharges.get(skillId)
|
||||
if (explicit !== undefined) return explicit.charges
|
||||
const found = this.availableSkills.find(s => s.skillId === skillId)
|
||||
return found?.charges
|
||||
}
|
||||
|
||||
getSkillMaxCharges(skillId: number): number | undefined {
|
||||
const explicit = this.skillCharges.get(skillId)
|
||||
if (explicit !== undefined) return explicit.maxCharges
|
||||
const found = this.availableSkills.find(s => s.skillId === skillId)
|
||||
return found?.maxCharges
|
||||
}
|
||||
|
||||
clearSkillCharges(skillId: number): void {
|
||||
this.skillCharges.delete(skillId)
|
||||
}
|
||||
|
||||
preloadSkillIcons(): void {
|
||||
if (typeof Image === 'undefined') return
|
||||
|
|
@ -181,9 +338,10 @@ export class SkillHotkeysHud {
|
|||
defaultLeft?: number,
|
||||
defaultRight?: number,
|
||||
): void {
|
||||
this.availableSkills = [...skills]
|
||||
if (defaultLeft !== undefined) this.leftSkillId = defaultLeft
|
||||
if (defaultRight !== undefined) this.rightSkillId = defaultRight
|
||||
// Passive skills strictly cannot enter the quickbar
|
||||
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
|
||||
if (defaultLeft !== undefined && !isPassiveSkill(defaultLeft)) this.leftSkillId = defaultLeft
|
||||
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) this.rightSkillId = defaultRight
|
||||
this.preloadSkillIcons()
|
||||
}
|
||||
|
||||
|
|
@ -220,18 +378,25 @@ export class SkillHotkeysHud {
|
|||
this.openPopup = null
|
||||
}
|
||||
|
||||
assignSkill(side: 'left' | 'right', skillId: number): void {
|
||||
assignSkill(side: 'left' | 'right', skillId: number): boolean {
|
||||
if (isPassiveSkill(skillId)) return false
|
||||
if (side === 'left') this.leftSkillId = skillId
|
||||
else this.rightSkillId = skillId
|
||||
return true
|
||||
}
|
||||
|
||||
setDualSkill(skillId: number): void {
|
||||
setDualSkill(skillId: number): boolean {
|
||||
if (isPassiveSkill(skillId)) return false
|
||||
this.leftSkillId = skillId
|
||||
this.rightSkillId = skillId
|
||||
return true
|
||||
}
|
||||
|
||||
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
|
||||
return this.availableSkills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
|
||||
return this.availableSkills.filter(s => {
|
||||
if (isPassiveSkill(s.skillId) || s.isPassive) return false
|
||||
return side === 'left' ? s.leftUsable : s.rightUsable
|
||||
})
|
||||
}
|
||||
|
||||
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
|
||||
|
|
@ -365,10 +530,11 @@ export class SkillHotkeysHud {
|
|||
genericSkillsImg: HTMLImageElement | null,
|
||||
font: D2FontRenderer,
|
||||
hasEquippedWeapon = this.hasEquippedWeapon,
|
||||
nowMs = performance.now(),
|
||||
): void {
|
||||
this.hasEquippedWeapon = hasEquippedWeapon
|
||||
this.drawSkillButton(ctx, LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y, 'left', this.leftSkillId, genericSkillsImg, font)
|
||||
this.drawSkillButton(ctx, RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y, 'right', this.rightSkillId, genericSkillsImg, font)
|
||||
this.drawSkillButton(ctx, LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y, 'left', this.leftSkillId, genericSkillsImg, font, false, nowMs)
|
||||
this.drawSkillButton(ctx, RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y, 'right', this.rightSkillId, genericSkillsImg, font, false, nowMs)
|
||||
|
||||
if (this.openPopup) {
|
||||
const cells = this.getPopupCells(this.openPopup)
|
||||
|
|
@ -382,6 +548,7 @@ export class SkillHotkeysHud {
|
|||
genericSkillsImg,
|
||||
font,
|
||||
this.hoveredPopupSkill?.skillId === c.skill.skillId,
|
||||
nowMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +566,7 @@ export class SkillHotkeysHud {
|
|||
genericSkillsImg: HTMLImageElement | null,
|
||||
font: D2FontRenderer,
|
||||
highlighted = false,
|
||||
nowMs = performance.now(),
|
||||
): void {
|
||||
ctx.fillStyle = '#14110d'
|
||||
ctx.fillRect(x, y, 48, 48)
|
||||
|
|
@ -458,10 +626,33 @@ export class SkillHotkeysHud {
|
|||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34'
|
||||
ctx.lineWidth = highlighted ? 2 : 1
|
||||
// 1. Aura skill: yellow border and golden/yellow tint (光环技能图标黄色)
|
||||
const isAura = isAuraSkill(skillId) || Boolean(this.availableSkills.find(s => s.skillId === skillId)?.isAura)
|
||||
if (isAura) {
|
||||
ctx.fillStyle = 'rgba(255, 215, 0, 0.18)'
|
||||
ctx.fillRect(x + 2, y + 2, 44, 44)
|
||||
}
|
||||
|
||||
// 2. Button Border (yellow for aura, gold/brown for normal)
|
||||
if (isAura) {
|
||||
ctx.strokeStyle = highlighted ? '#ffffff' : '#ffd700'
|
||||
ctx.lineWidth = highlighted ? 2 : 1.5
|
||||
} else {
|
||||
ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34'
|
||||
ctx.lineWidth = highlighted ? 2 : 1
|
||||
}
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, 47, 47)
|
||||
|
||||
// 3. Cooldown (CD) & Out-of-Charges Mask (技能在cd中的时候显示红色不可用遮罩)
|
||||
const onCd = this.isSkillOnCooldown(skillId, nowMs)
|
||||
const charges = this.getSkillCharges(skillId)
|
||||
const outOfCharges = charges !== undefined && charges <= 0
|
||||
if (onCd || outOfCharges) {
|
||||
ctx.fillStyle = 'rgba(220, 20, 20, 0.45)'
|
||||
ctx.fillRect(x + 1, y + 1, 46, 46)
|
||||
}
|
||||
|
||||
// 4. Hotkey label (F1..F8) at bottom-right
|
||||
const fKey = this.getHotkeyLabelForSkill(side, skillId)
|
||||
if (fKey) {
|
||||
font.drawText(ctx, fKey, x + 44, y + 44, {
|
||||
|
|
@ -470,6 +661,15 @@ export class SkillHotkeysHud {
|
|||
align: 'right',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Charges display (充能类技能显示数字)
|
||||
if (charges !== undefined) {
|
||||
const chargeColor = charges > 0 ? (isAura ? 'yellow' : 'white') : 'red'
|
||||
font.drawText(ctx, String(charges), x + 4, y + 44, {
|
||||
font: 'font8',
|
||||
color: chargeColor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -627,6 +827,40 @@ export class SkillHotkeysHud {
|
|||
})
|
||||
}
|
||||
|
||||
// Aura Tag
|
||||
const isAura = isAuraSkill(skillId) || entry?.isAura
|
||||
if (isAura) {
|
||||
lines.push({
|
||||
text: '灵气技能 (Aura · 仅右键生效)',
|
||||
color: 'yellow',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// Cooldown Tag
|
||||
if (this.isSkillOnCooldown(skillId)) {
|
||||
const rem = this.getSkillCooldownRemaining(skillId)
|
||||
lines.push({
|
||||
text: `[冷却中: ${(rem / 1000).toFixed(1)} 秒]`,
|
||||
color: 'red',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// Charges Tag
|
||||
const charges = this.getSkillCharges(skillId)
|
||||
const maxCharges = this.getSkillMaxCharges(skillId)
|
||||
if (charges !== undefined) {
|
||||
lines.push({
|
||||
text: `剩余充能: ${charges}${maxCharges !== undefined ? ` / ${maxCharges}` : ''}`,
|
||||
color: charges > 0 ? 'blue' : 'red',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
if (vm) {
|
||||
// Current level
|
||||
const soft = vm.effectiveLevel - vm.hardPoints
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Diablo II v1.13c Quickbar & Speedbar Parity Tests (Issue #382).
|
||||
*
|
||||
* Requirements:
|
||||
* 1. 技能在cd中的时候显示红色不可用遮罩 (Red unavailable overlay on cooldown)
|
||||
* 2. 光环技能图标黄色 (Aura skill icon yellow border & tint)
|
||||
* 3. 充能类技能显示数字 (Charged skills display remaining charges number)
|
||||
* 4. 被动技能不进入快捷栏 (Passive skills do not enter quickbar speedbar)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
PASSIVE_SKILL_IDS,
|
||||
PALADIN_AURA_SKILL_IDS,
|
||||
isPassiveSkill,
|
||||
isAuraSkill,
|
||||
getAvailableSkillsForSide,
|
||||
type HotkeySkillEntry,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
let font: D2FontRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
font = new D2FontRenderer()
|
||||
})
|
||||
|
||||
describe('1. Passive Skills Filtering (被动技能不进入快捷栏)', () => {
|
||||
it('identifies all 23 canonical Diablo II 1.13c passive skills', () => {
|
||||
// Amazon passives
|
||||
expect(isPassiveSkill(9)).toBe(true) // Critical Strike
|
||||
expect(isPassiveSkill(14)).toBe(true) // Penetrate
|
||||
expect(isPassiveSkill(17)).toBe(true) // Dodge
|
||||
expect(isPassiveSkill(22)).toBe(true) // Avoid
|
||||
expect(isPassiveSkill(27)).toBe(true) // Evade
|
||||
expect(isPassiveSkill(29)).toBe(true) // Pierce
|
||||
|
||||
// Sorceress passives
|
||||
expect(isPassiveSkill(37)).toBe(true) // Warmth
|
||||
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
|
||||
expect(isPassiveSkill(63)).toBe(true) // Lightning Mastery
|
||||
expect(isPassiveSkill(65)).toBe(true) // Cold Mastery
|
||||
|
||||
// Necromancer passives
|
||||
expect(isPassiveSkill(69)).toBe(true) // Skeleton Mastery
|
||||
expect(isPassiveSkill(79)).toBe(true) // Golem Mastery
|
||||
expect(isPassiveSkill(89)).toBe(true) // Summon Resist
|
||||
|
||||
// Barbarian passives
|
||||
expect(isPassiveSkill(127)).toBe(true) // Sword Mastery
|
||||
expect(isPassiveSkill(141)).toBe(true) // Increased Stamina
|
||||
expect(isPassiveSkill(145)).toBe(true) // Iron Skin
|
||||
expect(isPassiveSkill(148)).toBe(true) // Increased Speed
|
||||
expect(isPassiveSkill(153)).toBe(true) // Natural Resistance
|
||||
|
||||
// Druid & Assassin passives
|
||||
expect(isPassiveSkill(224)).toBe(true) // Lycanthropy
|
||||
expect(isPassiveSkill(252)).toBe(true) // Claw Mastery
|
||||
expect(isPassiveSkill(263)).toBe(true) // Weapon Block
|
||||
|
||||
// Active skills should return false
|
||||
expect(isPassiveSkill(0)).toBe(false) // Attack
|
||||
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
|
||||
expect(isPassiveSkill(47)).toBe(false) // Fire Ball
|
||||
expect(isPassiveSkill(54)).toBe(false) // Teleport
|
||||
expect(isPassiveSkill(64)).toBe(false) // Frozen Orb
|
||||
expect(isPassiveSkill(98)).toBe(false) // Might (Aura)
|
||||
})
|
||||
|
||||
it('filters out passive skills from getAvailableSkillsForSide', () => {
|
||||
const mixedSkills: HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true, isPassive: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 61, name: 'Fire Mastery', nameZh: '火焰强化', level: 5, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
const leftList = getAvailableSkillsForSide('left', mixedSkills)
|
||||
const rightList = getAvailableSkillsForSide('right', mixedSkills)
|
||||
|
||||
expect(leftList.map(s => s.skillId)).toEqual([0, 47])
|
||||
expect(rightList.map(s => s.skillId)).toEqual([0, 47])
|
||||
})
|
||||
|
||||
it('strips passive skills when setAvailableSkills is called', () => {
|
||||
const skillsWithPassives: HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 127, name: 'Sword Mastery', nameZh: '剑术支配', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
hud.setAvailableSkills(skillsWithPassives)
|
||||
expect(hud.availableSkills.some(s => isPassiveSkill(s.skillId))).toBe(false)
|
||||
expect(hud.availableSkills.map(s => s.skillId)).toEqual([0, 47])
|
||||
|
||||
// Speedbar popup should contain 0 passives
|
||||
const speedbarLeft = hud.getSpeedbarSkills('left')
|
||||
const speedbarRight = hud.getSpeedbarSkills('right')
|
||||
expect(speedbarLeft.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
|
||||
expect(speedbarRight.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects assigning passive skills to left or right action slots', () => {
|
||||
hud.leftSkillId = 47
|
||||
hud.rightSkillId = 64
|
||||
|
||||
// Attempt assigning Warmth (37)
|
||||
const leftAssigned = hud.assignSkill('left', 37)
|
||||
expect(leftAssigned).toBe(false)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
|
||||
const rightAssigned = hud.assignSkill('right', 61)
|
||||
expect(rightAssigned).toBe(false)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
|
||||
// Attempt setDualSkill
|
||||
const dualAssigned = hud.setDualSkill(127)
|
||||
expect(dualAssigned).toBe(false)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Aura Skills Mechanics & Yellow Styling (光环技能图标黄色)', () => {
|
||||
it('identifies all 20 Paladin Defensive and Offensive Auras', () => {
|
||||
expect(PALADIN_AURA_SKILL_IDS.size).toBe(20)
|
||||
|
||||
// Defensive Auras
|
||||
expect(isAuraSkill(99)).toBe(true) // Prayer
|
||||
expect(isAuraSkill(100)).toBe(true) // Resist Fire
|
||||
expect(isAuraSkill(104)).toBe(true) // Defiance
|
||||
expect(isAuraSkill(105)).toBe(true) // Resist Cold
|
||||
expect(isAuraSkill(109)).toBe(true) // Cleansing
|
||||
expect(isAuraSkill(110)).toBe(true) // Resist Lightning
|
||||
expect(isAuraSkill(115)).toBe(true) // Vigor
|
||||
expect(isAuraSkill(120)).toBe(true) // Meditation
|
||||
expect(isAuraSkill(124)).toBe(true) // Redemption
|
||||
expect(isAuraSkill(125)).toBe(true) // Salvation
|
||||
|
||||
// Offensive Auras
|
||||
expect(isAuraSkill(98)).toBe(true) // Might
|
||||
expect(isAuraSkill(102)).toBe(true) // Holy Fire
|
||||
expect(isAuraSkill(103)).toBe(true) // Thorns
|
||||
expect(isAuraSkill(108)).toBe(true) // Blessed Aim
|
||||
expect(isAuraSkill(113)).toBe(true) // Concentration
|
||||
expect(isAuraSkill(114)).toBe(true) // Holy Freeze
|
||||
expect(isAuraSkill(118)).toBe(true) // Holy Shock
|
||||
expect(isAuraSkill(119)).toBe(true) // Sanctuary
|
||||
expect(isAuraSkill(122)).toBe(true) // Fanaticism
|
||||
expect(isAuraSkill(123)).toBe(true) // Conviction
|
||||
|
||||
// Non-auras
|
||||
expect(isAuraSkill(96)).toBe(false) // Sacrifice (Combat)
|
||||
expect(isAuraSkill(112)).toBe(false) // Blessed Hammer (Combat)
|
||||
expect(isAuraSkill(47)).toBe(false) // Fire Ball
|
||||
})
|
||||
|
||||
it('renders yellow border and yellow tint for aura skills', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
strokeRectCalls.push({ x, y, w, h, style: String(mockCtx.strokeStyle) })
|
||||
}),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 98 // Might (Aura)
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
// Check yellow aura tint over right skill icon (x=635)
|
||||
const yellowTint = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0')
|
||||
)
|
||||
expect(yellowTint).toBeDefined()
|
||||
|
||||
// Check yellow border (#ffd700) around right skill button
|
||||
const yellowBorder = strokeRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700'
|
||||
)
|
||||
expect(yellowBorder).toBeDefined()
|
||||
})
|
||||
|
||||
it('includes aura narrative tag in tooltip', () => {
|
||||
hud.setAvailableSkills([
|
||||
{ skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true },
|
||||
])
|
||||
const lines = hud.formatTooltipLines(98, 'right', false)
|
||||
const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow')
|
||||
expect(auraTag).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => {
|
||||
it('manages skill cooldown durations accurately', () => {
|
||||
const now = 10000
|
||||
expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor
|
||||
|
||||
hud.setSkillCooldown(56, 1200, now) // 1.2s cooldown
|
||||
expect(hud.isSkillOnCooldown(56, now + 500)).toBe(true)
|
||||
expect(hud.getSkillCooldownRemaining(56, now + 500)).toBe(700)
|
||||
|
||||
// After cooldown expires
|
||||
expect(hud.isSkillOnCooldown(56, now + 1200)).toBe(false)
|
||||
expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0)
|
||||
})
|
||||
|
||||
it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.leftSkillId = 47 // Fire Ball
|
||||
hud.rightSkillId = 56 // Meteor
|
||||
const now = 20000
|
||||
|
||||
// Put Meteor on cooldown
|
||||
hud.setSkillCooldown(56, 1200, now)
|
||||
|
||||
// Draw at now + 300ms (still on CD)
|
||||
hud.draw(mockCtx, null, font, true, now + 300)
|
||||
|
||||
// Right slot (Meteor) must have red mask
|
||||
const meteorRedMask = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(meteorRedMask).toBeDefined()
|
||||
|
||||
// Left slot (Fire Ball, not on CD) must NOT have red mask
|
||||
const fireBallRedMask = fillRectCalls.find(
|
||||
c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(fireBallRedMask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears cooldown and removes red mask', () => {
|
||||
hud.setSkillCooldown(56, 2000, 1000)
|
||||
expect(hud.isSkillOnCooldown(56, 1500)).toBe(true)
|
||||
|
||||
hud.clearSkillCooldown(56)
|
||||
expect(hud.isSkillOnCooldown(56, 1500)).toBe(false)
|
||||
})
|
||||
|
||||
it('formats remaining cooldown in tooltip', () => {
|
||||
hud.setSkillCooldown(56, 1500, 5000)
|
||||
const lines = hud.formatTooltipLines(56, 'right', false)
|
||||
const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red')
|
||||
expect(cdLine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Charged Skills Number Display (充能类技能显示数字)', () => {
|
||||
it('sets and retrieves skill charges and max charges', () => {
|
||||
hud.setSkillCharges(54, 20, 25) // Teleport charges 20/25
|
||||
expect(hud.getSkillCharges(54)).toBe(20)
|
||||
expect(hud.getSkillMaxCharges(54)).toBe(25)
|
||||
|
||||
hud.setSkillCharges(54, 0, 25) // depleted
|
||||
expect(hud.getSkillCharges(54)).toBe(0)
|
||||
})
|
||||
|
||||
it('draws remaining charges count on the skill icon', () => {
|
||||
const drawTextCalls: { text: string; x: number; y: number; color?: string | undefined }[] = []
|
||||
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
|
||||
drawTextCalls.push({ text: String(text), x, y, color: opts?.color })
|
||||
return 20
|
||||
})
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 54 // Teleport
|
||||
hud.setSkillCharges(54, 18, 20)
|
||||
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
// Charge number '18' drawn at bottom-left corner of right slot (x = 635 + 4 = 639)
|
||||
const chargeTextCall = drawTextCalls.find(
|
||||
c => c.text === '18' && c.x === RIGHT_SKILL_BOUNDS.x + 4 && c.y === RIGHT_SKILL_BOUNDS.y + 44
|
||||
)
|
||||
expect(chargeTextCall).toBeDefined()
|
||||
expect(chargeTextCall!.color).toBe('white')
|
||||
})
|
||||
|
||||
it('renders red unavailable mask when charges reach 0', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 54
|
||||
hud.setSkillCharges(54, 0, 20) // 0 charges remaining
|
||||
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
const redMask = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(redMask).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats charge status in tooltip', () => {
|
||||
hud.setSkillCharges(54, 15, 20)
|
||||
const lines = hud.formatTooltipLines(54, 'right', false)
|
||||
const chargeLine = lines.find(l => l.text.includes('剩余充能: 15 / 20'))
|
||||
expect(chargeLine).toBeDefined()
|
||||
expect(chargeLine!.color).toBe('blue')
|
||||
|
||||
hud.setSkillCharges(54, 0, 20)
|
||||
const depletedLines = hud.formatTooltipLines(54, 'right', false)
|
||||
const depletedLine = depletedLines.find(l => l.text.includes('剩余充能: 0 / 20'))
|
||||
expect(depletedLine).toBeDefined()
|
||||
expect(depletedLine!.color).toBe('red')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue