pfUI:RegisterModule("swingtimer", 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, hsSeenCurrent = false, cleaveSeenCurrent = false, maulSeenCurrent = false, isWarrior = false, isDruid = false, cachedHSSlots = {}, cachedCleaveSlots = {}, cachedMaulSlots = {}, useSpellQueueEvent = false, swingThrottle = 0, onSwingCache = {}, } -- Wand "Shoot" runs on the ranged bar but is INDEPENDENT of the melee -- swing clock — casters melee-weave between mainhand swings and wand -- fires, both timers tick concurrently. Every other ranged-auto-attack -- (Hunter Auto Shot, any future auto-repeat ranged spell) replaces MH. local WAND_SHOOT_SPELLID = 5019 local THROW_SPELLID = 2764 -- one-shot ranged, not auto-repeat -- On-next-swing spells replace the next auto-attack swing. -- Covers Raptor Strike, Maul, Mongoose Bite, Holy Strike, etc. automatically. local function IsOnSwingSpell(spellId) if S.onSwingCache[spellId] ~= nil then return S.onSwingCache[spellId] end local result = C_Spell.IsNextMeleeSpell(spellId) S.onSwingCache[spellId] = result return result end -- HS / Cleave / Maul are detected dynamically via IsOnSwingSpell (the -- SPELL_ATTR_ON_NEXT_SWING bit) + name comparison against the rank-1 -- canonical names cached below — no per-rank ID maintenance. -- 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 = UnitClassBase("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 -- OH weapon detection: slot 17 (off hand) accepts one-hand (13) and off-hand -- weapons (22); main-hand-only (21) can never sit there. local OH_WEAPON_TYPES = { [13]=true, [22]=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 return OH_WEAPON_TYPES[C_Item.GetItemInventoryTypeByID(id)] == 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. replaceMH=true (Hunter Auto Shot, Throw) stops -- the melee swing clock while ranged ticks; replaceMH=false (wand Shoot) -- leaves it running for melee weaving. local function ResetRanged(replaceMH) if not sw_showranged then return end UpdateWeaponSpeeds() if S.raSpeed <= 0 then return end if replaceMH then S.mhActive = false pfUI.swingtimer.mainhand:Hide() end 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/Maul helpers. Canonical rank-1 spellIDs resolve to the -- localized spell name once, so per-spell classification is rank- and -- locale-independent without per-rank ID hardcoding (every rank of -- Heroic Strike etc. returns the same name). local HS_NAME = C_Spell.GetSpellName(78) -- Heroic Strike (Rank 1) local CLEAVE_NAME = C_Spell.GetSpellName(845) -- Cleave (Rank 1) local MAUL_NAME = C_Spell.GetSpellName(6807) -- Maul (Rank 1) -- Classify an on-next-swing spell as "hs" / "cleave" / "maul" / nil. -- IsOnSwingSpell gates the family (ATTR_ON_NEXT_SWING bit 0x04) so -- unrelated spells return nil cheaply. local function ClassifyOnSwingSpell(spellId) if not IsOnSwingSpell(spellId) then return nil end local name = C_Spell.GetSpellName(spellId) if name == HS_NAME then return "hs" elseif name == CLEAVE_NAME then return "cleave" elseif name == MAUL_NAME then return "maul" end end -- Set the queue flags from a ClassifyOnSwingSpell result. nil = no-op -- (preserves prior flag state when the queued spell isn't one we -- color-code, matching the legacy table-lookup behavior). local function SetQueuedKind(kind) if not kind then return end S.hsQueued = (kind == "hs") S.cleaveQueued = (kind == "cleave") S.maulQueued = (kind == "maul") S.hsSeenCurrent, S.cleaveSeenCurrent, S.maulSeenCurrent = false, false, false end local function RebuildQueueSlotCache() if not sw_hsqueue then return end S.cachedHSSlots = {} S.cachedCleaveSlots = {} S.cachedMaulSlots = {} if not (S.isWarrior or S.isDruid) then return end for slot = 1, 120 do local kind, id = GetActionInfo(slot) local name if kind == "spell" then name = C_Spell.GetSpellName(id) elseif kind == "macro" then name = GetMacroSpell(id) end if name == HS_NAME then table.insert(S.cachedHSSlots, slot) elseif name == CLEAVE_NAME then table.insert(S.cachedCleaveSlots, slot) elseif name == MAUL_NAME then table.insert(S.cachedMaulSlots, slot) 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 -- Reconcile a stale event-driven queue flag against the client's current -- action. Not every de-queue emits a SPELL_QUEUE pop — pressing Esc or -- re-pressing to cancel an on-swing spell doesn't — so the flag alone stays -- set. IsCurrentAction, which the client clears on cancel, is the reconciling -- signal, but only after it has confirmed the ability as current at least once -- (`seen`): a nampower-initiated cast may never flip IsCurrentAction, and must -- keep its color until its own pop/resolve rather than be cleared early. -- Returns the updated (queued, seen). local function ReconcileQueued(queued, slots, seen) if not queued then return false, false end if CheckQueuedAction(slots) then return true, true end if seen then return false, false end -- was current, now gone -> cancelled return true, false -- never confirmed current -> keep end local function IsHSOrCleaveQueued() if not sw_hsqueue or not S.isWarrior then return false, false end if S.useSpellQueueEvent then S.hsQueued, S.hsSeenCurrent = ReconcileQueued(S.hsQueued, S.cachedHSSlots, S.hsSeenCurrent) S.cleaveQueued, S.cleaveSeenCurrent = ReconcileQueued(S.cleaveQueued, S.cachedCleaveSlots, S.cleaveSeenCurrent) 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.isDruid and S.useSpellQueueEvent then S.maulQueued, S.maulSeenCurrent = ReconcileQueued(S.maulQueued, S.cachedMaulSlots, S.maulSeenCurrent) end 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:SetFormattedText("%.1f", math.floor(S.mhTimer * 10) / 10) end if sw_showspeed and S.mhSpeed > 0 then pfUI.swingtimer.mainhand.speed:SetFormattedText("%.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:SetFormattedText("%.1f", math.floor(S.ohTimer * 10) / 10) end if sw_showspeed and S.ohSpeed > 0 then pfUI.swingtimer.offhand.speed:SetFormattedText("%.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:SetFormattedText("%.1f", math.floor(remaining * 10) / 10) else pfUI.swingtimer.ranged.text:SetFormattedText("%.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:SetFormattedText("%.1f", math.floor(remaining * 10) / 10) end end if sw_showspeed and S.raSpeed > 0 then pfUI.swingtimer.ranged.speed:SetFormattedText("%.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 not (arg1 and arg1 > 0) then return end S.pendingCastSpellId = arg1 -- Freeze the swing timer for cast-time spells that DON'T reset auto- -- attack on completion (Slam, Hammer of Wrath on Turtle, etc.) — those -- let the swing resume from where it paused. C_Spell.ResetsMeleeSwing -- mirrors the server rule; spells that reset don't need freezing (they -- reset on SPELL_GO_SELF). Subsumes the old hardcoded swingDelaySpells -- list (no list maintenance for new Slam-style spells). if S.mhActive then if not C_Spell.ResetsMeleeSwing(arg1) 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) -- C_Spell.IsRangedAutoAttackSpell catches both Auto Shot (75) and -- wand Shoot (5019) via Spell.dbc's AUTO_REPEAT attribute (covers -- any future auto-repeat ranged spell automatically). Wand is the -- one independent of the MH swing — everything else replaces it. -- Throw isn't auto-repeat (single-shot) so it's handled explicitly. if C_Spell.IsRangedAutoAttackSpell(spellId) then ResetRanged(spellId ~= WAND_SHOOT_SPELLID) return elseif spellId == THROW_SPELLID then ResetRanged(true) return elseif IsOnSwingSpell(spellId) then -- On-next-swing ability (HS / Cleave / Maul / Raptor Strike / etc.) -- — the swing fires as the spell consumes it. Drop the queued color. S.hsQueued = false; S.cleaveQueued = false; S.maulQueued = false ResetMH() else -- C_Spell.ResetsMeleeSwing mirrors the server rule (Turtle's -- Spell::IsMeleeAttackResetSpell): InterruptFlags has AUTOATTACK and -- AttributesEx2 lacks NOT_RESET_AUTO_ACTIONS. When the spell resets the -- swing, snap the timers to full. Otherwise (elseif) a frozen-swing- -- during-cast is a Slam-style cast — push the timer forward by the cast -- duration so the bar resumes from where it paused. if C_Spell.ResetsMeleeSwing(spellId) then if S.mhActive and S.mhSpeed > 0 then UpdateWeaponSpeeds() S.mhTimerMax = S.mhSpeed S.mhTimer = S.mhSpeed end if S.ohActive and S.ohSpeed > 0 then S.ohTimerMax = S.ohSpeed S.ohTimer = S.ohSpeed end elseif S.mhFrozenAt then local castDuration = GetTime() - S.mhFrozenAt S.mhTimer = S.mhTimer + castDuration S.mhTimerMax = S.mhTimerMax + castDuration S.mhFrozenAt = nil end end S.pendingCastSpellId = nil end -- SPELL_CAST_EVENT hook: HS/Cleave/Maul queue tracking pfUI.libdebuff_spell_cast_hooks = pfUI.libdebuff_spell_cast_hooks or {} pfUI.libdebuff_spell_cast_hooks["swingtimer"] = function(success, spellId) local kind = ClassifyOnSwingSpell(spellId) if not kind then return end S.useSpellQueueEvent = true SetQueuedKind(kind) end local events = CreateFrame("Frame") events:RegisterEvent("AUTO_ATTACK_SELF") events:RegisterEvent("AUTO_ATTACK_OTHER") events:RegisterEvent("PLAYER_ENTERING_WORLD") events:RegisterEvent("PLAYER_EQUIPMENT_CHANGED") -- ClassicAPI: per-slot, equipment-only 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 IsPlayerGuid(targetGuid) 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 SetQueuedKind(ClassifyOnSwingSpell(spellId)) 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 = UnitClassBase("player") S.isWarrior = (class == "WARRIOR") S.isDruid = (class == "DRUID") UpdateWeaponSpeeds() RebuildQueueSlotCache() elseif event == "PLAYER_EQUIPMENT_CHANGED" then -- arg1 = changed slot; only weapon slots (main/off/ranged) affect swing speed if not INVSLOTS_EQUIPABLE_IN_COMBAT[arg1] 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 IsPlayerGuid(arg1) 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)