feat(ui): authentic Diablo II 1.13c quickbar cd mask, aura styling, charges and passive filtering (Fixes #382)

This commit is contained in:
troytt 2026-09-22 11:35:47 +00:00
parent 775f4113c9
commit 4f6eadea6c
2 changed files with 615 additions and 11 deletions

View File

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

View File

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