Files
2026-08-23 16:52:05 +02:00

787 lines
22 KiB
Lua

-- 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 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",
}
-- ============================================================================
-- SMALL HELPERS
-- ============================================================================
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
local function GetRaidCount()
if GetNumRaidMembers then
return GetNumRaidMembers() or 0
end
return 0
end
local function GetPartyCount()
if GetNumPartyMembers then
return GetNumPartyMembers() or 0
end
return 0
end
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 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
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
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
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
-- ============================================================================
-- ALERT LOGIC
-- ============================================================================
function HP:CheckMana()
if not PlayerUsesMana() then
state.oom = false
state.nearoom = false
return
end
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
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
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
function HP:HandleAlive()
state.dead = false
self:CheckHealth()
self:CheckMana()
end
-- ============================================================================
-- AGGRO
-- ============================================================================
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
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()
if not HPOptionsFrame then
HP:CreateGUI()
end
if HPOptionsFrame:IsVisible() then
HPOptionsFrame:Hide()
else
HPOptionsFrame:Show()
end
end
-- ============================================================================
-- INITIALIZATION / EVENTS
-- ============================================================================
local function InitializeDatabase()
HPTABPC = HPTABPC or {}
DB = HPTABPC
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
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
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")
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)