From 1c77d5d50b047112250ec31468fecade48f40b40 Mon Sep 17 00:00:00 2001 From: praxis <889+praxis@noreply.octowow.st> Date: Sun, 13 Sep 2026 09:51:17 +0000 Subject: [PATCH] Add modules/castbar.lua --- modules/castbar.lua | 435 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 modules/castbar.lua diff --git a/modules/castbar.lua b/modules/castbar.lua new file mode 100644 index 0000000..1d7dcb9 --- /dev/null +++ b/modules/castbar.lua @@ -0,0 +1,435 @@ +pfUI:RegisterModule("castbar", "vanilla", function () + + local font = C.castbar.use_unitfonts == "1" and pfUI.font_unit or pfUI.font_default + local font_size = C.castbar.use_unitfonts == "1" and C.global.font_unit_size or C.global.font_size + local rawborder, default_border = GetBorderSize("unitframes") + local cbtexture = pfUI.media[C.appearance.castbar.texture] + + -- Helper function for castbar timer formatting + local function FormatCastbarTime(value) + if C.unitframes.castbardecimals == "1" then + -- 1 decimal, round half up (matches Blizzard spellbook display) + return string.format("%.1f", floor(value * 10 + 0.5) / 10) + else + -- 2 decimals (default) + return string.format("%.2f", value) + end + end + + local function CreateCastbar(name, parent, unitstr, unitname) + local cb = CreateFrame("Frame", name, parent or UIParent) + + cb:SetHeight(C.global.font_size * 1.5) + cb:SetFrameStrata("MEDIUM") + cb:SetFrameLevel(8) + + cb.unitstr = unitstr + cb.unitname = unitname + + -- icon + cb.icon = CreateFrame("Frame", nil, cb) + cb.icon:SetPoint("TOPLEFT", 0, 0) + cb.icon:SetHeight(16) + cb.icon:SetWidth(16) + + cb.icon.texture = cb.icon:CreateTexture(nil, "OVERLAY") + cb.icon.texture:SetAllPoints() + cb.icon.texture:SetTexCoord(.08, .92, .08, .92) + CreateBackdrop(cb.icon, default_border) + + -- statusbar + cb.bar = CreateFrame("StatusBar", nil, cb) + cb.bar:SetStatusBarTexture(cbtexture) + cb.bar:ClearAllPoints() + cb.bar:SetAllPoints(cb) + cb.bar:SetMinMaxValues(0, 100) + cb.bar:SetValue(20) + local r,g,b,a = strsplit(",", C.appearance.castbar.castbarcolor) + cb.bar:SetStatusBarColor(r,g,b,a) + CreateBackdrop(cb.bar, default_border) + CreateBackdropShadow(cb.bar) + + -- text left + cb.bar.left = cb.bar:CreateFontString("Status", "DIALOG", "GameFontNormal") + cb.bar.left:ClearAllPoints() + cb.bar.left:SetPoint("TOPLEFT", cb.bar, "TOPLEFT", 3 + C.castbar[unitstr].txtleftoffx, C.castbar[unitstr].txtleftoffy) + cb.bar.left:SetPoint("BOTTOMRIGHT", cb.bar, "BOTTOMRIGHT", -3 + C.castbar[unitstr].txtleftoffx, C.castbar[unitstr].txtleftoffy) + cb.bar.left:SetNonSpaceWrap(false) + cb.bar.left:SetFontObject(GameFontWhite) + cb.bar.left:SetTextColor(1,1,1,1) + cb.bar.left:SetFont(font, font_size, "OUTLINE") + cb.bar.left:SetJustifyH("left") + + -- text right + cb.bar.right = cb.bar:CreateFontString("Status", "DIALOG", "GameFontNormal") + cb.bar.right:ClearAllPoints() + cb.bar.right:SetPoint("TOPLEFT", cb.bar, "TOPLEFT", 3 + C.castbar[unitstr].txtrightoffx, C.castbar[unitstr].txtrightoffy) + cb.bar.right:SetPoint("BOTTOMRIGHT", cb.bar, "BOTTOMRIGHT", -3 + C.castbar[unitstr].txtrightoffx, C.castbar[unitstr].txtrightoffy) + cb.bar.right:SetNonSpaceWrap(false) + cb.bar.right:SetFontObject(GameFontWhite) + cb.bar.right:SetTextColor(1,1,1,1) + cb.bar.right:SetFont(font, font_size, "OUTLINE") + cb.bar.right:SetJustifyH("right") + + cb.bar.lag = cb.bar:CreateTexture(nil, "OVERLAY") + cb.bar.lag:SetPoint("TOPRIGHT", cb.bar, "TOPRIGHT", 0, 0) + cb.bar.lag:SetPoint("BOTTOMRIGHT", cb.bar, "BOTTOMRIGHT", 0, 0) + cb.bar.lag:SetTexture(1,.2,.2,.2) + + -- OnUpdate script with throttle for performance optimization + cb:SetScript("OnUpdate", function() + -- Throttle for performance + if (this.tick or 0) > GetTime() then return end + this.tick = GetTime() + 0.020 -- ~50 FPS for smooth castbar + + if this.drag and this.drag:IsShown() then + this:SetAlpha(1) + return + end + + if not UnitExists(this.unitstr) then + this:SetAlpha(0) + end + + if this.fadeout and this:GetAlpha() > 0 then + if this:GetAlpha() == 0 then + this.fadeout = nil + end + + this:SetAlpha(this:GetAlpha()-0.05) + end + + local channel = nil + local query = this.unitstr ~= "" and this.unitstr or this.unitname + if not query then return end + + -- Check if we have a GUID-based focus (Turtle WoW native GUID) + local focusGuid = nil + if this.unitstr and string.find(this.unitstr, "^0x") then + focusGuid = this.unitstr + elseif this.unitstr and this.unitstr == "player" and GetUnitGUID then + focusGuid = GetUnitGUID("player") + elseif this.unitstr and this.unitstr ~= "player" then + local guid = GetUnitGUID(this.unitstr) + if guid then focusGuid = guid end + end + this.focusGuid = focusGuid + + -- Try libdebuff_casts first for GUID-based units (works with Turtle GUID + Nampower events) + local cast, nameSubtext, text, texture, startTime, endTime + local castBlocked = false + if focusGuid and pfUI.libdebuff_casts and pfUI.libdebuff_casts[focusGuid] then + local castData = pfUI.libdebuff_casts[focusGuid] + if castData.event == "CAST" or castData.event == "FAIL" then + castBlocked = true + pfUI.libdebuff_casts[focusGuid] = nil + elseif (castData.event == "START" or castData.event == "CHANNEL") and castData.endTime and castData.endTime > GetTime() then + cast = castData.spellName + texture = castData.icon + startTime = castData.startTime * 1000 + endTime = castData.endTime * 1000 + -- Try to get rank from spell DB via spellID (nameSubtext is not stored in libdebuff_casts) + if castData.spellID and GetSpellRecField then + nameSubtext = GetSpellRecField(castData.spellID, "rank") or "" + else + nameSubtext = "" + end + if castData.event == "CHANNEL" then + channel = cast + end + end + end + + local useLibcastForPlayer = this.unitstr == "player" + + -- For player: use player name to query libcast.db directly + if not cast and useLibcastForPlayer then + query = UnitName("player") + end + + -- Fallback: pfGetCastInfo only when no focusGuid (Nampower not available for this unit). + -- If we have a focusGuid, libdebuff is authoritative - don't fall back to libcast + -- even if no cast is active (prevents false positives e.g. spellbook clicks on CD spells). + if not cast and not castBlocked and not focusGuid and pfGetCastInfo then + cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill = pfGetCastInfo(query) + end + + if not cast and not castBlocked and not focusGuid and pfGetChannelInfo then + channel, nameSubtext, text, texture, startTime, endTime, isTradeSkill = pfGetChannelInfo(this.unitstr or this.unitname) + cast = channel + end + + if cast then + local duration = endTime - startTime + local max = duration / 1000 + local cur = GetTime() - startTime / 1000 + + this:SetAlpha(1) + + local spellname = this.showname and cast and cast .. " " or "" + local rank = this.showrank and nameSubtext and nameSubtext ~= "" and string.format("|cffaaffcc[%s]|r", nameSubtext) or "" + + if this.endTime ~= endTime then + this.bar:SetStatusBarColor(strsplit(",", C.appearance.castbar[(channel and "channelcolor" or "castbarcolor")])) + this.bar.left:SetText(spellname .. rank) + this.fadeout = nil + this.endTime = endTime + + -- set texture + if texture and this.showicon then + local size = this:GetHeight() + this.icon:Show() + this.icon:SetHeight(size) + this.icon:SetWidth(size) + + -- Override with item icon from libdebuff_casts or persistent item icon cache + local useTexture = texture + local useItemName = nil + if pfUI.libdebuff_casts or pfUI.libdebuff_item_icons then + local castGuid = nil + if this.unitstr and UnitExists then + local guid = GetUnitGUID(this.unitstr) + castGuid = guid + end + if castGuid then + -- First check active cast data + if pfUI.libdebuff_casts and pfUI.libdebuff_casts[castGuid] and pfUI.libdebuff_casts[castGuid].itemID then + useTexture = pfUI.libdebuff_casts[castGuid].icon or texture + -- Fallback to persistent item icon cache + elseif pfUI.libdebuff_item_icons and pfUI.libdebuff_item_icons[castGuid] then + useTexture = pfUI.libdebuff_item_icons[castGuid].icon or texture + useItemName = pfUI.libdebuff_item_icons[castGuid].name + end + end + end + + this.icon.texture:SetTexture(useTexture) + this.bar:SetPoint("TOPLEFT", this.icon, "TOPRIGHT", this.spacing, 0) + + -- Override spell name with item name for item-triggered casts + if useItemName and this.showname then + this.bar.left:SetText(useItemName .. " " .. rank) + end + else + this.bar:SetPoint("TOPLEFT", this, 0, 0) + this.icon:Hide() + end + + if this.showlag then + local _, _, lag = GetNetStats() + local width = this:GetWidth() / (duration/1000) * (lag/1000) + this.bar.lag:SetWidth(math.min(this:GetWidth(), width)) + else + this.bar.lag:Hide() + end + end + + local newMax = duration / 1000 + if this.lastMax ~= newMax then + this.bar:SetMinMaxValues(0, newMax) + this.lastMax = newMax + end + + if channel then + cur = max + startTime/1000 - GetTime() + end + + cur = cur > max and max or cur + cur = cur < 0 and 0 or cur + + this.bar:SetValue(cur) + + if this.showtimer then + if this.delay and this.delay > 0 then + local delay = "|cffffaaaa" .. (channel and "-" or "+") .. FormatCastbarTime(this.delay) .. " |r " + this.bar.right:SetText(delay .. FormatCastbarTime(cur) .. " / " .. FormatCastbarTime(max)) + else + this.bar.right:SetText(FormatCastbarTime(cur) .. " / " .. FormatCastbarTime(max)) + end + end + + this.fadeout = nil + else + this.bar:SetMinMaxValues(1,100) + this.bar:SetValue(100) + this.lastMax = nil + this.fadeout = 1 + this.delay = 0 + this.itemIconApplied = nil + end + end) + + -- register for spell delay + -- Prefer Nampower's SPELL_DELAYED_SELF (gives casterGuid + delayMs directly). + -- Fall back to vanilla SPELLCAST_DELAYED if Nampower is not available. + local playerarg = nil + local function ApplyPushback(delayMs) + if not delayMs or delayMs <= 0 or not this.endTime then return end + this.delay = (this.delay or 0) + delayMs / 1000 + this.endTime = this.endTime + delayMs + local focusGuid = this.focusGuid + if focusGuid and pfUI.libdebuff_casts and pfUI.libdebuff_casts[focusGuid] then + pfUI.libdebuff_casts[focusGuid].endTime = this.endTime / 1000 + end + end + + cb:RegisterEvent("SPELL_DELAYED_SELF") + cb:RegisterEvent(CASTBAR_EVENT_CAST_DELAY) + cb:RegisterEvent(CASTBAR_EVENT_CHANNEL_DELAY) + cb:RegisterEvent(CASTBAR_EVENT_CAST_START) + cb:RegisterEvent(CASTBAR_EVENT_CHANNEL_START) + cb:SetScript("OnEvent", function() + if this.unitstr and not UnitIsUnit(this.unitstr, "player") then return end + + if event == "SPELL_DELAYED_SELF" then + -- arg1=casterGuid, arg2=delayMs (Nampower, most accurate) + ApplyPushback(arg2) + + elseif event == CASTBAR_EVENT_CAST_DELAY then + -- SPELLCAST_DELAYED fallback intentionally removed - addon requires Nampower. + -- Cast pushback is handled by SPELL_DELAYED_SELF above. + return + + elseif event == CASTBAR_EVENT_CHANNEL_DELAY then + -- SPELLCAST_CHANNEL_UPDATE fires when a channel is pushed back by damage. + -- arg1 = new remaining time in ms. Channel ends sooner = newEndTime < this.endTime. + if not this.endTime or not arg1 then return end + local newEndTime = GetTime() * 1000 + arg1 + local diff = this.endTime - newEndTime -- positive = time lost to pushback + if diff > 50 then + this.delay = (this.delay or 0) + diff / 1000 + this.endTime = newEndTime + local focusGuid = this.focusGuid + if focusGuid and pfUI.libdebuff_casts and pfUI.libdebuff_casts[focusGuid] then + pfUI.libdebuff_casts[focusGuid].endTime = newEndTime / 1000 + end + end + + elseif event == CASTBAR_EVENT_CAST_START or event == CASTBAR_EVENT_CHANNEL_START then + playerarg = pfUI.client <= 11200 or arg1 == "player" and true or nil + if playerarg then this.delay = 0 end + end + end) + + cb:SetAlpha(0) + return cb + end + + pfUI.castbar = CreateFrame("Frame", "pfCastBar", UIParent) + + -- hide blizzard + if C.castbar.player.hide_blizz == "1" then + CastingBarFrame:SetScript("OnShow", function() CastingBarFrame:Hide() end) + CastingBarFrame:UnregisterAllEvents() + CastingBarFrame:Hide() + end + + -- [[ pfPlayerCastbar ]] -- + if C.castbar.player.hide_pfui == "0" then + pfUI.castbar.player = CreateCastbar("pfPlayerCastbar", UIParent, "player") + pfUI.castbar.player.showicon = C.castbar.player.showicon == "1" and true or nil + pfUI.castbar.player.showname = C.castbar.player.showname == "1" and true or nil + pfUI.castbar.player.showtimer = C.castbar.player.showtimer == "1" and true or nil + pfUI.castbar.player.showlag = C.castbar.player.showlag == "1" and true or nil + pfUI.castbar.player.showrank = C.castbar.player.showrank == "1" and true or nil + pfUI.castbar.player.spacing = default_border * 2 + tonumber(C.unitframes.player.pspace) * GetPerfectPixel() + + if pfUI.uf.player then + local anchor = pfUI.uf.player.portrait:GetHeight() > pfUI.uf.player:GetHeight() and pfUI.uf.player.power or pfUI.uf.player + local width = C.castbar.player.width ~= "-1" and C.castbar.player.width or anchor:GetWidth() + pfUI.castbar.player:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -pfUI.castbar.player.spacing) + pfUI.castbar.player:SetWidth(width) + else + local width = C.castbar.player.width ~= "-1" and C.castbar.player.width or 200 + pfUI.castbar.player:SetPoint("CENTER", 0, -200) + pfUI.castbar.player:SetWidth(width) + end + + if C.castbar.player.height ~= "-1" then + pfUI.castbar.player:SetHeight(C.castbar.player.height) + end + + UpdateMovable(pfUI.castbar.player) + end + + -- [[ pfTargetCastbar ]] -- + if C.castbar.target.hide_pfui == "0" then + pfUI.castbar.target = CreateCastbar("pfTargetCastbar", UIParent, "target") + pfUI.castbar.target.showicon = C.castbar.target.showicon == "1" and true or nil + pfUI.castbar.target.showname = C.castbar.target.showname == "1" and true or nil + pfUI.castbar.target.showtimer = C.castbar.target.showtimer == "1" and true or nil + pfUI.castbar.target.showlag = C.castbar.target.showlag == "1" and true or nil + pfUI.castbar.target.showrank = C.castbar.target.showrank == "1" and true or nil + pfUI.castbar.target.spacing = default_border * 2 + tonumber(C.unitframes.target.pspace) * GetPerfectPixel() + + if pfUI.uf.target then + local anchor = pfUI.uf.target.portrait:GetHeight() > pfUI.uf.target:GetHeight() and pfUI.uf.target.power or pfUI.uf.target + local width = C.castbar.target.width ~= "-1" and C.castbar.target.width or anchor:GetWidth() + pfUI.castbar.target:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -pfUI.castbar.target.spacing) + pfUI.castbar.target:SetWidth(width) + else + local width = C.castbar.target.width ~= "-1" and C.castbar.target.width or 200 + pfUI.castbar.target:SetPoint("CENTER", 0, -225) + pfUI.castbar.target:SetWidth(width) + end + + if C.castbar.target.height ~= "-1" then + pfUI.castbar.target:SetHeight(C.castbar.target.height) + end + + UpdateMovable(pfUI.castbar.target) + end + + -- [[ pfFocusCastbar ]] -- + if C.castbar.focus.hide_pfui == "0" and pfUI.uf.focus then + pfUI.castbar.focus = CreateCastbar("pfFocusCastbar", UIParent, "focus") + pfUI.castbar.focus.showicon = C.castbar.focus.showicon == "1" and true or nil + pfUI.castbar.focus.showname = C.castbar.focus.showname == "1" and true or nil + pfUI.castbar.focus.showtimer = C.castbar.focus.showtimer == "1" and true or nil + pfUI.castbar.focus.showlag = C.castbar.focus.showlag == "1" and true or nil + pfUI.castbar.focus.showrank = C.castbar.focus.showrank == "1" and true or nil + pfUI.castbar.focus.spacing = default_border * 2 + tonumber(C.unitframes.focus.pspace) * GetPerfectPixel() + + -- reset unitstr for vanilla focus frame emulation + if pfUI.client <= 11200 then + pfUI.castbar.focus.unitstr = nil + end + + local anchor = pfUI.uf.focus.portrait:GetHeight() > pfUI.uf.focus:GetHeight() and pfUI.uf.focus.power or pfUI.uf.focus + local width = C.castbar.focus.width ~= "-1" and C.castbar.focus.width or anchor:GetWidth() + pfUI.castbar.focus:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -pfUI.castbar.focus.spacing) + pfUI.castbar.focus:SetWidth(width) + + if C.castbar.focus.height ~= "-1" then + pfUI.castbar.focus:SetHeight(C.castbar.focus.height) + end + + -- keep unit values in sync with focus unitframe + HookScript(pfUI.castbar.focus, "OnUpdate", function() + -- clear on empty focus frames + if pfUI.uf.focus.unitname == nil and pfUI.uf.focus.label == nil then + pfUI.castbar.focus.unitstr = nil + pfUI.castbar.focus.unitname = nil + return + end + + -- skip on initial values + if pfUI.uf.focus.unitname == "focus" then return end + if pfUI.uf.focus.unitname == "" then return end + + -- try to obtain a unitstr + pfUI.castbar.focus.unitstr = string.format("%s%s", (pfUI.uf.focus.label or ""), (pfUI.uf.focus.id or "")) + pfUI.castbar.focus.unitstr = pfUI.castbar.focus.unitstr == "" and nil or pfUI.castbar.focus.unitstr + + if pfUI.castbar.focus.unitstr then + -- read non-lowercase unitname when possible + pfUI.castbar.focus.unitname = UnitName(pfUI.castbar.focus.unitstr) or pfUI.castbar.focus.unitname + elseif strlower(pfUI.castbar.focus.unitname) ~= strlower(pfUI.uf.focus.unitname) then + -- sync unitname with focus frame's lowercase value + pfUI.castbar.focus.unitname = pfUI.uf.focus.unitname + end + end) + + UpdateMovable(pfUI.castbar.focus) + end +end) \ No newline at end of file