diff --git a/modules/swingtimer.lua b/modules/swingtimer.lua index 758f53b7..274bcd86 100644 --- a/modules/swingtimer.lua +++ b/modules/swingtimer.lua @@ -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)