pfUI:RegisterNewModule("swingtimer", "Swing Timer") pfUI:RegisterModule("swingtimer", "vanilla:tbc", function () local rawborder, border = GetBorderSize() -- HitInfo flags (EVENTS.md) 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_QUEUE_POPPED = 1 -- Consolidate state into a table to avoid Lua 5.0 upvalue limit (32 max) local S = { mhTimer = 0, mhTimerMax = 1, ohTimer = 0, ohTimerMax = 1, raTimer = 0, raTimerMax = 1, mhSpeed = 0, ohSpeed = 0, raSpeed = 0, mhActive = false, ohActive = false, raActive = false, lastMhMarkerX = -1, lastOhMarkerX = -1, lastRaMarkerX = -1, autoAttackActive = false, inCombat = false, pendingCastSpellId = nil, mhFrozenAt = nil, hsQueued = false, cleaveQueued = false, maulQueued = false, isWarrior = false, isDruid = false, cachedHSSlots = {}, cachedCleaveSlots = {}, useSpellQueueEvent = false, playerGUID = nil, swingThrottle = 0, onSwingCache = {}, } -- Ranged spell IDs local RANGED_SPELLIDS = { [75] = true, -- Auto Shot (Hunter) [2764] = true, -- Throw (Warrior/Rogue) } -- Spells that DELAY the swing timer by their cast duration but do NOT reset it. -- Slam: vanilla behavior on Turtle WoW - delays swing, does not reset. -- Hammer of Wrath: Turtle WoW changed behavior - does not reset swing timer. local swingDelaySpells = { [1464] = true, [8820] = true, [11604] = true, [11605] = true, -- Slam R1-R4 [24275] = true, [24274] = true, [24239] = true, -- Hammer of Wrath R1-R3 } -- 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. local ATTR_ON_NEXT_SWING = 4 local function IsOnSwingSpell(spellId) if S.onSwingCache[spellId] ~= nil then return S.onSwingCache[spellId] end local rec = GetSpellRec(spellId) local result = rec and bit.band(rec.attributes, ATTR_ON_NEXT_SWING) ~= 0 or false S.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, } -- Maul spell IDs (all ranks 1 to 7) local maulSpellIDs = { [6807] = true, [6808] = true, [6809] = true, [8972] = true, [9745] = true, [9880] = true, [9881] = 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_showranged = C.unitframes.swingtimerranged ~= "0" local sw_fontsize = tonumber(C.unitframes.swingtimerfontsize) or 12 local sw_hsqueue = C.unitframes.swingtimerhsqueue ~= "0" local sw_showspeed = C.unitframes.swingtimerattackspeed == "1" 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%.]+)") if r then return tonumber(r) or dr, tonumber(g) or dg, tonumber(b) or db, tonumber(a) or da end return dr, dg, db, da end local mhR, mhG, mhB, mhA = ParseColor(C.unitframes.swingtimermhcolor, 0.8, 0.3, 0.3, 1) local ohR, ohG, ohB, ohA = ParseColor(C.unitframes.swingtimerohcolor, 0.3, 0.8, 0.3, 1) 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" local mhDefaultR, mhDefaultG, mhDefaultB = mhR, mhG, mhB -- 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("BOTTOM", UIParent, "CENTER", -189, 103) pfUI.swingtimer.mainhand:SetWidth(sw_width) pfUI.swingtimer.mainhand:SetHeight(sw_height) pfUI.swingtimer.mainhand:SetMinMaxValues(0, 1) pfUI.swingtimer.mainhand:SetValue(0) pfUI.swingtimer.mainhand:SetStatusBarTexture(sw_texture) pfUI.swingtimer.mainhand:SetStatusBarColor(mhR, mhG, mhB, mhA) pfUI.swingtimer.mainhand:Hide() pfUI.swingtimer.mainhand.text = pfUI.swingtimer.mainhand:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.mainhand.text:SetPoint("CENTER", pfUI.swingtimer.mainhand, "CENTER", 0, 0) pfUI.swingtimer.mainhand.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.mainhand.text:SetTextColor(1, 1, 1, 1) pfUI.swingtimer.mainhand.text:SetText("") if not sw_showtext then pfUI.swingtimer.mainhand.text:Hide() end pfUI.swingtimer.mainhand.label = pfUI.swingtimer.mainhand:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.mainhand.label:SetPoint("RIGHT", pfUI.swingtimer.mainhand, "LEFT", -4, 0) pfUI.swingtimer.mainhand.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") 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) pfUI.swingtimer.mainhand.marker = pfUI.swingtimer.mainhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.mainhand.marker:SetTexture(1, 1, 1, 1.0) pfUI.swingtimer.mainhand.marker:SetWidth(2) pfUI.swingtimer.mainhand.marker:SetHeight(sw_height) pfUI.swingtimer.mainhand.marker:Hide() -- Left glow: fades from transparent to white (right edge = marker) pfUI.swingtimer.mainhand.markerGlowL = pfUI.swingtimer.mainhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.mainhand.markerGlowL:SetTexture(1, 1, 1, 1) pfUI.swingtimer.mainhand.markerGlowL:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0, 1, 1, 1, 0.35) pfUI.swingtimer.mainhand.markerGlowL:SetWidth(10) pfUI.swingtimer.mainhand.markerGlowL:SetHeight(sw_height) pfUI.swingtimer.mainhand.markerGlowL:Hide() -- Right glow: fades from white to transparent (left edge = marker) pfUI.swingtimer.mainhand.markerGlowR = pfUI.swingtimer.mainhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.mainhand.markerGlowR:SetTexture(1, 1, 1, 1) pfUI.swingtimer.mainhand.markerGlowR:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0.35, 1, 1, 1, 0) pfUI.swingtimer.mainhand.markerGlowR:SetWidth(10) pfUI.swingtimer.mainhand.markerGlowR:SetHeight(sw_height) pfUI.swingtimer.mainhand.markerGlowR:Hide() CreateBackdropShadow(pfUI.swingtimer.mainhand) -- Offhand bar pfUI.swingtimer.offhand = CreateFrame("StatusBar", "pfSwingTimerOffhand", UIParent) pfUI.swingtimer.offhand:SetPoint("TOP", pfUI.swingtimer.mainhand, "BOTTOM", 0, -4) pfUI.swingtimer.offhand:SetWidth(sw_width) pfUI.swingtimer.offhand:SetHeight(sw_height) pfUI.swingtimer.offhand:SetMinMaxValues(0, 1) pfUI.swingtimer.offhand:SetValue(0) pfUI.swingtimer.offhand:SetStatusBarTexture(sw_texture) pfUI.swingtimer.offhand:SetStatusBarColor(ohR, ohG, ohB, ohA) pfUI.swingtimer.offhand:Hide() pfUI.swingtimer.offhand.text = pfUI.swingtimer.offhand:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.offhand.text:SetPoint("CENTER", pfUI.swingtimer.offhand, "CENTER", 0, 0) pfUI.swingtimer.offhand.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.offhand.text:SetTextColor(1, 1, 1, 1) pfUI.swingtimer.offhand.text:SetText("") if not sw_showtext then pfUI.swingtimer.offhand.text:Hide() end pfUI.swingtimer.offhand.label = pfUI.swingtimer.offhand:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.offhand.label:SetPoint("RIGHT", pfUI.swingtimer.offhand, "LEFT", -4, 0) pfUI.swingtimer.offhand.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.offhand.label:SetTextColor(0.8, 0.8, 0.8, 1) pfUI.swingtimer.offhand.label:SetText(sw_showlabel and "OH" or "") pfUI.swingtimer.offhand.speed = pfUI.swingtimer.offhand:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.offhand.speed:SetPoint("LEFT", pfUI.swingtimer.offhand, "RIGHT", 4, 0) pfUI.swingtimer.offhand.speed:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.offhand.speed:SetTextColor(0.8, 0.8, 0.8, 1) pfUI.swingtimer.offhand.speed:SetText("") if not sw_showspeed then pfUI.swingtimer.offhand.speed:Hide() end CreateBackdrop(pfUI.swingtimer.offhand) pfUI.swingtimer.offhand.marker = pfUI.swingtimer.offhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.offhand.marker:SetTexture(1, 1, 1, 1.0) pfUI.swingtimer.offhand.marker:SetWidth(2) pfUI.swingtimer.offhand.marker:SetHeight(sw_height) pfUI.swingtimer.offhand.marker:Hide() -- Left glow: fades from transparent to white (right edge = marker) pfUI.swingtimer.offhand.markerGlowL = pfUI.swingtimer.offhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.offhand.markerGlowL:SetTexture(1, 1, 1, 1) pfUI.swingtimer.offhand.markerGlowL:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0, 1, 1, 1, 0.35) pfUI.swingtimer.offhand.markerGlowL:SetWidth(10) pfUI.swingtimer.offhand.markerGlowL:SetHeight(sw_height) pfUI.swingtimer.offhand.markerGlowL:Hide() -- Right glow: fades from white to transparent (left edge = marker) pfUI.swingtimer.offhand.markerGlowR = pfUI.swingtimer.offhand:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.offhand.markerGlowR:SetTexture(1, 1, 1, 1) pfUI.swingtimer.offhand.markerGlowR:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0.35, 1, 1, 1, 0) pfUI.swingtimer.offhand.markerGlowR:SetWidth(10) pfUI.swingtimer.offhand.markerGlowR:SetHeight(sw_height) pfUI.swingtimer.offhand.markerGlowR:Hide() CreateBackdropShadow(pfUI.swingtimer.offhand) -- Ranged bar pfUI.swingtimer.ranged = CreateFrame("Frame", "pfSwingTimerRanged", UIParent) pfUI.swingtimer.ranged:SetPoint("BOTTOM", UIParent, "CENTER", -189, 83) pfUI.swingtimer.ranged:SetWidth(sw_width) pfUI.swingtimer.ranged:SetHeight(sw_height) pfUI.swingtimer.ranged:Hide() 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) pfUI.swingtimer.ranged.left:SetHeight(sw_height) pfUI.swingtimer.ranged.left:SetWidth(sw_width / 2) pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1) pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA) 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) pfUI.swingtimer.ranged.right:SetHeight(sw_height) pfUI.swingtimer.ranged.right:SetWidth(sw_width / 2) pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1) pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA) 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) pfUI.swingtimer.ranged.warn:SetHeight(sw_height) pfUI.swingtimer.ranged.warn:SetWidth(1) pfUI.swingtimer.ranged.warn:SetVertexColor(rwR, rwG, rwB, rwA) pfUI.swingtimer.ranged.warn:Hide() pfUI.swingtimer.ranged.text = pfUI.swingtimer.ranged:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.ranged.text:SetPoint("CENTER", pfUI.swingtimer.ranged, "CENTER", 0, 0) pfUI.swingtimer.ranged.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.ranged.text:SetTextColor(1, 1, 1, 1) pfUI.swingtimer.ranged.text:SetText("") if not sw_showtext then pfUI.swingtimer.ranged.text:Hide() end pfUI.swingtimer.ranged.label = pfUI.swingtimer.ranged:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.ranged.label:SetPoint("RIGHT", pfUI.swingtimer.ranged, "LEFT", -4, 0) pfUI.swingtimer.ranged.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.ranged.label:SetTextColor(0.8, 0.8, 0.8, 1) pfUI.swingtimer.ranged.label:SetText(sw_showlabel and "Ra" or "") pfUI.swingtimer.ranged.speed = pfUI.swingtimer.ranged:CreateFontString("Status", "DIALOG", "GameFontNormal") pfUI.swingtimer.ranged.speed:SetPoint("LEFT", pfUI.swingtimer.ranged, "RIGHT", 4, 0) pfUI.swingtimer.ranged.speed:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE") pfUI.swingtimer.ranged.speed:SetTextColor(0.8, 0.8, 0.8, 1) pfUI.swingtimer.ranged.speed:SetText("") if not sw_showspeed then pfUI.swingtimer.ranged.speed:Hide() end CreateBackdrop(pfUI.swingtimer.ranged) pfUI.swingtimer.ranged.marker = pfUI.swingtimer.ranged:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.ranged.marker:SetTexture(1, 1, 1, 1.0) pfUI.swingtimer.ranged.marker:SetWidth(2) pfUI.swingtimer.ranged.marker:SetHeight(sw_height) pfUI.swingtimer.ranged.marker:Hide() -- Left glow: fades from transparent to white (right edge = marker) pfUI.swingtimer.ranged.markerGlowL = pfUI.swingtimer.ranged:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.ranged.markerGlowL:SetTexture(1, 1, 1, 1) pfUI.swingtimer.ranged.markerGlowL:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0, 1, 1, 1, 0.35) pfUI.swingtimer.ranged.markerGlowL:SetWidth(10) pfUI.swingtimer.ranged.markerGlowL:SetHeight(sw_height) pfUI.swingtimer.ranged.markerGlowL:Hide() -- Right glow: fades from white to transparent (left edge = marker) pfUI.swingtimer.ranged.markerGlowR = pfUI.swingtimer.ranged:CreateTexture(nil, "OVERLAY") pfUI.swingtimer.ranged.markerGlowR:SetTexture(1, 1, 1, 1) pfUI.swingtimer.ranged.markerGlowR:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 0.35, 1, 1, 1, 0) pfUI.swingtimer.ranged.markerGlowR:SetWidth(10) pfUI.swingtimer.ranged.markerGlowR:SetHeight(sw_height) pfUI.swingtimer.ranged.markerGlowR:Hide() CreateBackdropShadow(pfUI.swingtimer.ranged) UpdateMovable(pfUI.swingtimer.mainhand) UpdateMovable(pfUI.swingtimer.ranged) -- 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 local function UpdateWeaponSpeeds() local ms, os = UnitAttackSpeed("player") S.mhSpeed = (ms and ms > 0) and ms or S.mhSpeed S.ohSpeed = (HasOffhandWeapon() and os and os > 0) and os or 0 local rs = UnitRangedDamage("player") S.raSpeed = (rs and rs > 0) and rs or 0 end -- Reset MH countdown to full speed (server confirmed swing) local function ResetMH() if S.mhFrozenAt then S.mhFrozenAt = nil end UpdateWeaponSpeeds() pfUI.swingtimer.mhGraceAt = nil -- cancel any pending hide S.mhTimerMax = S.mhSpeed S.mhTimer = S.mhSpeed S.mhActive = true pfUI.swingtimer.mainhand:Show() pfUI.swingtimer:Show() end -- Reset OH countdown to full speed local function ResetOH() UpdateWeaponSpeeds() if S.ohSpeed <= 0 then return end pfUI.swingtimer.ohGraceAt = nil -- cancel any pending hide S.ohTimerMax = S.ohSpeed S.ohTimer = S.ohSpeed S.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 S.raSpeed <= 0 then return end -- Ranged replaces MH bar S.mhActive = false pfUI.swingtimer.mainhand:Hide() S.raTimerMax = S.raSpeed S.raTimer = S.raSpeed S.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() S.mhActive = false S.ohActive = false S.raActive = false S.mhTimer = 0 S.ohTimer = 0 S.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 S.isWarrior or not sw_hsqueue or S.useSpellQueueEvent then return end S.cachedHSSlots = {} S.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(S.cachedHSSlots, slot) elseif string.find(tex, "Ability_Warrior_Cleave") then table.insert(S.cachedCleaveSlots, slot) end end if name then local lower = string.lower(name) if lower == "heroic strike" or lower == "heroicstrike" or lower == "hs" then table.insert(S.cachedHSSlots, slot) elseif lower == "cleave" then table.insert(S.cachedCleaveSlots, slot) end end end end local function CheckQueuedAction(slotList) for i = 1, table.getn(slotList) do if IsCurrentAction(slotList[i]) then return true end end return false end local function IsHSOrCleaveQueued() if not sw_hsqueue or not S.isWarrior then return false, false end if S.useSpellQueueEvent then return S.hsQueued, S.cleaveQueued end return CheckQueuedAction(S.cachedHSSlots), CheckQueuedAction(S.cachedCleaveSlots) end -- OnUpdate: countdown all timers with delta, then render pfUI.swingtimer:SetScript("OnUpdate", function() S.swingThrottle = S.swingThrottle + arg1 local swingDelay = pfUI.throttle and pfUI.throttle:Get("swingtimer") or 0.02 if S.swingThrottle < swingDelay then return end local delta = S.swingThrottle S.swingThrottle = 0 -- (out-of-combat hide handled naturally when timers expire) 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 S.mhActive then -- Don't tick while frozen (swing-delay spell like Slam is being cast) if not S.mhFrozenAt then S.mhTimer = S.mhTimer - delta end if S.mhTimer <= 0 then S.mhTimer = 0 if S.mhFrozenAt then -- Swing-delay spell cast in progress: bar holds at 0, skip grace/hide. -- SPELL_GO handler will unfreeze and add cast duration to timer. elseif not pfUI.swingtimer.mhGraceAt then pfUI.swingtimer.mhGraceAt = GetTime() + GRACE elseif GetTime() >= pfUI.swingtimer.mhGraceAt then pfUI.swingtimer.mhGraceAt = nil if not S.inCombat or not S.autoAttackActive then S.mhActive = false pfUI.swingtimer.mainhand:Hide() S.lastMhMarkerX = -1 pfUI.swingtimer.mainhand.marker:Hide() pfUI.swingtimer.mainhand.markerGlowL:Hide() pfUI.swingtimer.mainhand.markerGlowR:Hide() end end end end if S.ohActive then S.ohTimer = S.ohTimer - delta if S.ohTimer <= 0 then S.ohTimer = 0 if not pfUI.swingtimer.ohGraceAt then pfUI.swingtimer.ohGraceAt = GetTime() + GRACE elseif GetTime() >= pfUI.swingtimer.ohGraceAt then pfUI.swingtimer.ohGraceAt = nil if not S.inCombat or not S.autoAttackActive then S.ohActive = false pfUI.swingtimer.offhand:Hide() S.lastOhMarkerX = -1 pfUI.swingtimer.offhand.marker:Hide() pfUI.swingtimer.offhand.markerGlowL:Hide() pfUI.swingtimer.offhand.markerGlowR:Hide() end end end end if S.raActive then S.raTimer = S.raTimer - delta if S.raTimer <= 0 then S.raTimer = 0 S.raActive = false pfUI.swingtimer.ranged:Hide() end end -- HS/Cleave color local curR, curG, curB = mhDefaultR, mhDefaultG, mhDefaultB if sw_hsqueue then if S.maulQueued and S.isDruid then curR, curG, curB = 1.0, 0.55, 0.0 -- orange for druid maul queue elseif S.isWarrior then local hs, cl = IsHSOrCleaveQueued() if cl then curR, curG, curB = 0.2, 0.9, 0.2 elseif hs then curR, curG, curB = 0.9, 0.9, 0.2 end end end -- Render MH if S.mhActive then local progress = 1 - (S.mhTimer / S.mhTimerMax) pfUI.swingtimer.mainhand:SetValue(progress) if S.mhTimer <= 0 then -- Timer expired: hide marker (no gap at right edge) if S.lastMhMarkerX ~= -1 then S.lastMhMarkerX = -1 pfUI.swingtimer.mainhand.marker:Hide() pfUI.swingtimer.mainhand.markerGlowL:Hide() pfUI.swingtimer.mainhand.markerGlowR:Hide() end else local mhMarkerX = progress * sw_width if mhMarkerX < 1 then mhMarkerX = 1 end if mhMarkerX > sw_width - 2 then mhMarkerX = sw_width - 2 end if mhMarkerX ~= S.lastMhMarkerX then S.lastMhMarkerX = mhMarkerX pfUI.swingtimer.mainhand.marker:SetPoint("LEFT", pfUI.swingtimer.mainhand, "LEFT", mhMarkerX - 1, 0) pfUI.swingtimer.mainhand.markerGlowL:SetPoint("RIGHT", pfUI.swingtimer.mainhand.marker, "LEFT", 0, 0) pfUI.swingtimer.mainhand.markerGlowR:SetPoint("LEFT", pfUI.swingtimer.mainhand.marker, "RIGHT", 0, 0) pfUI.swingtimer.mainhand.marker:Show() pfUI.swingtimer.mainhand.markerGlowL:Show() pfUI.swingtimer.mainhand.markerGlowR:Show() end end pfUI.swingtimer.mainhand:SetStatusBarColor(curR, curG, curB, mhA) if sw_showtext then pfUI.swingtimer.mainhand.text:SetText(string.format("%.1f", math.floor(S.mhTimer * 10) / 10)) end if sw_showspeed and S.mhSpeed > 0 then pfUI.swingtimer.mainhand.speed:SetText(string.format("%.2f", S.mhSpeed)) end anyActive = true end -- Render OH if sw_showoh and S.ohActive then local progress = 1 - (S.ohTimer / S.ohTimerMax) pfUI.swingtimer.offhand:SetValue(progress) if S.ohTimer <= 0 then if S.lastOhMarkerX ~= -1 then S.lastOhMarkerX = -1 pfUI.swingtimer.offhand.marker:Hide() pfUI.swingtimer.offhand.markerGlowL:Hide() pfUI.swingtimer.offhand.markerGlowR:Hide() end else local ohMarkerX = progress * sw_width if ohMarkerX < 1 then ohMarkerX = 1 end if ohMarkerX > sw_width - 2 then ohMarkerX = sw_width - 2 end if ohMarkerX ~= S.lastOhMarkerX then S.lastOhMarkerX = ohMarkerX pfUI.swingtimer.offhand.marker:SetPoint("LEFT", pfUI.swingtimer.offhand, "LEFT", ohMarkerX - 1, 0) pfUI.swingtimer.offhand.markerGlowL:SetPoint("RIGHT", pfUI.swingtimer.offhand.marker, "LEFT", 0, 0) pfUI.swingtimer.offhand.markerGlowR:SetPoint("LEFT", pfUI.swingtimer.offhand.marker, "RIGHT", 0, 0) pfUI.swingtimer.offhand.marker:Show() pfUI.swingtimer.offhand.markerGlowL:Show() pfUI.swingtimer.offhand.markerGlowR:Show() end end if sw_showtext then pfUI.swingtimer.offhand.text:SetText(string.format("%.1f", math.floor(S.ohTimer * 10) / 10)) end if sw_showspeed and S.ohSpeed > 0 then pfUI.swingtimer.offhand.speed:SetText(string.format("%.2f", S.ohSpeed)) end anyActive = true elseif not sw_showoh then pfUI.swingtimer.offhand:Hide() end -- Render Ranged if sw_showranged and S.raActive then local remaining = S.raTimer if isHunter then local DEADZONE = 0.5 local halfW = sw_width / 2 if remaining > DEADZONE then local elapsed = S.raTimerMax - remaining local phase1dur = S.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, (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 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", math.floor((remaining - 0.5) * 10) / 10)) end end else local progress = 1 - (remaining / S.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 if sw_showspeed and S.raSpeed > 0 then pfUI.swingtimer.ranged.speed:SetText(string.format("%.2f", S.raSpeed)) end local raProgress = 1 - (S.raTimer / S.raTimerMax) local raMarkerX = raProgress * sw_width if raMarkerX < 1 then raMarkerX = 1 end if raMarkerX > sw_width - 2 then raMarkerX = sw_width - 2 end if raMarkerX ~= S.lastRaMarkerX then S.lastRaMarkerX = raMarkerX if not isHunter then pfUI.swingtimer.ranged.marker:SetPoint("LEFT", pfUI.swingtimer.ranged, "LEFT", raMarkerX - 1, 0) pfUI.swingtimer.ranged.marker:Show() pfUI.swingtimer.ranged.markerGlowL:SetPoint("RIGHT", pfUI.swingtimer.ranged.marker, "LEFT", 0, 0) pfUI.swingtimer.ranged.markerGlowR:SetPoint("LEFT", pfUI.swingtimer.ranged.marker, "RIGHT", 0, 0) pfUI.swingtimer.ranged.markerGlowL:Show() pfUI.swingtimer.ranged.markerGlowR:Show() else pfUI.swingtimer.ranged.marker:Hide() pfUI.swingtimer.ranged.markerGlowL:Hide() pfUI.swingtimer.ranged.markerGlowR:Hide() end end anyActive = true elseif not sw_showranged then pfUI.swingtimer.ranged:Hide() end if not anyActive then if not pfUI.swingtimer.mainhand:IsShown() and not pfUI.swingtimer.offhand:IsShown() and not pfUI.swingtimer.ranged:IsShown() then this:Hide() end end end) -- SPELL_START_SELF: fires only for cast-time spells, never instants local spellStartFrame = CreateFrame("Frame") spellStartFrame:RegisterEvent("SPELL_START_SELF") spellStartFrame:SetScript("OnEvent", function() if arg1 and arg1 > 0 then S.pendingCastSpellId = arg1 -- Slam (and other delay-only spells): freeze the swing timer during cast -- so it pauses instead of ticking down and expiring mid-cast if swingDelaySpells[arg1] and S.mhActive then S.mhFrozenAt = GetTime() end end end) -- Cast cancel/fail/interrupt: unfreeze swing timer if a delay spell was interrupted local spellFailFrame = CreateFrame("Frame") spellFailFrame:RegisterEvent("SPELLCAST_FAILED") spellFailFrame:RegisterEvent("SPELLCAST_INTERRUPTED") spellFailFrame:SetScript("OnEvent", function() if S.mhFrozenAt then S.mhFrozenAt = nil end S.pendingCastSpellId = nil end) -- 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 ResetRanged() return elseif swingDelaySpells[spellId] then -- Swing-delay spells (Slam, Hammer of Wrath on Turtle WoW): -- Delay the swing timer by cast duration, do NOT reset it. if S.mhFrozenAt then local castDuration = GetTime() - S.mhFrozenAt S.mhTimer = S.mhTimer + castDuration S.mhTimerMax = S.mhTimerMax + castDuration S.mhFrozenAt = nil end S.pendingCastSpellId = nil return elseif hsSpellIDs[spellId] or cleaveSpellIDs[spellId] or maulSpellIDs[spellId] or IsOnSwingSpell(spellId) then S.hsQueued = false; S.cleaveQueued = false S.maulQueued = false ResetMH() elseif cleaveSpellIDs[spellId] then S.hsQueued = false; S.cleaveQueued = false ResetMH() else -- Any spell with interruptFlags > 0 resets the swing timer -- (Moonfire, Faerie Fire, Wrath, Starfire etc. - NOT Insect Swarm which has flags=0) local _rec = GetSpellRec(spellId) if _rec and _rec.interruptFlags and _rec.interruptFlags > 0 then if S.mhActive and S.mhSpeed > 0 then UpdateWeaponSpeeds() S.mhTimerMax = S.mhSpeed S.mhTimer = S.mhSpeed end end end S.pendingCastSpellId = nil end -- 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 hsSpellIDs[spellId] then S.hsQueued = true; S.cleaveQueued = false; S.maulQueued = false elseif cleaveSpellIDs[spellId] then S.cleaveQueued = true; S.hsQueued = false; S.maulQueued = false elseif maulSpellIDs[spellId] then S.maulQueued = true; S.hsQueued = false; S.cleaveQueued = false 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") events:RegisterEvent("START_AUTOATTACK") events:RegisterEvent("STOP_AUTOATTACK") events:SetScript("OnEvent", function() if event == "AUTO_ATTACK_SELF" then local hitInfo = arg4 or 0 local isOffhand = bit.band(hitInfo, HITINFO_LEFTSWING) ~= 0 local noAction = bit.band(hitInfo, HITINFO_NOACTION) ~= 0 -- NOACTION means server did not advance swing clock (dodge/parry/miss). -- Only skip if the timer is already running - if it's not active yet -- (first swing ever), we still want to start it so the bar appears. if noAction then if isOffhand and S.ohActive then return end if not isOffhand and S.mhActive then return end 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 = S.ohActive and (S.ohTimer / S.ohTimerMax) or 0 if S.ohActive and S.ohTimer > 0 and pct > 0.20 then return end ResetOH() else local pct = S.mhActive and (S.mhTimer / S.mhTimerMax) or 0 if S.mhActive and S.mhTimer > 0 and pct > 0.20 then return end ResetMH() end elseif event == "AUTO_ATTACK_OTHER" then -- Parry haste: enemy attacked the player and player parried local targetGuid = arg2 if not targetGuid or not S.playerGUID then return end if targetGuid ~= S.playerGUID then return end local victimState = arg5 or 0 -- VICTIMSTATE_PARRY = 3 -- 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 -- Apply to whichever swing comes next (smallest % remaining = closest to firing) if S.ohActive and S.ohSpeed > 0 and (S.ohTimer / S.ohTimerMax) < (S.mhTimer / S.mhTimerMax) then local minimum = S.ohSpeed * 0.20 if S.ohTimer > minimum then local reduct = S.ohSpeed * 0.40 local before = S.ohTimer S.ohTimer = S.ohTimer - reduct if S.ohTimer < minimum then S.ohTimer = minimum end end elseif S.mhActive and S.mhSpeed > 0 then local minimum = S.mhSpeed * 0.20 if S.mhTimer > minimum then local reduct = S.mhSpeed * 0.40 local before = S.mhTimer S.mhTimer = S.mhTimer - reduct if S.mhTimer < minimum then S.mhTimer = minimum end end else end end elseif event == "SPELL_QUEUE_EVENT" then local eventCode = arg1 or -1 local spellId = arg2 or 0 if eventCode == ON_SWING_QUEUED then S.useSpellQueueEvent = true if hsSpellIDs[spellId] then S.hsQueued = true; S.cleaveQueued = false; S.maulQueued = false elseif cleaveSpellIDs[spellId] then S.cleaveQueued = true; S.hsQueued = false; S.maulQueued = false elseif maulSpellIDs[spellId] then S.maulQueued = true; S.hsQueued = false; S.cleaveQueued = false end elseif eventCode == ON_SWING_QUEUE_POPPED then S.hsQueued = false; S.cleaveQueued = false; S.maulQueued = false end elseif event == "START_AUTOATTACK" then S.autoAttackActive = true elseif event == "STOP_AUTOATTACK" then S.autoAttackActive = false elseif event == "PLAYER_ENTERING_WORLD" then local _, class = UnitClass("player") S.isWarrior = (class == "WARRIOR") S.isDruid = (class == "DRUID") S.playerGUID = GetUnitGUID("player") UpdateWeaponSpeeds() RebuildQueueSlotCache() elseif event == "UNIT_INVENTORY_CHANGED" then if arg1 and arg1 ~= "player" then return end UpdateWeaponSpeeds() if S.ohSpeed == 0 then S.ohActive = false pfUI.swingtimer.offhand:Hide() end if S.raSpeed == 0 then S.raActive = false pfUI.swingtimer.ranged:Hide() end elseif event == "ACTIONBAR_SLOT_CHANGED" then RebuildQueueSlotCache() elseif event == "PLAYER_REGEN_DISABLED" then S.inCombat = true UpdateWeaponSpeeds() elseif event == "PLAYER_REGEN_ENABLED" then S.inCombat = false S.hsQueued = false S.cleaveQueued = false S.maulQueued = false elseif event == "UNIT_DIED" then if arg1 and arg1 == S.playerGUID then ResetAll() end end end) -- ------------------------------------------------------------------------- -- Public API for external addons (e.g. SCRM) -- Only available when the swingtimer module is loaded. -- Usage: -- if pfUI and pfUI.swingtimer and pfUI.swingtimer.api then -- local api = pfUI.swingtimer.api -- local mhTimer = api.GetMHTimer() -- end -- ------------------------------------------------------------------------- pfUI.swingtimer.api = { -- Remaining time in seconds until next swing GetMHTimer = function() return S.mhTimer end, GetOHTimer = function() return S.ohTimer end, GetRangedTimer = function() return S.raTimer end, -- Weapon speed (full swing duration in seconds) GetMHSpeed = function() return S.mhSpeed end, GetOHSpeed = function() return S.ohSpeed end, GetRangedSpeed = function() return S.raSpeed end, -- Whether each timer is currently running IsMHActive = function() return S.mhActive end, IsOHActive = function() return S.ohActive end, IsRangedActive = function() return S.raActive end, -- Swing progress 0.0 (just reset) to 1.0 (about to swing) GetMHProgress = function() return S.mhActive and S.mhTimerMax > 0 and (1 - S.mhTimer / S.mhTimerMax) or 0 end, GetOHProgress = function() return S.ohActive and S.ohTimerMax > 0 and (1 - S.ohTimer / S.ohTimerMax) or 0 end, GetRangedProgress = function() return S.raActive and S.raTimerMax > 0 and (1 - S.raTimer / S.raTimerMax) or 0 end, -- Warrior HS/Cleave queue state IsHSQueued = function() return S.hsQueued end, IsCleaveQueued = function() return S.cleaveQueued end, IsMaulQueued = function() return S.maulQueued end, } UpdateWeaponSpeeds() end)