forked from Dusk/ShaguTweaks-ClassicAPI
Two Action Bar options, off by default, ported from brues-code/pfUI's swing timer and restyled as classic cast bars: dark background, UI-StatusBar fill, the cast bar border and spark, and the weapon's icon on the left like Improved Castbar's spell icon. - Swing Timer: main hand and off hand (shields are skipped). - Wand Timer: wand Shoot, and Auto Shot for bows, guns and crossbows, red during Auto Shot's last half second. ClassicAPI's PLAYER_SWING drives the timers and PLAYER_SWING_RANGE_UPDATE grays a bar while the target is out of reach. Auto-attack state uses the 1.12 PLAYER_ENTER_COMBAT/PLAYER_LEAVE_COMBAT and START/STOP_AUTOREPEAT_SPELL events, plus START/STOP_AUTOATTACK on ClassicAPI v1.18.14+. Without PLAYER_SWING (before v1.15.10) both options do nothing. The bars stack just above the cast bar. Holding Ctrl+Shift+Alt shows a preview to drag; right-click resets. The position is saved per character. pfUI's MIT license is preserved in LICENSES/pfUI-MIT.txt and credited in THIRD_PARTY_NOTICES.md and the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019W3rwusM4i1CYaHQAYixJ7
445 lines
14 KiB
Lua
445 lines
14 KiB
Lua
-- Swing Timer and Wand Timer
|
|
--
|
|
-- Ported from brues-code/pfUI's swingtimer module (MIT, Shagu and
|
|
-- contributors) and restyled as classic cast bars with the weapon icon.
|
|
-- ClassicAPI's PLAYER_SWING event reports the server's attack timers
|
|
-- (white hits, Heroic Strike/Maul, parry haste, extra attacks, Auto Shot
|
|
-- and wand Shoot), so nothing here guesses from the combat log.
|
|
|
|
local _G = ShaguTweaks.GetGlobalEnv()
|
|
local T = ShaguTweaks.T
|
|
local API = ShaguTweaks.API or {}
|
|
|
|
local swing = ShaguTweaks:register({
|
|
title = T["Swing Timer"],
|
|
description = T["Shows main-hand and off-hand swing timers above the cast bar, with the weapon's icon. Hold <Ctrl>, <Shift> and <Alt> to move them; right-click to reset."],
|
|
expansions = { ["vanilla"] = true },
|
|
category = T["Action Bar"],
|
|
enabled = false,
|
|
})
|
|
|
|
local wand = ShaguTweaks:register({
|
|
title = T["Wand Timer"],
|
|
description = T["Shows the time until your next wand shot (or Auto Shot) above the cast bar, with the weapon's icon."],
|
|
expansions = { ["vanilla"] = true },
|
|
category = T["Action Bar"],
|
|
enabled = false,
|
|
})
|
|
|
|
local WIDTH, HEIGHT = 195, 13
|
|
local SPACING = 24 -- one bar plus its cast bar border
|
|
local GRACE = .15 -- bridge the frame or two until the next PLAYER_SWING
|
|
local THROTTLE = .02
|
|
local AUTOSHOT_WINDUP = .5
|
|
|
|
local COLORS = {
|
|
mainhand = { 1, .7, 0 }, -- the cast bar's own gold
|
|
offhand = { 0, 1, 0 }, -- the channel bar's green
|
|
ranged = { .3, .6, 1 },
|
|
far = { .5, .5, .5 }, -- target out of range
|
|
windup = { 1, 0, 0 }, -- Auto Shot wind-up: don't move
|
|
}
|
|
|
|
local SLOTS = { mainhand = 16, offhand = 17, ranged = 18 }
|
|
local ORDER = { "mainhand", "offhand", "ranged" }
|
|
local PREVIEW = { mainhand = T["Main Hand"], offhand = T["Off Hand"], ranged = T["Ranged"] }
|
|
|
|
-- Everything lives in one table: Lua 5.0 allows only 32 upvalues per function.
|
|
local S = {
|
|
melee = false, ranged = false,
|
|
autoattack = false, autorepeat = false, combat = false,
|
|
hunter = false, preview = false, elapsed = 0,
|
|
far = {},
|
|
bars = {},
|
|
}
|
|
|
|
local function Supported()
|
|
if not _G.C_SwingTimer or not _G.Enum or not _G.Enum.PlayerSwingType then return end
|
|
local utils = _G.C_EventUtils
|
|
if utils and utils.IsEventValid and not utils.IsEventValid("PLAYER_SWING") then return end
|
|
return true
|
|
end
|
|
|
|
local function ModifiersDown()
|
|
local shift = API.IsShiftKeyDown or IsShiftKeyDown
|
|
local ctrl = API.IsControlKeyDown or IsControlKeyDown
|
|
local alt = API.IsAltKeyDown or IsAltKeyDown
|
|
return shift() and ctrl() and alt()
|
|
end
|
|
|
|
local function HasOffhandWeapon()
|
|
local items = _G.C_Item
|
|
if items and items.GetItemInventoryType then
|
|
local invtype = items.GetItemInventoryType({ equipmentSlotIndex = SLOTS.offhand })
|
|
return invtype == 13 or invtype == 22 -- one-hand, off-hand weapon
|
|
end
|
|
|
|
local link = GetInventoryItemLink("player", SLOTS.offhand)
|
|
local _, _, id = link and string.find(link, "item:(%d+)")
|
|
if not id then return false end
|
|
local _, _, _, _, _, _, _, equip = GetItemInfo(tonumber(id))
|
|
return equip == "INVTYPE_WEAPON" or equip == "INVTYPE_WEAPONOFFHAND"
|
|
end
|
|
|
|
local function UpdateSpeeds()
|
|
local mh, oh = UnitAttackSpeed("player")
|
|
S.bars.mainhand.speed = (mh and mh > 0) and mh or S.bars.mainhand.speed or 0
|
|
S.bars.offhand.speed = (HasOffhandWeapon() and oh and oh > 0) and oh or 0
|
|
local ra = UnitRangedDamage("player")
|
|
S.bars.ranged.speed = (ra and ra > 0) and ra or 0
|
|
end
|
|
|
|
local function UpdateIcon(bar)
|
|
local texture = GetInventoryItemTexture("player", bar.slot)
|
|
if texture then
|
|
bar.icon.texture:SetTexture(texture)
|
|
bar.icon:Show()
|
|
else
|
|
bar.icon:Hide()
|
|
end
|
|
end
|
|
|
|
local function Wanted(bar)
|
|
if bar.key == "ranged" then return S.ranged end
|
|
return S.melee
|
|
end
|
|
|
|
-- Stack the shown bars upward from the container, in a fixed order.
|
|
local function Layout()
|
|
local slot = 0
|
|
for i = 1, 3 do
|
|
local bar = S.bars[ORDER[i]]
|
|
if bar:IsShown() then
|
|
bar:ClearAllPoints()
|
|
bar:SetPoint("BOTTOM", S.container, "BOTTOM", 0, slot * SPACING)
|
|
slot = slot + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
local function SetProgress(bar, progress, r, g, b)
|
|
bar:SetValue(progress)
|
|
bar:SetStatusBarColor(r, g, b)
|
|
bar.spark:SetPoint("CENTER", bar, "LEFT", progress * WIDTH, 2)
|
|
end
|
|
|
|
local function StopBar(bar)
|
|
bar.active = false
|
|
bar.timer = 0
|
|
bar.graceAt = nil
|
|
if not S.preview then bar:Hide() end
|
|
end
|
|
|
|
local function StartBar(bar, remaining)
|
|
if not Wanted(bar) or S.preview then return end
|
|
UpdateSpeeds()
|
|
if bar.speed <= 0 then return end
|
|
|
|
-- remaining can be shorter than the weapon speed (parry haste, Auto Shot
|
|
-- wind-up): keep the full swing as the bar length and start part-way.
|
|
bar.max = math.max(bar.speed, remaining)
|
|
bar.timer = remaining
|
|
bar.graceAt = nil
|
|
bar.active = true
|
|
UpdateIcon(bar)
|
|
|
|
if not bar:IsShown() then
|
|
bar:Show()
|
|
Layout()
|
|
end
|
|
S.driver:Show()
|
|
end
|
|
|
|
local function ResetAll()
|
|
for i = 1, 3 do StopBar(S.bars[ORDER[i]]) end
|
|
Layout()
|
|
end
|
|
|
|
local function Tick(bar, delta, now)
|
|
if not bar.active then return end
|
|
|
|
bar.timer = bar.timer - delta
|
|
if bar.timer <= 0 then
|
|
bar.timer = 0
|
|
-- Keep a full bar while auto-attack or Shoot is still on (the target may
|
|
-- just be out of reach); hide it once the attacks stopped.
|
|
local running = bar.key == "ranged" and S.autorepeat or (bar.key ~= "ranged" and S.autoattack and S.combat)
|
|
if not bar.graceAt then
|
|
bar.graceAt = now + GRACE
|
|
elseif now >= bar.graceAt and not running then
|
|
StopBar(bar)
|
|
Layout()
|
|
return
|
|
end
|
|
end
|
|
|
|
local r, g, b = unpack(COLORS[bar.key])
|
|
if bar.key == "ranged" and S.hunter and bar.timer > 0 and bar.timer <= AUTOSHOT_WINDUP then
|
|
r, g, b = unpack(COLORS.windup)
|
|
end
|
|
if S.far[bar.swingType] then
|
|
r, g, b = unpack(COLORS.far)
|
|
end
|
|
|
|
SetProgress(bar, 1 - bar.timer / bar.max, r, g, b)
|
|
|
|
local text = string.format("%.1f", math.floor(bar.timer * 10) / 10)
|
|
if bar.lastText ~= text then
|
|
bar.lastText = text
|
|
bar.text:SetText(text)
|
|
end
|
|
end
|
|
|
|
local function OnUpdate()
|
|
S.elapsed = S.elapsed + arg1
|
|
if S.elapsed < THROTTLE then return end
|
|
local delta = S.elapsed
|
|
S.elapsed = 0
|
|
|
|
local now = GetTime()
|
|
local any = false
|
|
for i = 1, 3 do
|
|
local bar = S.bars[ORDER[i]]
|
|
Tick(bar, delta, now)
|
|
any = any or bar.active
|
|
end
|
|
|
|
if not any then this:Hide() end
|
|
end
|
|
|
|
-- Classic cast bar look: dark background, UI-StatusBar fill, the cast bar
|
|
-- border and spark, and an icon frame like Improved Castbar's.
|
|
local function CreateBar(key)
|
|
local bar = CreateFrame("StatusBar", "ShaguTweaksSwingTimer_" .. key, S.container)
|
|
bar:SetWidth(WIDTH)
|
|
bar:SetHeight(HEIGHT)
|
|
bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
|
|
bar:SetMinMaxValues(0, 1)
|
|
bar:SetValue(0)
|
|
bar:Hide()
|
|
|
|
bar.bg = bar:CreateTexture(nil, "BACKGROUND")
|
|
bar.bg:SetAllPoints(bar)
|
|
bar.bg:SetTexture(0, 0, 0, .5)
|
|
|
|
bar.border = bar:CreateTexture(nil, "ARTWORK")
|
|
bar.border:SetTexture("Interface\\CastingBar\\UI-CastingBar-Border")
|
|
bar.border:SetWidth(256)
|
|
bar.border:SetHeight(64)
|
|
bar.border:SetPoint("TOP", bar, "TOP", 0, 28)
|
|
|
|
bar.spark = bar:CreateTexture(nil, "OVERLAY")
|
|
bar.spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark")
|
|
bar.spark:SetBlendMode("ADD")
|
|
bar.spark:SetWidth(32)
|
|
bar.spark:SetHeight(32)
|
|
bar.spark:SetPoint("CENTER", bar, "LEFT", 0, 2)
|
|
|
|
bar.text = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
|
|
bar.text:SetPoint("CENTER", bar, "CENTER", 0, 3)
|
|
|
|
bar.icon = CreateFrame("Frame", nil, bar)
|
|
bar.icon:SetPoint("RIGHT", bar, "LEFT", -10, 2)
|
|
bar.icon:SetWidth(28)
|
|
bar.icon:SetHeight(28)
|
|
bar.icon:SetBackdrop({
|
|
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
|
tile = true, tileSize = 8, edgeSize = 12,
|
|
insets = { left = 2, right = 2, top = 2, bottom = 2 }
|
|
})
|
|
if ShaguTweaks.DarkMode then
|
|
bar.icon:SetBackdropBorderColor(.3, .3, .3, .9)
|
|
end
|
|
|
|
bar.icon.texture = bar.icon:CreateTexture(nil, "BACKGROUND")
|
|
bar.icon.texture:SetPoint("CENTER", 0, 0)
|
|
bar.icon.texture:SetWidth(24)
|
|
bar.icon.texture:SetHeight(24)
|
|
bar.icon.texture:SetTexCoord(.08, .92, .08, .92)
|
|
|
|
bar.key = key
|
|
bar.slot = SLOTS[key]
|
|
bar.swingType = _G.Enum.PlayerSwingType[key == "mainhand" and "MainHand" or key == "offhand" and "OffHand" or "Ranged"]
|
|
bar.speed, bar.timer, bar.max = 0, 0, 1
|
|
|
|
return bar
|
|
end
|
|
|
|
-- Hold Ctrl+Shift+Alt to show every enabled bar and drag them as one.
|
|
local function SetPreview(on)
|
|
if S.preview == on then return end
|
|
S.preview = on
|
|
|
|
S.container:EnableMouse(on)
|
|
for i = 1, 3 do
|
|
local bar = S.bars[ORDER[i]]
|
|
if on and Wanted(bar) then
|
|
UpdateIcon(bar)
|
|
local r, g, b = unpack(COLORS[bar.key])
|
|
SetProgress(bar, 1, r, g, b)
|
|
bar.text:SetText(PREVIEW[bar.key])
|
|
bar.lastText = nil
|
|
bar:Show()
|
|
elseif not bar.active then
|
|
bar:Hide()
|
|
end
|
|
end
|
|
|
|
if on then
|
|
S.container:SetHeight(HEIGHT + 2 * SPACING)
|
|
else
|
|
S.container:StopMovingOrSizing()
|
|
S.container:SetHeight(HEIGHT)
|
|
end
|
|
Layout()
|
|
end
|
|
|
|
local function RestorePosition()
|
|
local pos = ShaguTweaks_config and ShaguTweaks_config["SwingTimerPosition"]
|
|
S.container:ClearAllPoints()
|
|
if pos and pos[1] and pos[2] then
|
|
S.container:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", pos[1], pos[2])
|
|
else
|
|
-- default: stacked just above the cast bar
|
|
S.container:SetPoint("BOTTOM", CastingBarFrame, "TOP", 0, 20)
|
|
end
|
|
end
|
|
|
|
local function CreateCore()
|
|
if S.container then return true end
|
|
if not Supported() then return end
|
|
|
|
S.container = CreateFrame("Frame", "ShaguTweaksSwingTimer", UIParent)
|
|
S.container:SetWidth(WIDTH)
|
|
S.container:SetHeight(HEIGHT)
|
|
S.container:SetMovable(true)
|
|
S.container:SetClampedToScreen(true)
|
|
S.container:EnableMouse(false)
|
|
S.container:SetScript("OnMouseDown", function()
|
|
if arg1 == "LeftButton" then this:StartMoving() end
|
|
end)
|
|
S.container:SetScript("OnMouseUp", function()
|
|
this:StopMovingOrSizing()
|
|
if arg1 == "RightButton" then
|
|
ShaguTweaks_config["SwingTimerPosition"] = nil
|
|
else
|
|
ShaguTweaks_config["SwingTimerPosition"] = { this:GetLeft(), this:GetBottom() }
|
|
end
|
|
RestorePosition()
|
|
end)
|
|
|
|
for i = 1, 3 do S.bars[ORDER[i]] = CreateBar(ORDER[i]) end
|
|
|
|
S.driver = CreateFrame("Frame", nil, UIParent)
|
|
S.driver:Hide()
|
|
S.driver:SetScript("OnUpdate", OnUpdate)
|
|
|
|
S.events = CreateFrame("Frame")
|
|
S.events:SetScript("OnEvent", function()
|
|
if event == "PLAYER_SWING" then
|
|
local swingType = _G.Enum.PlayerSwingType
|
|
if arg2 == swingType.MainHand then
|
|
StartBar(S.bars.mainhand, arg1)
|
|
elseif arg2 == swingType.OffHand then
|
|
StartBar(S.bars.offhand, arg1)
|
|
elseif arg2 == swingType.Ranged then
|
|
StartBar(S.bars.ranged, arg1)
|
|
end
|
|
|
|
elseif event == "PLAYER_SWING_RANGE_UPDATE" then
|
|
-- arg1 swing type, arg2 in range, arg3 a check was possible
|
|
S.far[arg1] = arg3 and not arg2
|
|
|
|
elseif event == "PLAYER_TARGET_CHANGED" then
|
|
for i = 1, 3 do
|
|
local swingType = S.bars[ORDER[i]].swingType
|
|
S.far[swingType] = _G.C_SwingTimer.IsTargetWithinSwingRange(swingType) == false
|
|
end
|
|
|
|
elseif event == "PLAYER_ENTER_COMBAT" or event == "START_AUTOATTACK" then
|
|
S.autoattack = true
|
|
elseif event == "PLAYER_LEAVE_COMBAT" or event == "STOP_AUTOATTACK" then
|
|
S.autoattack = false
|
|
elseif event == "START_AUTOREPEAT_SPELL" then
|
|
S.autorepeat = true
|
|
elseif event == "STOP_AUTOREPEAT_SPELL" then
|
|
S.autorepeat = false
|
|
elseif event == "PLAYER_REGEN_DISABLED" then
|
|
S.combat = true
|
|
elseif event == "PLAYER_REGEN_ENABLED" then
|
|
S.combat = false
|
|
|
|
elseif event == "PLAYER_EQUIPMENT_CHANGED" or event == "UNIT_INVENTORY_CHANGED" then
|
|
if event == "PLAYER_EQUIPMENT_CHANGED" and arg1 ~= 16 and arg1 ~= 17 and arg1 ~= 18 then return end
|
|
UpdateSpeeds()
|
|
for i = 1, 3 do
|
|
local bar = S.bars[ORDER[i]]
|
|
UpdateIcon(bar)
|
|
if bar.active and bar.speed <= 0 then StopBar(bar) end
|
|
end
|
|
Layout()
|
|
|
|
elseif event == "MODIFIER_STATE_CHANGED" then
|
|
SetPreview(ModifiersDown() and true or false)
|
|
|
|
elseif event == "PLAYER_DEAD" then
|
|
ResetAll()
|
|
|
|
elseif event == "PLAYER_ENTERING_WORLD" then
|
|
local _, class = UnitClass("player")
|
|
S.hunter = class == "HUNTER"
|
|
UpdateSpeeds()
|
|
RestorePosition()
|
|
end
|
|
end)
|
|
|
|
local events = {
|
|
"PLAYER_SWING", "PLAYER_SWING_RANGE_UPDATE", "PLAYER_TARGET_CHANGED",
|
|
"PLAYER_ENTER_COMBAT", "PLAYER_LEAVE_COMBAT",
|
|
"START_AUTOREPEAT_SPELL", "STOP_AUTOREPEAT_SPELL",
|
|
"PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED",
|
|
"PLAYER_DEAD", "PLAYER_ENTERING_WORLD", "MODIFIER_STATE_CHANGED",
|
|
-- ClassicAPI v1.18.14+; the 1.12 combat events above cover older versions
|
|
"START_AUTOATTACK", "STOP_AUTOATTACK",
|
|
}
|
|
local utils = _G.C_EventUtils
|
|
for i = 1, table.getn(events) do
|
|
if not utils or not utils.IsEventValid or utils.IsEventValid(events[i]) then
|
|
S.events:RegisterEvent(events[i])
|
|
end
|
|
end
|
|
|
|
-- ClassicAPI's per-slot equipment event when present, else the 1.12 one
|
|
if utils and utils.IsEventValid and utils.IsEventValid("PLAYER_EQUIPMENT_CHANGED") then
|
|
S.events:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
|
|
else
|
|
S.events:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
|
end
|
|
|
|
local _, class = UnitClass("player")
|
|
S.hunter = class == "HUNTER"
|
|
S.combat = UnitAffectingCombat("player") and true or false
|
|
UpdateSpeeds()
|
|
RestorePosition()
|
|
return true
|
|
end
|
|
|
|
-- The range event fires only on a change, so read the state once.
|
|
local function EnableRangeCheck(bar)
|
|
_G.C_SwingTimer.EnableRangeCheck(bar.swingType, true)
|
|
S.far[bar.swingType] = _G.C_SwingTimer.IsTargetWithinSwingRange(bar.swingType) == false
|
|
end
|
|
|
|
swing.enable = function(self)
|
|
if not CreateCore() then return end
|
|
S.melee = true
|
|
EnableRangeCheck(S.bars.mainhand)
|
|
EnableRangeCheck(S.bars.offhand)
|
|
end
|
|
|
|
wand.enable = function(self)
|
|
if not CreateCore() then return end
|
|
S.ranged = true
|
|
EnableRangeCheck(S.bars.ranged)
|
|
end
|