swingtimer rework

This commit is contained in:
Meow
2026-03-04 19:18:01 +01:00
parent 8f6b961b4a
commit bdfc13bb08
+394 -348
View File
@@ -2,43 +2,88 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
local rawborder, border = GetBorderSize()
-- HitInfo flags (EVENTS.md)
local HITINFO_LEFTSWING = 4 -- 0x4: Off-hand attack
local HITINFO_NOACTION = 65536 -- 0x10000: server did not advance the swing clock
local HITINFO_LEFTSWING = 4 -- 4: Off-hand attack
local HITINFO_NOACTION = 65536 -- 65536: server did not advance the swing clock
-- SPELL_QUEUE_EVENT codes (EVENTS.md)
local ON_SWING_QUEUED = 0
local ON_SWING_QUEUED = 0
local ON_SWING_QUEUE_POPPED = 1
-- Swing state
local swingState = {
mainhand = { speed = 0, nextSwing = 0, swinging = false },
offhand = { speed = 0, nextSwing = 0, swinging = false },
ranged = { speed = 0, nextSwing = 0, swinging = false },
}
-- Countdown timers (seconds remaining, SP_SwingTimer style)
-- Counting down from speed -> 0. Reset to speed on each confirmed swing.
local mhTimer = 0
local mhTimerMax = 1
local ohTimer = 0
local ohTimerMax = 1
local raTimer = 0
local raTimerMax = 1
-- Ranged spell IDs that trigger the ranged swing timer (replaces MH)
-- Weapon speeds (seconds), updated via UnitAttackSpeed / UnitRangedDamage
local mhSpeed = 0
local ohSpeed = 0
local raSpeed = 0
-- Whether each bar should be shown at all
local mhActive = false
local ohActive = false
local raActive = false
-- Whether auto-attack is currently toggled on
local autoAttackActive = false
-- Tracks spellId from SPELL_START_SELF (cast-time spell in progress)
-- Used in SPELL_GO to detect cast-time resets vs instants
local pendingCastSpellId = nil
-- Ranged spell IDs
local RANGED_SPELLIDS = {
[75] = true, -- Auto Shot (Hunter)
[2764] = true, -- Throw (Warrior/Rogue)
}
-- Create container frame
pfUI.swingtimer = CreateFrame("Frame", "pfSwingTimer", UIParent)
pfUI.swingtimer:SetFrameStrata("MEDIUM")
pfUI.swingtimer:Hide()
-- Slam: has cast time but does NOT reset the swing timer (just delays it).
-- We explicitly ignore these in SPELL_GO so they fall through to no-op.
local slamSpellIDs = {
[1464] = true, [8820] = true, [11604] = true, [11605] = true,
}
-- Read config once at load into locals
-- SPELL_ATTR_ON_NEXT_SWING (bit 2, value 4): spell replaces next auto-attack swing.
-- Covers Raptor Strike, Maul, Mongoose Bite, Holy Strike, etc. automatically.
-- Cache results to avoid repeated GetSpellRec calls per SPELL_GO.
local ATTR_ON_NEXT_SWING = 4
local onSwingCache = {} -- [spellId] = true/false
local function IsOnSwingSpell(spellId)
if onSwingCache[spellId] ~= nil then return onSwingCache[spellId] end
local rec = GetSpellRec(spellId)
local result = rec and bit.band(rec.attributes, ATTR_ON_NEXT_SWING) ~= 0 or false
onSwingCache[spellId] = result
return result
end
-- Heroic Strike spell IDs (all ranks)
local hsSpellIDs = {
[78] = true, [284] = true, [285] = true, [1608] = true,
[11564] = true, [11565] = true, [11566] = true, [11567] = true,
}
-- Cleave spell IDs (all ranks)
local cleaveSpellIDs = {
[845] = true, [7369] = true, [11608] = true, [11609] = true,
[20569] = true,
}
-- Read config
local sw_width = tonumber(C.unitframes.swingtimerwidth) or 200
local sw_height = tonumber(C.unitframes.swingtimerheight) or 12
local sw_texture = C.unitframes.swingtimertexture or "Interface\\AddOns\\pfUI\\img\\bar"
local sw_showtext = C.unitframes.swingtimertext ~= "0"
local sw_showlabel = C.unitframes.swingtimerlabel ~= "0"
local sw_showoh = C.unitframes.swingtimeroffhand ~= "0"
local sw_showoh = C.unitframes.swingtimeroffhand ~= "0"
local sw_showranged = C.unitframes.swingtimerranged ~= "0"
local sw_fontsize = tonumber(C.unitframes.swingtimerfontsize) or 12
local sw_fontsize = tonumber(C.unitframes.swingtimerfontsize) or 12
local sw_hsqueue = C.unitframes.swingtimerhsqueue ~= "0"
local sw_showspeed = C.unitframes.swingtimerattackspeed == "1"
-- Parse color strings "r,g,b,a" into components
local function ParseColor(str, dr, dg, db, da)
if not str or str == "" then return dr, dg, db, da end
local _, _, r, g, b, a = string.find(str, "([%d%.]+),([%d%.]+),([%d%.]+),([%d%.]+)")
@@ -53,10 +98,21 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
local raR, raG, raB, raA = ParseColor(C.unitframes.swingtimerrangedcolor, 0.3, 0.6, 1.0, 1)
local rwR, rwG, rwB, rwA = ParseColor(C.unitframes.swingtimerrangedwarncolor, 0.9, 0.0, 0.0, 1)
local isHunter = UnitClass("player") == "Hunter"
-- Store default MH color for HS/Cleave restore
local mhDefaultR, mhDefaultG, mhDefaultB = mhR, mhG, mhB
-- HS/Cleave queue state
local hsQueued = false
local cleaveQueued = false
local isWarrior = false
local cachedHSSlots = {}
local cachedCleaveSlots = {}
local useSpellQueueEvent = false
-- Create container frame
pfUI.swingtimer = CreateFrame("Frame", "pfSwingTimer", UIParent)
pfUI.swingtimer:SetFrameStrata("MEDIUM")
pfUI.swingtimer:Hide()
-- Mainhand bar
pfUI.swingtimer.mainhand = CreateFrame("StatusBar", "pfSwingTimerMainhand", UIParent)
pfUI.swingtimer.mainhand:SetPoint("CENTER", UIParent, "CENTER", 0, -100)
@@ -81,6 +137,13 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
pfUI.swingtimer.mainhand.label:SetTextColor(0.8, 0.8, 0.8, 1)
pfUI.swingtimer.mainhand.label:SetText(sw_showlabel and "MH" or "")
pfUI.swingtimer.mainhand.speed = pfUI.swingtimer.mainhand:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.mainhand.speed:SetPoint("LEFT", pfUI.swingtimer.mainhand, "RIGHT", 4, 0)
pfUI.swingtimer.mainhand.speed:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.mainhand.speed:SetTextColor(0.8, 0.8, 0.8, 1)
pfUI.swingtimer.mainhand.speed:SetText("")
if not sw_showspeed then pfUI.swingtimer.mainhand.speed:Hide() end
CreateBackdrop(pfUI.swingtimer.mainhand)
CreateBackdropShadow(pfUI.swingtimer.mainhand)
@@ -111,16 +174,13 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
CreateBackdrop(pfUI.swingtimer.offhand)
CreateBackdropShadow(pfUI.swingtimer.offhand)
-- Ranged bar (bow/gun/crossbow - triggered by SPELL_GO_SELF for Auto Shot / Throw)
-- Hunter uses a special "close from outside->in, open inside->out" animation
-- instead of a normal left->right StatusBar fill.
-- Ranged bar
pfUI.swingtimer.ranged = CreateFrame("Frame", "pfSwingTimerRanged", UIParent)
pfUI.swingtimer.ranged:SetPoint("CENTER", UIParent, "CENTER", 0, -120)
pfUI.swingtimer.ranged:SetWidth(sw_width)
pfUI.swingtimer.ranged:SetHeight(sw_height)
pfUI.swingtimer.ranged:Hide()
-- Phase 1: left half, anchored to CENTER (right edge fixed), shrinks leftward = outside->in
pfUI.swingtimer.ranged.left = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.left:SetTexture(sw_texture)
pfUI.swingtimer.ranged.left:SetPoint("RIGHT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
@@ -129,7 +189,6 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1)
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
-- Phase 1: right half, anchored to CENTER (left edge fixed), shrinks rightward = outside->in
pfUI.swingtimer.ranged.right = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.right:SetTexture(sw_texture)
pfUI.swingtimer.ranged.right:SetPoint("LEFT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
@@ -138,7 +197,6 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
-- Phase 2: warning color, anchored CENTER, grows outward
pfUI.swingtimer.ranged.warn = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.warn:SetTexture(sw_texture)
pfUI.swingtimer.ranged.warn:SetPoint("CENTER", pfUI.swingtimer.ranged, "CENTER", 0, 0)
@@ -163,41 +221,112 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
CreateBackdrop(pfUI.swingtimer.ranged)
CreateBackdropShadow(pfUI.swingtimer.ranged)
-- Slam spell IDs (all ranks) - resets swing timer from now, not chained
local slamSpellIDs = {
[1464] = true, [8820] = true, [11604] = true, [11605] = true,
}
UpdateMovable(pfUI.swingtimer.mainhand)
UpdateMovable(pfUI.swingtimer.ranged)
-- HS/Cleave queue state
local hsQueued = false
local cleaveQueued = false
local isWarrior = false
local cachedHSSlots = {}
local cachedCleaveSlots = {}
local useSpellQueueEvent = false
-- OH weapon detection
local OH_WEAPON_TYPES = { [13]=true, [21]=true }
local function HasOffhandWeapon()
local l = GetInventoryItemLink("player", 17)
if not l then return false end
local _, _, id = string.find(l, "item:(%d+)")
id = tonumber(id)
if not id then return false end
local s = GetItemStats and GetItemStats(id)
if not s then return false end
return OH_WEAPON_TYPES[s.inventoryType] == true
end
-- Heroic Strike spell IDs (all ranks)
local hsSpellIDs = {
[78] = true, [284] = true, [285] = true, [1608] = true,
[11564] = true, [11565] = true, [11566] = true, [11567] = true,
[25286] = true,
}
-- Cleave spell IDs (all ranks)
local cleaveSpellIDs = {
[845] = true, [7369] = true, [11608] = true, [11609] = true,
[20569] = true,
}
local function UpdateWeaponSpeeds()
local ms, os = UnitAttackSpeed("player")
mhSpeed = (ms and ms > 0) and ms or mhSpeed
ohSpeed = (HasOffhandWeapon() and os and os > 0) and os or 0
local rs = UnitRangedDamage("player")
raSpeed = (rs and rs > 0) and rs or 0
end
-- Reset MH countdown to full speed (server confirmed swing)
local function ResetMH()
UpdateWeaponSpeeds()
pfUI.swingtimer.mhGraceAt = nil -- cancel any pending hide
mhTimerMax = mhSpeed
mhTimer = mhSpeed
mhActive = true
pfUI.swingtimer.mainhand:Show()
pfUI.swingtimer:Show()
end
-- Reset OH countdown to full speed
local function ResetOH()
UpdateWeaponSpeeds()
if ohSpeed <= 0 then return end
pfUI.swingtimer.ohGraceAt = nil -- cancel any pending hide
ohTimerMax = ohSpeed
ohTimer = ohSpeed
ohActive = true
if sw_showoh then pfUI.swingtimer.offhand:Show() end
pfUI.swingtimer:Show()
end
-- Reset ranged countdown
local function ResetRanged()
if not sw_showranged then return end
UpdateWeaponSpeeds()
if raSpeed <= 0 then return end
-- Ranged replaces MH bar
mhActive = false
pfUI.swingtimer.mainhand:Hide()
raTimerMax = raSpeed
raTimer = raSpeed
raActive = true
if isHunter then
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("RIGHT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1)
else
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("TOPLEFT", pfUI.swingtimer.ranged, "TOPLEFT", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(0.1)
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(0.1)
pfUI.swingtimer.ranged.right:Hide()
end
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:SetWidth(1)
pfUI.swingtimer.ranged.warn:Hide()
pfUI.swingtimer.ranged:Show()
pfUI.swingtimer:Show()
end
local function ResetAll()
mhActive = false
ohActive = false
raActive = false
mhTimer = 0
ohTimer = 0
raTimer = 0
pfUI.swingtimer.mhGraceAt = nil
pfUI.swingtimer.ohGraceAt = nil
pfUI.swingtimer.mainhand:Hide()
pfUI.swingtimer.offhand:Hide()
pfUI.swingtimer.ranged:Hide()
pfUI.swingtimer:Hide()
end
-- HS/Cleave helpers
local function RebuildQueueSlotCache()
if not isWarrior or not sw_hsqueue or useSpellQueueEvent then return end
cachedHSSlots = {}
cachedCleaveSlots = {}
for slot = 1, 120 do
local tex = GetActionTexture(slot)
local name = GetActionText(slot)
if tex then
if string.find(tex, "Ability_Rogue_Ambush") then
table.insert(cachedHSSlots, slot)
@@ -205,7 +334,6 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
table.insert(cachedCleaveSlots, slot)
end
end
if name then
local lower = string.lower(name)
if lower == "heroic strike" or lower == "heroicstrike" or lower == "hs" then
@@ -226,157 +354,70 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
local function IsHSOrCleaveQueued()
if not sw_hsqueue or not isWarrior then return false, false end
if useSpellQueueEvent then
return hsQueued, cleaveQueued
end
if useSpellQueueEvent then return hsQueued, cleaveQueued end
return CheckQueuedAction(cachedHSSlots), CheckQueuedAction(cachedCleaveSlots)
end
UpdateMovable(pfUI.swingtimer.mainhand)
UpdateMovable(pfUI.swingtimer.ranged)
-- VERSION B TEST: HasOffhandWeapon() removed.
-- The original used GetItemInfo() to detect OH weapon type, but GetItemInfo()
-- returns nil on first login before the item cache is populated, causing
-- offhand.speed to stay 0. Now we just read offhandAttackTime directly,
-- same as the old working version.
-- Check offhand slot for an actual weapon. GetItemInfo may return nil on first
-- login (item cache not yet populated), so we return nil in that case to signal
-- "unknown" rather than false, allowing the caller to keep the previous value.
-- inventoryType 13 = INVTYPE_WEAPONOFFHAND, 21 = INVTYPE_WEAPON (one-hand, dual wieldable)
-- Shields = 14, held-in-hand = 23, everything else = no swing
local OH_WEAPON_TYPES = { [13]=true, [21]=true }
local function HasOffhandWeapon()
local l = GetInventoryItemLink("player", 17)
if not l then return false end
local _, _, id = string.find(l, "item:(%d+)")
id = tonumber(id)
if not id then return false end
local s = GetItemStats and GetItemStats(id)
if not s then return false end
return OH_WEAPON_TYPES[s.inventoryType] == true
end
local function UpdateWeaponSpeeds()
if not GetUnitField then return end
local mhSpeed = GetUnitField("player", "baseAttackTime")
local ohSpeed = GetUnitField("player", "offhandAttackTime")
if mhSpeed and mhSpeed > 0 then
swingState.mainhand.speed = mhSpeed / 1000
end
if HasOffhandWeapon() and ohSpeed and ohSpeed > 0 then
swingState.offhand.speed = ohSpeed / 1000
else
swingState.offhand.speed = 0
end
local raSpeed = GetUnitField("player", "rangedAttackTime")
if raSpeed and raSpeed > 0 then
swingState.ranged.speed = raSpeed / 1000
else
swingState.ranged.speed = 0
end
end
local function StartSwing(isOffhand, resetFromNow)
local now = GetTime()
-- always refresh speeds to catch haste buffs/debuffs
UpdateWeaponSpeeds()
-- dual-wield guard: if MH swing just started (<100ms ago) and this isn't
-- flagged as offhand, it's likely an OH event with missing flag
if not isOffhand and swingState.offhand.speed > 0 then
local mhAge = now - (swingState.mainhand.nextSwing - swingState.mainhand.speed)
if swingState.mainhand.swinging and mhAge > 0 and mhAge < 0.1 then
isOffhand = true
end
end
if isOffhand and swingState.offhand.speed > 0 then
-- resetFromNow: hard reset (e.g. Slam) - always start from now
-- otherwise: chain from previous nextSwing to avoid timer jumps
local base = (not resetFromNow and swingState.offhand.swinging and swingState.offhand.nextSwing > (now - 0.5)) and swingState.offhand.nextSwing or now
swingState.offhand.nextSwing = base + swingState.offhand.speed
swingState.offhand.swinging = true
if sw_showoh then pfUI.swingtimer.offhand:Show() end
else
-- resetFromNow: hard reset (e.g. Slam) - always start from now
-- otherwise: chain from previous nextSwing to avoid timer jumps on HS/normal swing
local base = (not resetFromNow and swingState.mainhand.swinging and swingState.mainhand.nextSwing > (now - 0.5)) and swingState.mainhand.nextSwing or now
swingState.mainhand.nextSwing = base + swingState.mainhand.speed
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
end
pfUI.swingtimer:Show()
end
local function StartRangedSwing()
if not sw_showranged then return end
UpdateWeaponSpeeds()
if swingState.ranged.speed <= 0 then return end
-- Ranged replaces MH: cancel mainhand swing
swingState.mainhand.swinging = false
pfUI.swingtimer.mainhand:Hide()
swingState.ranged.nextSwing = GetTime() + swingState.ranged.speed
swingState.ranged.swinging = true
if isHunter then
-- Hunter: left/right halves anchored to CENTER, shrink outside->in
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("RIGHT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1)
else
-- Non-Hunter: left anchored to TOPLEFT, grows left->right like MH/OH
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("TOPLEFT", pfUI.swingtimer.ranged, "TOPLEFT", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(0.1)
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(0.1)
pfUI.swingtimer.ranged.right:Hide()
end
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:SetWidth(1)
pfUI.swingtimer.ranged.warn:Hide()
pfUI.swingtimer.ranged:Show()
pfUI.swingtimer:Show()
end
local function ResetSwingTimers()
swingState.mainhand.swinging = false
swingState.offhand.swinging = false
swingState.ranged.swinging = false
pfUI.swingtimer.mainhand:Hide()
pfUI.swingtimer.offhand:Hide()
pfUI.swingtimer.ranged:Hide()
pfUI.swingtimer:Hide()
end
-- OnUpdate: countdown all timers with delta, then render
local swingThrottle = 0
pfUI.swingtimer:SetScript("OnUpdate", function()
swingThrottle = swingThrottle + arg1
if swingThrottle < 0.016 then return end
local delta = swingThrottle
swingThrottle = 0
local now = GetTime()
local anyActive = false
-- Execute delayed regen reset (only if combat hasn't resumed)
if swingState.regenResetPending and now >= swingState.regenResetPending then
swingState.regenResetPending = nil
ResetSwingTimers()
-- Delayed out-of-combat reset
if pfUI.swingtimer.regenResetAt and GetTime() >= pfUI.swingtimer.regenResetAt then
pfUI.swingtimer.regenResetAt = nil
ResetAll()
return
end
local anyActive = false
-- Tick timers down. When a timer expires we give a short grace period before
-- hiding the bar, to bridge the 1-2 frame gap until AUTO_ATTACK_SELF arrives.
-- If AUTO_ATTACK_SELF arrives during the grace period it resets the timer
-- normally and the grace timer is cleared. If not (e.g. auto-attack was
-- turned off), the bar hides after the grace period ends.
local GRACE = 0.15 -- seconds to wait after timer hits 0 before hiding
if mhActive then
mhTimer = mhTimer - delta
if mhTimer <= 0 then
mhTimer = 0
if not pfUI.swingtimer.mhGraceAt then
pfUI.swingtimer.mhGraceAt = GetTime() + GRACE
elseif GetTime() >= pfUI.swingtimer.mhGraceAt then
pfUI.swingtimer.mhGraceAt = nil
mhActive = false
pfUI.swingtimer.mainhand:Hide()
end
end
end
if ohActive then
ohTimer = ohTimer - delta
if ohTimer <= 0 then
ohTimer = 0
if not pfUI.swingtimer.ohGraceAt then
pfUI.swingtimer.ohGraceAt = GetTime() + GRACE
elseif GetTime() >= pfUI.swingtimer.ohGraceAt then
pfUI.swingtimer.ohGraceAt = nil
ohActive = false
pfUI.swingtimer.offhand:Hide()
end
end
end
if raActive then
raTimer = raTimer - delta
if raTimer <= 0 then
raTimer = 0
raActive = false
pfUI.swingtimer.ranged:Hide()
end
end
-- HS/Cleave color
local curR, curG, curB = mhDefaultR, mhDefaultG, mhDefaultB
if sw_hsqueue and isWarrior then
local hs, cl = IsHSOrCleaveQueued()
@@ -387,104 +428,83 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
end
end
if swingState.mainhand.swinging then
local remaining = swingState.mainhand.nextSwing - now
if remaining <= 0 then
swingState.mainhand.swinging = false
pfUI.swingtimer.mainhand:Hide()
else
local progress = 1 - (remaining / swingState.mainhand.speed)
pfUI.swingtimer.mainhand:SetValue(progress)
pfUI.swingtimer.mainhand:SetStatusBarColor(curR, curG, curB, mhA)
if sw_showtext then
pfUI.swingtimer.mainhand.text:SetText(string.format("%.1f", remaining))
end
anyActive = true
-- Render MH
if mhActive then
local progress = 1 - (mhTimer / mhTimerMax)
pfUI.swingtimer.mainhand:SetValue(progress)
pfUI.swingtimer.mainhand:SetStatusBarColor(curR, curG, curB, mhA)
if sw_showtext then
pfUI.swingtimer.mainhand.text:SetText(string.format("%.1f", math.floor(mhTimer * 10) / 10))
end
if sw_showspeed and mhSpeed > 0 then
pfUI.swingtimer.mainhand.speed:SetText(string.format("%.2f", mhSpeed))
end
anyActive = true
end
if sw_showoh and swingState.offhand.swinging then
local remaining = swingState.offhand.nextSwing - now
if remaining <= 0 then
swingState.offhand.swinging = false
pfUI.swingtimer.offhand:Hide()
else
local progress = 1 - (remaining / swingState.offhand.speed)
pfUI.swingtimer.offhand:SetValue(progress)
if sw_showtext then
pfUI.swingtimer.offhand.text:SetText(string.format("%.1f", remaining))
end
anyActive = true
-- Render OH
if sw_showoh and ohActive then
local progress = 1 - (ohTimer / ohTimerMax)
pfUI.swingtimer.offhand:SetValue(progress)
if sw_showtext then
pfUI.swingtimer.offhand.text:SetText(string.format("%.1f", math.floor(ohTimer * 10) / 10))
end
anyActive = true
elseif not sw_showoh then
pfUI.swingtimer.offhand:Hide()
end
if sw_showranged and swingState.ranged.swinging then
local remaining = swingState.ranged.nextSwing - now
if remaining <= 0 then
swingState.ranged.swinging = false
pfUI.swingtimer.ranged:Hide()
else
if isHunter then
-- Hunter ranged animation: two phases
-- Phase 1 (speed-0.5s): bar visible full, shrinks from outside->in toward center (normal color)
-- Phase 2 (0.5s): bar grows from center outward (warning color)
local DEADZONE = 0.5
local halfW = sw_width / 2
if remaining > DEADZONE then
-- Phase 1: left/right halves shrink from outside->in toward center
local elapsed = swingState.ranged.speed - remaining
local phase1dur = swingState.ranged.speed - DEADZONE
local p = elapsed / phase1dur -- 0 = full, 1 = gone
local w = halfW * (1 - p)
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Show()
pfUI.swingtimer.ranged.left:SetWidth(w)
pfUI.swingtimer.ranged.left:SetTexCoord(0, (1 - p) * 0.5, 0, 1)
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:Show()
pfUI.swingtimer.ranged.right:SetWidth(w)
pfUI.swingtimer.ranged.right:SetTexCoord(1 - (1 - p) * 0.5, 1, 0, 1)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:Hide()
else
-- Phase 2: warning color grows from center->outside
local p = 1 - (remaining / DEADZONE) -- 0 = nothing, 1 = full
local w = sw_width * p
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.warn:SetWidth(w)
pfUI.swingtimer.ranged.warn:Show()
end
else
-- Non-Hunter (Warrior Throw, Rogue): simple left->right fill like MH/OH
local progress = 1 - (remaining / swingState.ranged.speed)
local w = sw_width * progress
-- Render Ranged
if sw_showranged and raActive then
local remaining = raTimer
if isHunter then
local DEADZONE = 0.5
local halfW = sw_width / 2
if remaining > DEADZONE then
local elapsed = raTimerMax - remaining
local phase1dur = raTimerMax - DEADZONE
local p = elapsed / phase1dur
local w = halfW * (1 - p)
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Show()
pfUI.swingtimer.ranged.left:SetWidth(w)
pfUI.swingtimer.ranged.left:SetTexCoord(0, progress, 0, 1)
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.left:SetTexCoord(0, (1 - p) * 0.5, 0, 1)
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:Show()
pfUI.swingtimer.ranged.right:SetWidth(w)
pfUI.swingtimer.ranged.right:SetTexCoord(1 - (1 - p) * 0.5, 1, 0, 1)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:Hide()
else
local p = 1 - (remaining / DEADZONE)
local w = sw_width * p
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.warn:SetWidth(w)
pfUI.swingtimer.ranged.warn:Show()
end
if sw_showtext then
if isHunter and remaining <= 0.5 then
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining))
elseif isHunter then
-- Show time until deadzone starts, not full remaining
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining - 0.5))
if remaining <= 0.5 then
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", math.floor(remaining * 10) / 10))
else
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining))
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", math.floor((remaining - 0.5) * 10) / 10))
end
end
anyActive = true
else
local progress = 1 - (remaining / raTimerMax)
local w = sw_width * progress
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Show()
pfUI.swingtimer.ranged.left:SetWidth(w)
pfUI.swingtimer.ranged.left:SetTexCoord(0, progress, 0, 1)
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.warn:Hide()
if sw_showtext then
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", math.floor(remaining * 10) / 10))
end
end
anyActive = true
elseif not sw_showranged then
pfUI.swingtimer.ranged:Hide()
end
@@ -498,36 +518,40 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
end
end)
local events = CreateFrame("Frame")
events:RegisterEvent("AUTO_ATTACK_SELF")
events:RegisterEvent("AUTO_ATTACK_OTHER")
events:RegisterEvent("PLAYER_ENTERING_WORLD")
events:RegisterEvent("UNIT_INVENTORY_CHANGED")
events:RegisterEvent("PLAYER_REGEN_DISABLED")
events:RegisterEvent("PLAYER_REGEN_ENABLED")
events:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
events:RegisterEvent("UNIT_DIED")
events:RegisterEvent("SPELL_QUEUE_EVENT")
-- SPELL_START_SELF: fires only for cast-time spells, never instants
local spellStartFrame = CreateFrame("Frame")
spellStartFrame:RegisterEvent("SPELL_START_SELF")
spellStartFrame:SetScript("OnEvent", function()
pendingCastSpellId = arg1
end)
-- Use libdebuff SPELL_GO_SELF hook instead of registering the event separately
-- SPELL_GO hook via libdebuff
pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {}
pfUI.libdebuff_spell_go_hooks["swingtimer"] = function(spellId)
if RANGED_SPELLIDS[spellId] then
StartRangedSwing()
ResetRanged()
elseif slamSpellIDs[spellId] then
-- Slam resets the swing timer hard from now (not chained from previous nextSwing)
-- Slam delays auto-attack but does NOT reset the swing timer. Ignore.
pendingCastSpellId = nil
return
elseif hsSpellIDs[spellId] or IsOnSwingSpell(spellId) then
hsQueued = false; cleaveQueued = false
StartSwing(false, true)
elseif hsSpellIDs[spellId] then
hsQueued = false; cleaveQueued = false
StartSwing(false)
ResetMH()
elseif cleaveSpellIDs[spellId] then
hsQueued = false; cleaveQueued = false
StartSwing(false)
ResetMH()
else
-- Only reset for cast-time spells (signaled by SPELL_START_SELF)
if mhActive and mhSpeed > 0 and pendingCastSpellId == spellId then
UpdateWeaponSpeeds()
mhTimerMax = mhSpeed
mhTimer = mhSpeed
end
end
pendingCastSpellId = nil
end
-- Use libdebuff SPELL_CAST_EVENT hook instead of registering the event separately
-- SPELL_CAST_EVENT hook: HS/Cleave queue tracking
pfUI.libdebuff_spell_cast_hooks = pfUI.libdebuff_spell_cast_hooks or {}
pfUI.libdebuff_spell_cast_hooks["swingtimer"] = function(success, spellId)
if success ~= 1 then return end
@@ -540,60 +564,84 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
local playerGUID = nil
local events = CreateFrame("Frame")
events:RegisterEvent("AUTO_ATTACK_SELF")
events:RegisterEvent("AUTO_ATTACK_OTHER")
events:RegisterEvent("PLAYER_ENTERING_WORLD")
events:RegisterEvent("UNIT_INVENTORY_CHANGED")
events:RegisterEvent("PLAYER_REGEN_DISABLED")
events:RegisterEvent("PLAYER_REGEN_ENABLED")
events:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
events:RegisterEvent("UNIT_DIED")
events:RegisterEvent("SPELL_QUEUE_EVENT")
events:RegisterEvent("START_AUTOATTACK")
events:RegisterEvent("STOP_AUTOATTACK")
events:SetScript("OnEvent", function()
if event == "AUTO_ATTACK_SELF" then
local hitInfo = arg4 or 0
local hitInfo = arg4 or 0
local isOffhand = bit.band(hitInfo, HITINFO_LEFTSWING) ~= 0
local noAction = bit.band(hitInfo, HITINFO_NOACTION) ~= 0
-- HITINFO_NOACTION: server did not advance the swing clock (extra attack), ignore
local noAction = bit.band(hitInfo, HITINFO_NOACTION) ~= 0
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[AA] hi=%d OH=%s noAct=%s|r", hitInfo, tostring(isOffhand), tostring(noAction)))
if noAction then return end
-- Fallback extra attack detection: some servers (e.g. Turtle WoW) do not set
-- HITINFO_NOACTION for extra attacks (Sword Specialization, Windfury, Hand of Justice).
-- An extra attack fires a second AUTO_ATTACK_SELF while the current swing timer is
-- still running with significant time remaining. In that case, the swing clock was
-- NOT reset by the server, so we must not reset our timer either.
local now = GetTime()
if not isOffhand and swingState.mainhand.swinging then
local remaining = swingState.mainhand.nextSwing - now
-- If more than 20% of swing speed remains, this is an extra attack, not a real swing
if remaining > swingState.mainhand.speed * 0.2 then return end
-- Extra attack detection: if timer still has >20% remaining for that hand,
-- the server did NOT reset the swing clock -> this is an extra attack, skip.
-- Use 20% here (SP_SwingTimer's ShouldResetTimer threshold).
-- Exception: if timer is already at 0 (expired), always accept.
if isOffhand then
local pct = ohActive and (ohTimer / ohTimerMax) or 0
if ohActive and ohTimer > 0 and pct > 0.20 then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff4444[AA] OH extra-attack skip pct=%.0f%% t=%.3f|r", pct*100, ohTimer))
return
end
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff88[AA] OH reset (was pct=%.0f%% t=%.3f)|r", pct*100, ohTimer))
ResetOH()
else
local pct = mhActive and (mhTimer / mhTimerMax) or 0
if mhActive and mhTimer > 0 and pct > 0.20 then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff4444[AA] MH extra-attack skip pct=%.0f%% t=%.3f|r", pct*100, mhTimer))
return
end
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff88[AA] MH reset (was pct=%.0f%% t=%.3f)|r", pct*100, mhTimer))
ResetMH()
end
if isOffhand and swingState.offhand.swinging then
local remaining = swingState.offhand.nextSwing - now
if remaining > swingState.offhand.speed * 0.2 then return end
end
StartSwing(isOffhand)
elseif event == "AUTO_ATTACK_OTHER" then
-- arg1=attackerGuid, arg2=targetGuid, arg3=totalDamage, arg4=hitInfo, arg5=victimState
-- We want: someone attacked the player (targetGuid == playerGUID) and the player parried
-- Parry haste: enemy attacked the player and player parried
local targetGuid = arg2
if not targetGuid or not playerGUID then return end
if targetGuid ~= playerGUID then return end
local victimState = arg5 or 0
-- VICTIMSTATE_PARRY = 3
-- Vanilla parry mechanic: resets swing timer to 60% of weapon speed,
-- but only if the remaining time is more than 40% of weapon speed (otherwise no change)
-- Vanilla: parry reduces the NEXT swing timer by 40% of weapon speed,
-- minimum 20% of weapon speed remaining (SP_SwingTimer approach)
if victimState == 3 then
local now = GetTime()
local speed = swingState.mainhand.speed
local remaining = swingState.mainhand.nextSwing - now
local parryReset = speed * 0.6
local threshold = speed * 0.4
-- Only apply reset if it would actually reduce the remaining time
if remaining > parryReset then
swingState.mainhand.nextSwing = now + parryReset
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
pfUI.swingtimer:Show()
elseif remaining <= 0 and threshold > 0 then
-- swing already fired but parry came in: reset to parryReset
swingState.mainhand.nextSwing = now + parryReset
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
pfUI.swingtimer:Show()
-- Apply to whichever swing comes next (smallest % remaining = closest to firing)
if ohActive and ohSpeed > 0 and (ohTimer / ohTimerMax) < (mhTimer / mhTimerMax) then
local minimum = ohSpeed * 0.20
if ohTimer > minimum then
local reduct = ohSpeed * 0.40
local before = ohTimer
ohTimer = ohTimer - reduct
if ohTimer < minimum then ohTimer = minimum end
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff9900[PARRY] OH %.3fs->%.3f min=%.3f spd=%.3f pct=%.0f%%|r", before, ohTimer, minimum, ohSpeed, (ohTimer/ohTimerMax)*100))
else
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffaaaaaa[PARRY] OH no-op already<=min %.3f|r", ohTimer))
end
elseif mhActive and mhSpeed > 0 then
local minimum = mhSpeed * 0.20
if mhTimer > minimum then
local reduct = mhSpeed * 0.40
local before = mhTimer
mhTimer = mhTimer - reduct
if mhTimer < minimum then mhTimer = minimum end
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff9900[PARRY] MH %.3f->%.3f min=%.3f spd=%.3f pct=%.0f%%|r", before, mhTimer, minimum, mhSpeed, (mhTimer/mhTimerMax)*100))
else
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffaaaaaa[PARRY] MH no-op already<=min %.3f|r", mhTimer))
end
else
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[PARRY] skip mhA=%s ohA=%s mhT=%.3f ohT=%.3f|r", tostring(mhActive), tostring(ohActive), mhTimer, ohTimer))
end
end
@@ -606,30 +654,33 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
hsQueued = true; cleaveQueued = false
elseif cleaveSpellIDs[spellId] then
cleaveQueued = true; hsQueued = false
else
end
elseif eventCode == ON_SWING_QUEUE_POPPED then
hsQueued = false; cleaveQueued = false
else
end
elseif event == "START_AUTOATTACK" then
autoAttackActive = true
elseif event == "STOP_AUTOATTACK" then
autoAttackActive = false
elseif event == "PLAYER_ENTERING_WORLD" then
local _, class = UnitClass("player")
isWarrior = (class == "WARRIOR")
local guid = GetUnitGUID("player")
playerGUID = guid
playerGUID = GetUnitGUID("player")
UpdateWeaponSpeeds()
RebuildQueueSlotCache()
elseif event == "UNIT_INVENTORY_CHANGED" then
if arg1 and arg1 ~= "player" then return end
UpdateWeaponSpeeds()
if swingState.offhand.speed == 0 then
swingState.offhand.swinging = false
if ohSpeed == 0 then
ohActive = false
pfUI.swingtimer.offhand:Hide()
end
if swingState.ranged.speed == 0 then
swingState.ranged.swinging = false
if raSpeed == 0 then
raActive = false
pfUI.swingtimer.ranged:Hide()
end
@@ -637,22 +688,17 @@ pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
RebuildQueueSlotCache()
elseif event == "PLAYER_REGEN_DISABLED" then
swingState.regenResetPending = nil -- cancel delayed reset, combat resumed
pfUI.swingtimer.regenResetAt = nil
UpdateWeaponSpeeds()
elseif event == "PLAYER_REGEN_ENABLED" then
-- Delay reset to avoid false resets between mobs in raids
-- If combat resumes within 2s (PLAYER_REGEN_DISABLED), cancel the reset
swingState.regenResetPending = GetTime() + 2.0
pfUI.swingtimer.regenResetAt = GetTime() + 5.0
hsQueued = false
cleaveQueued = false
elseif event == "UNIT_DIED" then
-- Only reset if the player themselves died
local guid = arg1
if not guid then return end
if guid == playerGUID then
ResetSwingTimers()
if arg1 and arg1 == playerGUID then
ResetAll()
end
end
end)