diff --git a/HealerProtection.toc b/HealerProtection.toc index e139df7..b5be96d 100644 --- a/HealerProtection.toc +++ b/HealerProtection.toc @@ -1,10 +1,8 @@ ## Interface: 11200 ## Title: HealerProtection -## Notes: Alert your group when you run out of mana, take aggro, or are about to die! Designed for OctoWow. +## Notes: Lightweight healer alerts for Turtle WoW 1.12.1: mana, health, death and aggro. ## Author: Dusk92 -## Version: 1.0-OctoWow +## Version: 1.1 ## SavedVariablesPerCharacter: HPTABPC -## OptionalDeps: SuperWow, ClassicAPI, NamPower -# Fichiers de l'addon -core.lua \ No newline at end of file +core.lua diff --git a/core.lua b/core.lua index 6e0bfdd..07e79c3 100644 --- a/core.lua +++ b/core.lua @@ -1,744 +1,786 @@ --- HealerProtection Vanilla / Turtle WoW Port --- Ported for the 1.12.1 client (with SuperWow / ClassicAPI support) --- Optimized version: cached lookups, removed unused temp table, fixed NEARDEATH bug, removed role detection (Force Healer bypass always on) --- Optimization pass 2: SETOOMP getglobal only runs when actually needed, GetLang()/GetLocale() cached --- in ToCurrentChat instead of being recomputed up to 5 times, IsPlayerInBG() cache shared with CanWriteToChat +-- HealerProtection v1.1 +-- Turtle WoW / Vanilla 1.12.1 +-- +-- Goals of this version: +-- * Native Vanilla-safe event handling (Lua 5.0 / 1.12 style globals). +-- * No hard dependency on ClassicAPI, SuperWoW or Nampower. +-- * Event-driven mana / health / death checks. +-- * Lightweight polling only for aggro, because Vanilla has no reliable threat event. +-- * Restored Near Death alerts. +-- * Centralized chat/channel validation. +-- * Removed dead bootstrap, translation and combat-lockdown code. HealerProtection = HealerProtection or {} -local HPDEBUG = false -local warning_aggro = nil -local nearoom = false -local oom = false -local aggro = false -local isdead = false -local isChanneling = false -local onceM1 = false -local onceM2 = false -local lasttarget = "" -local delayrange = GetTime() -local outsideOfGroup = false + +local HP = HealerProtection +local DB = nil + +local state = { + oom = false, + nearoom = false, + neardeath = false, + dead = false, + aggro = false, + channeling = false, +} + +local warningAggro = nil +local lastLosTarget = "" +local nextLosMessage = 0 + +local DEFAULTS = { + printnothing = false, + showinraids = true, + showoutsideofinstance = false, + showinbgs = false, + + AGGRO = false, + showaggrochat = true, + showaggroemote = true, + + deathmessage = true, + + OOM = true, + showoomchat = true, + showoomemote = true, + OOMPercentage = 10, + + NEAROOM = true, + shownearoomchat = true, + shownearoomemote = true, + NEAROOMPercentage = 30, + + NEARDEATH = true, + showneardeathchat = true, + showneardeathemote = true, + NEARDEATHPercentage = 30, + + notinsight = false, + + channelchat = "AUTO", + prefix = "[Healer Protection]", + suffix = "", +} + +local TEXT = { + aggro_warning = "You have aggro!", + aggro_chat = "Aggro on me! Get it off!", + oom = "I am OOM!", + low_mana = "Low Mana!", + low_health = "Help, I'm dying!", + dead = "Healer is down... Fly, you fools!", + los = "Target is not in line of sight", +} -- ============================================================================ --- UTILS AND VANILLA 1.12 COMPATIBILITY +-- SMALL HELPERS -- ============================================================================ --- Cleanly retrieves the number of group members -local function GetGroupMembersCount() -if GetNumGroupMembers then return GetNumGroupMembers() end -local raid = GetNumRaidMembers() -if raid > 0 then return raid end -return GetNumPartyMembers() +local function Clamp(value, low, high) + value = tonumber(value) or low + if value < low then return low end + if value > high then return high end + return value end --- Checks if the player is in a battleground -local function IsPlayerInBG() -for i = 1, (MAX_BATTLEFIELD_QUEUES or 3) do -local status = GetBattlefieldStatus(i) -if status == "active" then -return true -end -end -return false +local function GetRaidCount() + if GetNumRaidMembers then + return GetNumRaidMembers() or 0 + end + return 0 end --- Mana resource abstraction -local function GetUnitPower(unit) -if UnitPower then return UnitPower(unit) end -return UnitMana(unit) +local function GetPartyCount() + if GetNumPartyMembers then + return GetNumPartyMembers() or 0 + end + return 0 end -local function GetUnitPowerMax(unit) -if UnitPowerMax then return UnitPowerMax(unit) end -return UnitManaMax(unit) +local function IsInBattleground() + if not GetBattlefieldStatus then + return false + end + + local maxQueues = MAX_BATTLEFIELD_QUEUES or 3 + local i + for i = 1, maxQueues do + local status = GetBattlefieldStatus(i) + if status == "active" then + return true + end + end + return false end -local function GetUnitPowerType(unit) -if UnitPowerType then -local _, token = UnitPowerType(unit) -return token -end -local t = UnitManaType(unit) -if t == 0 then return "MANA" end -return "OTHER" +local function IsInsideInstance() + if not IsInInstance then + -- Turtle WoW normally exposes IsInInstance(). If another 1.12 client + -- does not, fail open rather than silently disabling the addon. + return true + end + + local inside = IsInInstance() + return inside and true or false end --- Safe wrapper for C_Timer.After -local function SafeAfter(delay, func) -if C_Timer and C_Timer.After then -C_Timer.After(delay, func) -else -local fAfter = CreateFrame("Frame") -local elapsed_time = 0 -fAfter:SetScript("OnUpdate", function(self, elapsed) -local dt = elapsed or arg1 or 0 -elapsed_time = elapsed_time + dt -if elapsed_time >= delay then -fAfter:SetScript("OnUpdate", nil) -func() -end -end) -end +local function GetMana() + if UnitPower and UnitPowerMax then + return UnitPower("player") or 0, UnitPowerMax("player") or 0 + end + return UnitMana("player") or 0, UnitManaMax("player") or 0 end --- Simplified aggro detection (adapts if SuperWow is installed) -local function GetThreatSituation() -if UnitThreatSituation then -return UnitThreatSituation("player") -else -if UnitExists("target") and not UnitIsFriend("player", "target") then -if UnitIsUnit("targettarget", "player") then -return 3 +local function PlayerUsesMana() + if UnitPowerType then + local _, token = UnitPowerType("player") + return token == "MANA" + end + + if UnitManaType then + return UnitManaType("player") == 0 + end + + return true end + +local function Percent(value, maximum) + if not maximum or maximum <= 0 then + return 0 + end + return math.floor((value * 1000 / maximum) + 0.5) / 10 end -return 0 + +local function IsChannelAllowed(channel) + if channel == "SAY" or channel == "YELL" then + return true + end + + if channel == "GUILD" then + return GetGuildInfo and GetGuildInfo("player") ~= nil + end + + if channel == "RAID" then + return GetRaidCount() > 0 + end + + if channel == "PARTY" then + return GetPartyCount() > 0 or GetRaidCount() > 0 + end + + return false end + +function HP:GetCurrentChannel() + local channel = DB.channelchat or "AUTO" + + if channel ~= "AUTO" then + return channel + end + + if GetRaidCount() > 0 then + return "RAID" + end + + if GetPartyCount() > 0 then + return "PARTY" + end + + -- AUTO deliberately resolves to PARTY while solo. The centralized + -- validation below prevents invalid SendChatMessage calls. + return "PARTY" +end + +function HP:CanAnnounce(channel) + if DB.printnothing then + return false + end + + if not IsChannelAllowed(channel) then + return false + end + + if not DB.showoutsideofinstance and not IsInsideInstance() then + return false + end + + if not DB.showinbgs and IsInBattleground() then + return false + end + + if not DB.showinraids and GetRaidCount() > 0 then + return false + end + + return true +end + +local function CleanAffix(text, before) + if not text or text == "" or text == " " then + return "" + end + if before then + return text .. " " + end + return " " .. text +end + +function HP:Announce(message) + if not message or message == "" then + return false + end + + local channel = self:GetCurrentChannel() + if not self:CanAnnounce(channel) then + return false + end + + local prefix = CleanAffix(DB.prefix, true) + local suffix = CleanAffix(DB.suffix, false) + + SendChatMessage(prefix .. message .. suffix, channel) + return true +end + +local function DoSafeEmote(emote) + if not state.channeling and DoEmote then + DoEmote(emote) + end end -- ============================================================================ --- HEALERPROTECTION FUNCTIONS +-- ALERT LOGIC -- ============================================================================ --- Optimization: accepts an already computed channel to avoid recomputing GetCurrentChannel() --- on every single call (PrintChat can call this up to 6 times per tick) -function HealerProtection:AllowedTo(cachedChannel) -local channel = cachedChannel or HealerProtection:GetCurrentChannel() +function HP:CheckMana() + if not PlayerUsesMana() then + state.oom = false + state.nearoom = false + return + end -if HealerProtection:DBGV("printnothing", false) then -return false + local mana, maxMana = GetMana() + if maxMana <= 0 then + return + end + + local pct = Percent(mana, maxMana) + local oomAt = Clamp(DB.OOMPercentage, 1, 99) + local nearAt = Clamp(DB.NEAROOMPercentage, oomAt + 1, 99) + + -- OOM has priority. Mark near-OOM as already crossed so recovering from + -- OOM does not immediately spam a second "Low Mana" warning. + if DB.OOM and pct <= oomAt then + if not state.oom then + state.oom = true + state.nearoom = true + + if DB.showoomchat then + self:Announce(TEXT.oom .. " (" .. tostring(pct) .. "% Mana)") + end + if DB.showoomemote and self:CanAnnounce(self:GetCurrentChannel()) then + DoSafeEmote("oom") + end + end + elseif state.oom and pct > oomAt + 10 then + state.oom = false + end + + if DB.NEAROOM and not state.oom and pct <= nearAt and pct > oomAt then + if not state.nearoom then + state.nearoom = true + + if DB.shownearoomchat then + self:Announce(TEXT.low_mana .. " (" .. tostring(pct) .. "% Mana)") + end + if DB.shownearoomemote and self:CanAnnounce(self:GetCurrentChannel()) then + DoSafeEmote("incoming") + end + end + elseif state.nearoom and pct > nearAt + 10 then + state.nearoom = false + end end --- SAY and YELL don't require being in a party/raid/guild to make sense -if channel == "SAY" or channel == "YELL" then -return true +function HP:CheckHealth() + if UnitIsDead("player") then + return + end + + local health = UnitHealth("player") or 0 + local maxHealth = UnitHealthMax("player") or 0 + if maxHealth <= 0 then + return + end + + local pct = Percent(health, maxHealth) + local threshold = Clamp(DB.NEARDEATHPercentage, 1, 99) + + if DB.NEARDEATH and pct <= threshold then + if not state.neardeath then + state.neardeath = true + + if DB.showneardeathchat then + self:Announce(TEXT.low_health .. " (" .. tostring(pct) .. "% HP)") + end + if DB.showneardeathemote and self:CanAnnounce(self:GetCurrentChannel()) then + DoSafeEmote("helpme") + end + end + elseif state.neardeath and pct > threshold + 15 then + state.neardeath = false + end end --- GUILD requires actually being in a guild -if channel == "GUILD" then -if GetGuildInfo("player") ~= nil or HPDEBUG then -return true -end -if outsideOfGroup == false then -outsideOfGroup = true -HealerProtection:INFO("Not in a Guild") -end -return false +function HP:HandleDeath() + if state.dead then + return + end + + state.dead = true + state.neardeath = false + state.aggro = false + + if warningAggro then + warningAggro:Hide() + end + + if DB.deathmessage then + self:Announce(TEXT.dead) + end end --- RAID requires actually being in a raid (being in a simple party is not enough) -if channel == "RAID" then -if GetNumRaidMembers() > 0 or HPDEBUG then -return true -end -if outsideOfGroup == false then -outsideOfGroup = true -HealerProtection:INFO("Not in a Raid") -end -return false -end - --- PARTY (and AUTO, already resolved to PARTY/RAID by GetCurrentChannel) -if GetGroupMembersCount() > 0 or HPDEBUG then -return true -end - -if outsideOfGroup == false then -outsideOfGroup = true -HealerProtection:INFO("Not in a Party/Raid") -end - -return false -end - -function HealerProtection:GetCurrentChannel() -local _channel = "PARTY" -if HealerProtection:DBGV("channelchat", "AUTO") == "AUTO" then -if GetNumRaidMembers() > 0 then -_channel = "RAID" -elseif GetNumPartyMembers() > 0 then -_channel = "PARTY" -end -else -_channel = HealerProtection:DBGV("channelchat", "AUTO") -end - -return _channel -end - -function HealerProtection:InInstance() -if HPDEBUG then return true end -local is, _ = IsInInstance() -return is -end - --- Optimization: accepts an already computed inInstance to avoid a redundant call to InInstance() --- Optimization: accepts an already computed inBG to avoid a redundant call to IsPlayerInBG() -function HealerProtection:CanWriteToChat(chan, cachedInInstance, cachedInBG) -local inInstance = cachedInInstance -if inInstance == nil then -inInstance = HealerProtection:InInstance() -end - -local inBG = cachedInBG -if inBG == nil then -inBG = IsPlayerInBG() -end - -if onceM1 and not inInstance and HealerProtection:DBGV("showoutsideofinstance", false) == false then -onceM1 = false -HealerProtection:MSG("Only shows Messages in Instances.") -end - -if inInstance or HealerProtection:DBGV("showoutsideofinstance", false) then -if HealerProtection:DBGV("printnothing", false) == true then -if onceM2 then -onceM2 = false -HealerProtection:MSG("\"Print Nothing\" is enabled.") -end - -return false -elseif inBG and HealerProtection:DBGV("showinbgs", false) == false then -return false -elseif (GetNumRaidMembers() > 0) and HealerProtection:DBGV("showinraids", true) == false then -return false -else -return true -end -end - -return false -end - -function HealerProtection:GetLang() --- Optimization: GetLocale() cached once instead of being called up to 3 times below -local locale = GetLocale() -if locale == "enUS" then return locale end -if HealerProtection:DBGV("showonlyenglish", false) then return "enUS" end -if HealerProtection:DBGV("showonlytranslation", false) then return locale end -if HealerProtection:DBGV("showtranslation", true) then return "enUS," .. locale end - -return "enUS" -end - -function HealerProtection:ToCurrentChat(formatStr, val1text, val1val, val2text, val2val, cachedChannel, cachedInInstance, cachedInBG) -local inInstance = cachedInInstance -if inInstance == nil then -inInstance = HealerProtection:InInstance() -end - --- Optimization: computed once here and passed down to CanWriteToChat below, --- instead of letting IsPlayerInBG() run a second time inside it. -local inBG = cachedInBG -if inBG == nil then -inBG = IsPlayerInBG() -end - -local _channel = cachedChannel or HealerProtection:GetCurrentChannel() -local prefix = HealerProtection:DBGV("prefix", "[Healer Protection]") -local suffix = HealerProtection:DBGV("suffix", "") -if prefix ~= "" and prefix ~= " " then -prefix = prefix .. " " -elseif prefix == " " then -prefix = "" -end - -if suffix ~= "" and suffix ~= " " then -suffix = " " .. suffix -elseif suffix == " " then -suffix = "" -end - --- Optimization: reuse of already computed inInstance / inBG, avoids extra calls -if HealerProtection:CanWriteToChat(_channel, inInstance, inBG) then --- Optimization: GetLang() and GetLocale() cached once instead of being recomputed --- (each re-running its own DBGV / GetLocale lookups) up to 5 times in the block below -local lang = HealerProtection:GetLang() -local locale = GetLocale() -local msg = "" -if lang == "enUS" then -if val2text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, "enUS", val1val), HealerProtection:TryTrans(val2text, "enUS", val2val)) -elseif val1text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, "enUS", val1val)) -end -elseif lang == locale then -if val2text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, locale, val1val), HealerProtection:TryTrans(val2text, locale, val2val)) -elseif val1text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, locale, val1val)) -end -else -if val2text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, "enUS", val1val), HealerProtection:TryTrans(val2text, "enUS", val2val)) -elseif val1text then -msg = string.format(formatStr, HealerProtection:TryTrans(val1text, "enUS", val1val)) -end - -if val2text then -msg = msg .. " [" .. string.format(formatStr, HealerProtection:TryTrans(val1text, locale, val1val), HealerProtection:TryTrans(val2text, locale, val2val)) .. "]" -elseif val1text then -msg = msg .. " [" .. string.format(formatStr, HealerProtection:TryTrans(val1text, locale, val1val)) .. "]" -end -end - -local mes = prefix .. msg .. "." .. suffix -if mes ~= nil then -if SendChatMessage then -SendChatMessage(mes, _channel) -end -end -end -end - -function HealerProtection:Setup() -if HealerProtection:IsSetup() then -if not InCombatLockdown() then -HPTABPC = HPTABPC or {} -HealerProtection:SetSetup(false) - -warning_aggro = CreateFrame("Frame", nil, UIParent) -warning_aggro:SetFrameStrata("BACKGROUND") -warning_aggro:SetWidth(128) -warning_aggro:SetHeight(64) -warning_aggro.text = warning_aggro:CreateFontString(nil, "ARTWORK") - -local font = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" -warning_aggro.text:SetFont(font, 20, "OUTLINE") -warning_aggro.text:SetPoint("CENTER", 0, 300) -warning_aggro.text:SetText(HealerProtection:Trans("LID_youhaveaggro") .. "!") -warning_aggro.text:SetTextColor(1, 0, 0, 1) -warning_aggro:SetPoint("CENTER", 0, 0) -warning_aggro:Hide() - -local f = CreateFrame("Frame") -f:RegisterEvent("UI_ERROR_MESSAGE") -f:SetScript("OnEvent", function(self, event, a1) -local errMessage = a1 or arg1 -if event == "UI_ERROR_MESSAGE" and errMessage == SPELL_FAILED_LINE_OF_SIGHT then -local TName = UnitName("target") -if TName and UnitIsFriend("player", "target") then -if lasttarget ~= TName or delayrange < GetTime() then -delayrange = GetTime() + 1 -lasttarget = TName -if HealerProtection:DBGV("notinsight", false) then -local tex = "Target is not in the field of view (" .. TName .. ")" -if HealerProtection:DBGV("showtranslation", true) and GetLocale() ~= "enUS" then -tex = tex .. " [" .. errMessage .. " (" .. TName .. ")]" -end - -HealerProtection:ToCurrentChat("%s", tex) -end -end -end -end -end) - -local channeling = CreateFrame("Frame") -channeling:RegisterEvent("SPELLCAST_CHANNEL_START") -channeling:RegisterEvent("SPELLCAST_CHANNEL_STOP") -channeling:SetScript("OnEvent", function(self, event) -if event == "SPELLCAST_CHANNEL_START" then -isChanneling = true -elseif event == "SPELLCAST_CHANNEL_STOP" then -isChanneling = false -end -end) - -HealerProtection:SetDbTab(HPTABPC) -HealerProtection:InitSetting() - -if C_Timer and C_Timer.NewTicker then -C_Timer.NewTicker(1, function() HealerProtection:PrintChat() end) -else -local tickerFrame = CreateFrame("Frame") -local elapsed_time = 0 -tickerFrame:SetScript("OnUpdate", function(self, elapsed) -local dt = elapsed or arg1 or 0 -elapsed_time = elapsed_time + dt -if elapsed_time >= 1 then -elapsed_time = 0 -HealerProtection:PrintChat() -end -end) -end -else -SafeAfter(0.1, function() HealerProtection:Setup() end) -end -end -end - -function HealerProtection:PrintChat() --- Optimization: OOM / NEAROOM thresholds are cached once per tick -local oomPerc = HealerProtection:DBGV("OOMPercentage", 10) -local nearOomPerc = HealerProtection:DBGV("NEAROOMPercentage", 30) - --- Optimization: getglobal("SETOOMP") now only runs when the thresholds are actually --- misconfigured (oomPerc > nearOomPerc). With default settings (10 <= 30) that's false, --- so before this change the getglobal lookup ran uselessly every single second, forever. -if oomPerc > nearOomPerc then -local SETOOMP = getglobal("SETOOMP") -if SETOOMP ~= nil and not InCombatLockdown() then -HPTABPC["OOMPercentage"] = nearOomPerc -SETOOMP:SetValue(HPTABPC["OOMPercentage"]) -oomPerc = nearOomPerc -end -end - -local inInstance = HealerProtection:InInstance() -local inBG = IsPlayerInBG() -local _channel = HealerProtection:GetCurrentChannel() -if not HealerProtection:CanWriteToChat(_channel, inInstance, inBG) then return end - -if HealerProtection:IsLoaded() then --- Role detection removed: the "Force Healer" bypass is now always active, --- so there is no need to check the player's class/role here anymore. --- Note: "printnothing" is already checked inside CanWriteToChat right above, --- so if we reach this point it is guaranteed to be false -- no need to test it again. - --- Optimization: AllowedTo() only depends on the channel/group/guild/raid state, --- none of which can change mid-tick, so it's computed once and reused below --- instead of being called up to 6 times per tick (chat + emote x3 alerts). -local canAnnounce = HealerProtection:AllowedTo(_channel) - -if not UnitIsDead("player") then -isdead = false - --- AGGRO LOGIC -if HealerProtection:DBGV("AGGRO", false) then -local status = GetThreatSituation() -if status ~= nil then -if status > 0 and not aggro then -if HealerProtection:DBGV("showaggrochat", true) and canAnnounce then -HealerProtection:ToCurrentChat("{rt8} %s", "LID_ihaveaggro", nil, nil, nil, _channel, inInstance, inBG) -end - -if HealerProtection:DBGV("showaggroemote", true) and canAnnounce and not isChanneling then -DoEmote("helpme") -end - -aggro = true -elseif status == 0 and aggro then -aggro = false -end -else -aggro = false -end - -if warning_aggro then -if aggro then -warning_aggro:Show() -else -warning_aggro:Hide() -end -end -else -if warning_aggro then -warning_aggro:Hide() -end -end - --- MANA AND HEALTH LOGIC -local powerToken = GetUnitPowerType("player") -if powerToken == "MANA" then -local mana = GetUnitPower("player") -local manamax = GetUnitPowerMax("player") -local manaperc = HealerProtection:MathR((mana / manamax) * 100, 1) - --- OOM Alert (without percentage) -if HealerProtection:DBGV("OOM", true) then -if manaperc <= oomPerc and not oom then -oom = true -if HealerProtection:DBGV("showoomchat", true) and canAnnounce then -HealerProtection:ToCurrentChat("%s (%s)", "LID_outofmana", nil, "LID_xmana", manaperc, _channel, inInstance, inBG) -end - -if HealerProtection:DBGV("showoomemote", true) and canAnnounce and not isChanneling then -DoEmote("oom") -end -elseif manaperc > oomPerc + 20 and oom then -oom = false -end -end - --- Near-OOM Alert (without percentage) -if HealerProtection:DBGV("NEAROOM", true) and not oom then -if manaperc <= nearOomPerc and not nearoom then -nearoom = true -if HealerProtection:DBGV("shownearoomchat", true) and canAnnounce then -HealerProtection:ToCurrentChat("%s (%s)", "LID_nearoutofmana", nil, "LID_xmana", manaperc, _channel, inInstance, inBG) -end - -if HealerProtection:DBGV("shownearoomemote", true) and canAnnounce and not isChanneling then -DoEmote("incoming") -end -elseif manaperc > nearOomPerc + 20 and nearoom then -nearoom = false -end -end - -end -elseif not isdead then -isdead = true -if HealerProtection:DBGV("deathmessage", true) then -HealerProtection:ToCurrentChat("%s", "LID_healerisdead", nil, nil, nil, _channel, inInstance, inBG) -end -end -end +function HP:HandleAlive() + state.dead = false + self:CheckHealth() + self:CheckMana() end -- ============================================================================ --- SETTINGS WINDOW AND SLASH COMMANDS (HOMEMADE FOR 1.12) +-- AGGRO -- ============================================================================ -function HealerProtection:DBGV(key, default) -if HPTABPC and HPTABPC[key] ~= nil then -return HPTABPC[key] +local function UnitTargetsPlayer(unit) + if not UnitExists(unit) then + return false + end + if UnitIsFriend("player", unit) then + return false + end + + local targetOfTarget = unit .. "target" + return UnitExists(targetOfTarget) and UnitIsUnit(targetOfTarget, "player") end -return default + +local function GetAggroState() + -- Use a real threat API if the client/API extension supplies one. + if UnitThreatSituation then + local status = UnitThreatSituation("player") + if status and status > 0 then + return true + end + end + + -- Native Vanilla fallback: inspect the player's current hostile target. + if UnitTargetsPlayer("target") then + return true + end + + -- Improve the old target-only fallback without any dependency: + -- inspect hostile targets currently selected by party/raid members. + local raidCount = GetRaidCount() + local i + + if raidCount > 0 then + for i = 1, raidCount do + if UnitTargetsPlayer("raid" .. i .. "target") then + return true + end + end + else + local partyCount = GetPartyCount() + for i = 1, partyCount do + if UnitTargetsPlayer("party" .. i .. "target") then + return true + end + end + end + + return false +end + +function HP:CheckAggro() + if not DB.AGGRO or UnitIsDead("player") then + if state.aggro then + state.aggro = false + if warningAggro then warningAggro:Hide() end + end + return + end + + local hasAggro = GetAggroState() + + if hasAggro and not state.aggro then + state.aggro = true + + if DB.showaggrochat then + self:Announce("{rt8} " .. TEXT.aggro_chat) + end + if DB.showaggroemote and self:CanAnnounce(self:GetCurrentChannel()) then + DoSafeEmote("helpme") + end + + if warningAggro then + warningAggro:Show() + end + elseif not hasAggro and state.aggro then + state.aggro = false + if warningAggro then + warningAggro:Hide() + end + end +end + +-- ============================================================================ +-- UI / SETTINGS +-- ============================================================================ + +function HP:CreateGUI() + if HPOptionsFrame then + return HPOptionsFrame + end + + local f = CreateFrame("Frame", "HPOptionsFrame", UIParent) + f:SetWidth(430) + f:SetHeight(850) + f:SetPoint("CENTER", UIParent, "CENTER") + f:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, + tileSize = 32, + edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() f:StartMoving() end) + f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end) + f:SetFrameStrata("DIALOG") + f:Hide() + + f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + f.title:SetPoint("TOP", 0, -18) + f.title:SetText("Healer Protection - Settings") + + local btnClose = CreateFrame("Button", nil, f, "UIPanelButtonTemplate") + btnClose:SetWidth(100) + btnClose:SetHeight(24) + btnClose:SetPoint("BOTTOM", 0, 16) + btnClose:SetText("Close") + btnClose:SetScript("OnClick", function() f:Hide() end) + + local function CreateCheckbox(name, label, key, x, y) + local cb = CreateFrame("CheckButton", "HPCB_" .. name, f, "UICheckButtonTemplate") + cb:SetPoint("TOPLEFT", x, y) + getglobal(cb:GetName() .. "Text"):SetText(label) + getglobal(cb:GetName() .. "Text"):SetTextColor(1, 1, 1) + + cb:SetScript("OnShow", function() + cb:SetChecked(DB[key] and 1 or nil) + end) + + cb:SetScript("OnClick", function() + DB[key] = cb:GetChecked() == 1 + if key == "AGGRO" and not DB.AGGRO then + state.aggro = false + if warningAggro then warningAggro:Hide() end + end + end) + + return cb + end + + local function CreateSlider(name, labelFormat, key, minValue, maxValue, y) + local slider = CreateFrame("Slider", "HPSL_" .. name, f, "OptionsSliderTemplate") + slider:SetPoint("TOP", 0, y) + slider:SetWidth(330) + slider:SetHeight(16) + slider:SetOrientation("HORIZONTAL") + slider:SetMinMaxValues(minValue, maxValue) + slider:SetValueStep(1) + + getglobal(slider:GetName() .. "Low"):SetText(tostring(minValue)) + getglobal(slider:GetName() .. "High"):SetText(tostring(maxValue)) + + local label = getglobal(slider:GetName() .. "Text") + + local function UpdateLabel(value) + label:SetText(string.format(labelFormat, value)) + end + + slider:SetScript("OnShow", function() + slider:SetValue(DB[key]) + UpdateLabel(DB[key]) + end) + + slider:SetScript("OnValueChanged", function() + local value = arg1 + if value == nil then value = slider:GetValue() end + value = math.floor(value + 0.5) + DB[key] = value + UpdateLabel(value) + end) + + return slider + end + + local function CreateEditBox(labelText, globalName, x, y, width, key) + local label = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("TOPLEFT", x, y) + label:SetText(labelText) + + local eb = CreateFrame("EditBox", globalName, f, "InputBoxTemplate") + eb:SetWidth(width) + eb:SetHeight(20) + eb:SetPoint("TOPLEFT", x + 4, y - 18) + eb:SetAutoFocus(false) + + eb:SetScript("OnShow", function() + eb:SetText(DB[key] or "") + end) + + local function Save() + DB[key] = eb:GetText() or "" + end + + eb:SetScript("OnEnterPressed", function() + Save() + eb:ClearFocus() + end) + eb:SetScript("OnEditFocusLost", Save) + + return eb + end + + local function CreateChannelDropdown(x, y) + local label = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("TOPLEFT", x, y) + label:SetText("Announce Channel") + + local dropdown = CreateFrame("Frame", "HPChannelDropDown", f, "UIDropDownMenuTemplate") + dropdown:SetPoint("TOPLEFT", x - 16, y - 18) + UIDropDownMenu_SetWidth(120, dropdown) + + local channels = { "AUTO", "SAY", "YELL", "PARTY", "RAID", "GUILD" } + + local function OnClick() + DB.channelchat = this.value + UIDropDownMenu_SetSelectedValue(dropdown, this.value) + UIDropDownMenu_SetText(this.value, dropdown) + end + + local function Initialize() + local i + for i = 1, table.getn(channels) do + local info = {} + info.text = channels[i] + info.value = channels[i] + info.func = OnClick + UIDropDownMenu_AddButton(info) + end + end + + UIDropDownMenu_Initialize(dropdown, Initialize) + UIDropDownMenu_SetSelectedValue(dropdown, DB.channelchat) + UIDropDownMenu_SetText(DB.channelchat, dropdown) + + return dropdown + end + + CreateCheckbox("PrintNothing", "Print Nothing", "printnothing", 20, -45) + CreateCheckbox("ShowInRaid", "Show in Raids", "showinraids", 20, -70) + CreateCheckbox("Outside", "Show outside instances", "showoutsideofinstance", 20, -95) + CreateCheckbox("ShowInBG", "Show in Battlegrounds", "showinbgs", 20, -120) + CreateCheckbox("LineOfSight", "Line-of-sight chat warning", "notinsight", 20, -145) + + CreateCheckbox("Aggro", "AGGRO", "AGGRO", 20, -180) + CreateCheckbox("AggroChat", "AGGRO Chat-Message", "showaggrochat", 40, -205) + CreateCheckbox("AggroEmote", "AGGRO Emote", "showaggroemote", 40, -230) + CreateCheckbox("DeathMessage", "Death message", "deathmessage", 20, -255) + + CreateCheckbox("OOM", "Out of Mana", "OOM", 20, -290) + CreateCheckbox("OOMChat", "OOM Chat-Message", "showoomchat", 40, -315) + CreateCheckbox("OOMEmote", "OOM Emote", "showoomemote", 40, -340) + CreateSlider("OomPerc", "If under %d%% Mana, OOM alert", "OOMPercentage", 1, 20, -380) + + CreateCheckbox("NearOOM", "Near out of Mana", "NEAROOM", 20, -430) + CreateCheckbox("NearOOMChat", "Near OOM Chat-Message", "shownearoomchat", 40, -455) + CreateCheckbox("NearOOMEmote", "Near OOM Emote", "shownearoomemote", 40, -480) + CreateSlider("NearOomPerc", "If under %d%% Mana, low-mana alert", "NEAROOMPercentage", 5, 50, -520) + + CreateCheckbox("NearDeath", "Near Death", "NEARDEATH", 20, -570) + CreateCheckbox("NearDeathChat", "Near Death Chat-Message", "showneardeathchat", 40, -595) + CreateCheckbox("NearDeathEmote", "Near Death Emote", "showneardeathemote", 40, -620) + CreateSlider("NearDeathPerc", "If under %d%% Health, danger alert", "NEARDEATHPercentage", 5, 60, -660) + + CreateChannelDropdown(20, -710) + + CreateEditBox("Prefix", "HPEB_Prefix", 20, -770, 175, "prefix") + CreateEditBox("Suffix", "HPEB_Suffix", 220, -770, 175, "suffix") + + return f end SLASH_HEALERPROTECTION1 = "/hp" SLASH_HEALERPROTECTION2 = "/healerprotection" -SlashCmdList["HEALERPROTECTION"] = function(msg) -if HPOptionsFrame and HPOptionsFrame:IsVisible() then -HPOptionsFrame:Hide() -else -if not HPOptionsFrame then -HealerProtection:CreateGUI() -end -HPOptionsFrame:Show() -end -end +SlashCmdList["HEALERPROTECTION"] = function() + if not HPOptionsFrame then + HP:CreateGUI() + end -function HealerProtection:CreateGUI() -local f = CreateFrame("Frame", "HPOptionsFrame", UIParent) -f:SetWidth(400) -f:SetHeight(755) -f:SetPoint("CENTER", UIParent, "CENTER") -f:SetBackdrop({ -bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", -edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", -tile = true, tileSize = 32, edgeSize = 32, -insets = { left = 11, right = 12, top = 12, bottom = 11 } -}) -f:SetMovable(true) -f:EnableMouse(true) -f:RegisterForDrag("LeftButton") -f:SetScript("OnDragStart", function() f:StartMoving() end) -f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end) -f:SetFrameStrata("DIALOG") -f:Hide() - -f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") -f.title:SetPoint("TOP", 0, -18) -f.title:SetText("Healer Protection - Settings") - -local btnClose = CreateFrame("Button", nil, f, "UIPanelButtonTemplate") -btnClose:SetWidth(100) -btnClose:SetHeight(24) -btnClose:SetPoint("BOTTOM", 0, 16) -btnClose:SetText("Close") -btnClose:SetScript("OnClick", function() f:Hide() end) - --- ------------------------------------------------------------------ --- Helpers --- ------------------------------------------------------------------ - -local function CreateCheckbox(name, label, dbKey, defaultValue, xOffset, yOffset) -local cb = CreateFrame("CheckButton", "HPCB_"..name, f, "UICheckButtonTemplate") -cb:SetPoint("TOPLEFT", xOffset, yOffset) -getglobal(cb:GetName().."Text"):SetText(label) -getglobal(cb:GetName().."Text"):SetTextColor(1, 1, 1) - -cb:SetScript("OnShow", function() -local val = HPTABPC[dbKey] -if val == nil then val = defaultValue end -cb:SetChecked(val and 1 or nil) -end) - -cb:SetScript("OnClick", function() -local isChecked = (cb:GetChecked() == 1) -HPTABPC[dbKey] = isChecked -end) -return cb -end - --- labelFormat must contain one "%d" placeholder for the current value, e.g. "If under %d%% Health, print message" -local function CreateSlider(name, labelFormat, dbKey, defaultValue, minVal, maxVal, yOffset) -local slider = CreateFrame("Slider", "HPSL_"..name, f, "OptionsSliderTemplate") -slider:SetPoint("TOP", 0, yOffset) -slider:SetWidth(320) -slider:SetHeight(16) -slider:SetOrientation("HORIZONTAL") -slider:SetMinMaxValues(minVal, maxVal) -slider:SetValueStep(1) - -getglobal(slider:GetName().."Low"):SetText(tostring(minVal)) -getglobal(slider:GetName().."High"):SetText(tostring(maxVal)) -local sliderLabel = getglobal(slider:GetName().."Text") - -local function UpdateLabel(val) -sliderLabel:SetText(string.format(labelFormat, val)) -end - -slider:SetScript("OnShow", function() -local val = HPTABPC[dbKey] -if val == nil then val = defaultValue end -slider:SetValue(val) -UpdateLabel(val) -end) - -slider:SetScript("OnValueChanged", function() -local val = arg1 -if val == nil then val = slider:GetValue() end -val = math.floor(val + 0.5) -HPTABPC[dbKey] = val -UpdateLabel(val) -end) - -return slider -end - -local function CreateEditBox(name, xOffset, yOffset, width, dbKey, defaultValue) -local labelFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") -labelFS:SetPoint("TOPLEFT", xOffset, yOffset) -labelFS:SetText(name) - -local eb = CreateFrame("EditBox", "HPEB_"..name, f, "InputBoxTemplate") -eb:SetWidth(width) -eb:SetHeight(20) -eb:SetPoint("TOPLEFT", xOffset + 4, yOffset - 18) -eb:SetAutoFocus(false) - -eb:SetScript("OnShow", function() -local val = HPTABPC[dbKey] -if val == nil then val = defaultValue end -eb:SetText(val) -end) - -eb:SetScript("OnEnterPressed", function() -HPTABPC[dbKey] = eb:GetText() -eb:ClearFocus() -end) - -eb:SetScript("OnEditFocusLost", function() -HPTABPC[dbKey] = eb:GetText() -end) - -return eb -end - -local function CreateChannelDropdown(xOffset, yOffset) -local labelFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") -labelFS:SetPoint("TOPLEFT", xOffset, yOffset) -labelFS:SetText("Announce Channel") - -local dropdown = CreateFrame("Frame", "HPChannelDropDown", f, "UIDropDownMenuTemplate") -dropdown:SetPoint("TOPLEFT", xOffset - 16, yOffset - 18) -UIDropDownMenu_SetWidth(120, dropdown) - -local channels = { "AUTO", "SAY", "YELL", "PARTY", "RAID", "GUILD" } - -local function OnClick() -local value = this.value -HPTABPC["channelchat"] = value -UIDropDownMenu_SetSelectedValue(dropdown, value) -UIDropDownMenu_SetText(value, dropdown) -end - -local function Initialize() -for _, chan in ipairs(channels) do -local info = {} -info.text = chan -info.value = chan -info.func = OnClick -UIDropDownMenu_AddButton(info) -end -end - -UIDropDownMenu_Initialize(dropdown, Initialize) -local current = HealerProtection:DBGV("channelchat", "AUTO") -UIDropDownMenu_SetSelectedValue(dropdown, current) -UIDropDownMenu_SetText(current, dropdown) - -return dropdown -end - --- ------------------------------------------------------------------ --- Layout (mirrors the D4KiR Healer Protection panel, minus the CC --- checkbox grid top-right and the Loss of control section at the bottom) --- ------------------------------------------------------------------ - -CreateCheckbox("PrintNothing", "Print Nothing", "printnothing", false, 20, -45) -CreateCheckbox("ShowInRaid", "Show in Raids", "showinraids", true, 20, -70) -CreateCheckbox("Outside", "Show outside", "showoutsideofinstance", false, 20, -95) -CreateCheckbox("ShowInBG", "Show in Battlegrounds", "showinbgs", false, 20, -120) - -CreateCheckbox("Aggro", "AGGRO", "AGGRO", false, 20, -155) -CreateCheckbox("AggroChat", "AGGRO Chat-Message", "showaggrochat", true, 40, -180) -CreateCheckbox("AggroEmote", "AGGRO Emote", "showaggroemote", true, 40, -205) -CreateCheckbox("DeathMessage", "Death message", "deathmessage", true, 20, -230) - -CreateCheckbox("OOM", "Out of Mana", "OOM", true, 20, -265) -CreateCheckbox("OOMChat", "OOM Chat-Message", "showoomchat", true, 40, -290) -CreateCheckbox("OOMEmote", "OOM Emote", "showoomemote", true, 40, -315) - -CreateSlider("OomPerc", "If under %d%% Mana, print message", "OOMPercentage", 10, 1, 10, -355) - -CreateCheckbox("NearOOM", "Near out of Mana", "NEAROOM", true, 20, -410) -CreateCheckbox("NearOOMChat", "Near OOM Chat-Message", "shownearoomchat", true, 40, -435) -CreateCheckbox("NearOOMEmote", "Near OOM Emote", "shownearoomemote", true, 40, -460) - -CreateSlider("NearOomPerc", "If under %d%% Mana, print message", "NEAROOMPercentage", 30, 11, 30, -500) - -CreateChannelDropdown(20, -555) - -CreateEditBox("Prefix", 20, -615, 160, "prefix", "[Healer Protection]") -CreateEditBox("Suffix", 210, -615, 160, "suffix", "") + if HPOptionsFrame:IsVisible() then + HPOptionsFrame:Hide() + else + HPOptionsFrame:Show() + end end -- ============================================================================ --- MAGIC PATCH: STANDALONE BOOTSTRAP AND TRANSLATIONS +-- INITIALIZATION / EVENTS -- ============================================================================ -local traductions = { -["LID_youhaveaggro"] = "You have aggro", -["LID_ihaveaggro"] = "Aggro on me! Get it off!", -["LID_outofmana"] = "I am OOM!", -["LID_nearoutofmana"] = "Low Mana!", -["LID_xmana"] = "%s%% Mana", -["LID_neardeath"] = "Help, I'm dying!", -["LID_xhealth"] = "%s%% HP", -["LID_healerisdead"] = "Healer is down... Fly, you fools!" -} -- BUG FIXED: closing brace was missing in the original file, which would have caused a Lua syntax error +local function InitializeDatabase() + HPTABPC = HPTABPC or {} + DB = HPTABPC -function HealerProtection:Trans(key) -return traductions[key] or key + local key, value + for key, value in pairs(DEFAULTS) do + if DB[key] == nil then + DB[key] = value + end + end + + DB.OOMPercentage = Clamp(DB.OOMPercentage, 1, 20) + DB.NEAROOMPercentage = Clamp(DB.NEAROOMPercentage, 5, 50) + if DB.NEAROOMPercentage <= DB.OOMPercentage then + DB.NEAROOMPercentage = math.min(50, DB.OOMPercentage + 10) + end + DB.NEARDEATHPercentage = Clamp(DB.NEARDEATHPercentage, 5, 60) end -function HealerProtection:TryTrans(key, lang, val) -local texte = traductions[key] or key -if val then -return string.format(texte, tostring(val)) -end -return texte +local function CreateAggroWarning() + warningAggro = CreateFrame("Frame", nil, UIParent) + warningAggro:SetFrameStrata("HIGH") + warningAggro:SetWidth(220) + warningAggro:SetHeight(40) + warningAggro:SetPoint("CENTER", UIParent, "CENTER", 0, 300) + + warningAggro.text = warningAggro:CreateFontString(nil, "OVERLAY") + local font = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" + warningAggro.text:SetFont(font, 20, "OUTLINE") + warningAggro.text:SetPoint("CENTER", warningAggro, "CENTER", 0, 0) + warningAggro.text:SetText(TEXT.aggro_warning) + warningAggro.text:SetTextColor(1, 0, 0, 1) + warningAggro:Hide() end -function HealerProtection:IsSetup() return true end -function HealerProtection:SetSetup() end -function HealerProtection:SetDbTab() end -function HealerProtection:InitSetting() end -function HealerProtection:IsLoaded() return true end -function HealerProtection:INFO(msg) DEFAULT_CHAT_FRAME:AddMessage("|cFF00FFFF[HP Info]|r " .. msg) end -function HealerProtection:MSG(msg) DEFAULT_CHAT_FRAME:AddMessage("|cFFFFFF00[HP]|r " .. msg) end -function HealerProtection:MathR(val, dec) -dec = dec or 0 -local mult = 10 ^ dec -return math.floor(val * mult + 0.5) / mult -end +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("PLAYER_LOGIN") +eventFrame:RegisterEvent("PLAYER_DEAD") +eventFrame:RegisterEvent("PLAYER_ALIVE") +eventFrame:RegisterEvent("PLAYER_UNGHOST") +eventFrame:RegisterEvent("UNIT_MANA") +eventFrame:RegisterEvent("UNIT_MAXMANA") +eventFrame:RegisterEvent("UNIT_HEALTH") +eventFrame:RegisterEvent("UNIT_MAXHEALTH") +eventFrame:RegisterEvent("SPELLCAST_CHANNEL_START") +eventFrame:RegisterEvent("SPELLCAST_CHANNEL_STOP") +eventFrame:RegisterEvent("SPELLCAST_STOP") +eventFrame:RegisterEvent("UI_ERROR_MESSAGE") -local bootFrame = CreateFrame("Frame") -bootFrame:RegisterEvent("PLAYER_LOGIN") -bootFrame:SetScript("OnEvent", function() -HealerProtection:Setup() -DEFAULT_CHAT_FRAME:AddMessage("|cFF00FF00[Healer Protection]|r ready and armed! Type |cFFFFFF00/hp|r to configure the alerts.") +eventFrame:SetScript("OnEvent", function() + local e = event + local a1 = arg1 + + if e == "PLAYER_LOGIN" then + InitializeDatabase() + CreateAggroWarning() + HP:CheckMana() + HP:CheckHealth() + DEFAULT_CHAT_FRAME:AddMessage("|cFF00FF00[Healer Protection]|r v1.1 ready. Type |cFFFFFF00/hp|r to configure.") + return + end + + if not DB then + return + end + + if e == "PLAYER_DEAD" then + HP:HandleDeath() + return + end + + if e == "PLAYER_ALIVE" or e == "PLAYER_UNGHOST" then + HP:HandleAlive() + return + end + + if e == "UNIT_MANA" or e == "UNIT_MAXMANA" then + if a1 == "player" then + HP:CheckMana() + end + return + end + + if e == "UNIT_HEALTH" or e == "UNIT_MAXHEALTH" then + if a1 == "player" then + HP:CheckHealth() + end + return + end + + if e == "SPELLCAST_CHANNEL_START" then + state.channeling = true + return + end + + if e == "SPELLCAST_CHANNEL_STOP" or e == "SPELLCAST_STOP" then + state.channeling = false + return + end + + if e == "UI_ERROR_MESSAGE" and DB.notinsight then + local errMessage = a1 + if errMessage and SPELL_FAILED_LINE_OF_SIGHT and errMessage == SPELL_FAILED_LINE_OF_SIGHT then + local targetName = UnitName("target") + if targetName and UnitIsFriend("player", "target") then + local now = GetTime() + if targetName ~= lastLosTarget or now >= nextLosMessage then + lastLosTarget = targetName + nextLosMessage = now + 1 + HP:Announce(TEXT.los .. " (" .. targetName .. ")") + end + end + end + end +end) + +-- Aggro is the only feature that requires polling on Vanilla. +-- Keep the OnUpdate handler extremely cheap and return immediately when disabled. +local aggroFrame = CreateFrame("Frame") +local aggroElapsed = 0 +aggroFrame:SetScript("OnUpdate", function() + if not DB or not DB.AGGRO then + return + end + + aggroElapsed = aggroElapsed + (arg1 or 0) + if aggroElapsed < 0.75 then + return + end + + aggroElapsed = 0 + HP:CheckAggro() end)