diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 8a6a9dd..c8bef9e 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -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 = new Set([ + // 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 = new Set([ + // 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() + private cooldowns = new Map() + private skillCharges = new Map() + + 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 diff --git a/tests/hotkeys-parity.test.ts b/tests/hotkeys-parity.test.ts new file mode 100644 index 0000000..af6bfd0 --- /dev/null +++ b/tests/hotkeys-parity.test.ts @@ -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') + }) + }) +})