diff --git a/CombatMode/Config.lua b/CombatMode/Config.lua
index e5a06ee..fdeee4b 100644
--- a/CombatMode/Config.lua
+++ b/CombatMode/Config.lua
@@ -1046,6 +1046,186 @@ local AdvancedConfigOptions = {
}
}
+---------------------------------------------------------------------------------------
+-- HEALING RADIAL CONFIG --
+---------------------------------------------------------------------------------------
+local HealingRadialOptions = {
+ name = CM.METADATA["TITLE"],
+ handler = CM,
+ type = "group",
+ args = {
+ header = {
+ type = "header",
+ name = "|cff00FF7FHEALING RADIAL|r",
+ order = 1
+ },
+ description = {
+ type = "description",
+ name = "\nA radial menu for quickly targeting party members when healing. While |cffE52B50Mouse Look|r is active and you're in a party, hold a mouse button to display the radial, flick toward your target, and release to cast.\n\n",
+ fontSize = "medium",
+ order = 2
+ },
+ enabled = {
+ type = "toggle",
+ name = "Enable |cff00FF7FHealing Radial|r",
+ desc = "Shows a radial menu when using click-cast buttons while in a party. Party members are arranged by role with the tank at 12 o'clock.\n\n|cffffd700Default:|r |cffE52B50Off|r",
+ width = "full",
+ order = 3,
+ set = function(_, value)
+ CM.DB.global.healingRadial.enabled = value
+ -- Re-apply mouse button bindings (clears them if radial enabled, sets them if disabled)
+ CM.OverrideDefaultButtons()
+ if CM.HealingRadial and CM.HealingRadial.SetCaptureActive then
+ CM.HealingRadial.SetCaptureActive(value)
+ end
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.enabled
+ end,
+ },
+ spacing1 = Spacing("full", 3.1),
+ visualGroup = {
+ type = "group",
+ name = "Visual Settings",
+ order = 4,
+ inline = true,
+ args = {
+ showHealthBars = {
+ type = "toggle",
+ name = "Show Health Bars",
+ desc = "Display health bars on each party member slice.",
+ width = 1.2,
+ order = 1,
+ set = function(_, value)
+ CM.DB.global.healingRadial.showHealthBars = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.showHealthBars
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ showHealthPercent = {
+ type = "toggle",
+ name = "Show Health %",
+ desc = "Display health percentage on each slice.",
+ width = 1.2,
+ order = 2,
+ set = function(_, value)
+ CM.DB.global.healingRadial.showHealthPercent = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.showHealthPercent
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ showPlayerNames = {
+ type = "toggle",
+ name = "Show Names",
+ desc = "Display player names on each slice.",
+ width = 1.2,
+ order = 3,
+ set = function(_, value)
+ CM.DB.global.healingRadial.showPlayerNames = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.showPlayerNames
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ showRoleIcons = {
+ type = "toggle",
+ name = "Show Role Icons",
+ desc = "Display role icons (tank, healer, DPS) on each slice.",
+ width = 1.2,
+ order = 4,
+ set = function(_, value)
+ CM.DB.global.healingRadial.showRoleIcons = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.showRoleIcons
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ spacing2 = Spacing("full", 4.1),
+ sliceRadius = {
+ type = "range",
+ name = "Radial Size",
+ desc = "Distance from center to each party member slice.\n\n|cffffd700Default:|r |cff00FF7F120|r",
+ min = 60,
+ max = 200,
+ step = 10,
+ width = 1.5,
+ order = 5,
+ set = function(_, value)
+ CM.DB.global.healingRadial.sliceRadius = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.sliceRadius
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ sliceSize = {
+ type = "range",
+ name = "Slice Size",
+ desc = "Size of each party member slice.\n\n|cffffd700Default:|r |cff00FF7F80|r",
+ min = 50,
+ max = 120,
+ step = 10,
+ width = 1.5,
+ order = 6,
+ set = function(_, value)
+ CM.DB.global.healingRadial.sliceSize = value
+ end,
+ get = function()
+ return CM.DB.global.healingRadial.sliceSize
+ end,
+ disabled = function()
+ return not CM.DB.global.healingRadial.enabled
+ end
+ },
+ }
+ },
+ spacing3 = Spacing("full", 5),
+ layoutInfo = {
+ type = "group",
+ name = "|cffffd700Layout Information|r",
+ order = 6,
+ inline = true,
+ args = {
+ layoutNote = {
+ type = "description",
+ name = "|cff909090Party members are automatically positioned by role:|r\n\n|cffcfcfcf• |cff00d1ffTank|r at 12 o'clock (top)\n• |cff00ff00Healer|r at 7 o'clock (bottom-left)\n• |cffff6060DPS|r fill remaining positions\n\nYour character is included in the radial at your role's position.|r",
+ order = 1
+ }
+ }
+ },
+ spacing4 = Spacing("full", 7),
+ devnote = {
+ type = "group",
+ name = "|cffffd700Developer Note|r",
+ order = 8,
+ inline = true,
+ args = {
+ note = {
+ type = "description",
+ name = "|cff909090The Healing Radial uses the same spell assignments as |cffB47EDCClick Casting|r. Configure which spells are bound to each mouse button in the Click Casting tab.|r\n\n|cffFF5050Note:|r Party assignments can only be updated outside of combat due to WoW API restrictions.",
+ order = 1
+ }
+ }
+ }
+ }
+}
+
---------------------------------------------------------------------------------------
-- SETTINGS CATEGORY TREE --
---------------------------------------------------------------------------------------
@@ -1069,6 +1249,11 @@ CM.Config.OptionCategories = {
name = "|cffB47EDC • Click Casting|r",
table = ClickCastingOptions
},
+ {
+ id = "CombatMode_HealingRadial",
+ name = "|cff00FF7F • Healing Radial|r",
+ table = HealingRadialOptions
+ },
{
id = "CombatMode_Advanced",
name = "|cffffffff • Advanced|r",
diff --git a/CombatMode/Constants.lua b/CombatMode/Constants.lua
index 0be0054..bad6bd8 100644
--- a/CombatMode/Constants.lua
+++ b/CombatMode/Constants.lua
@@ -142,9 +142,28 @@ CM.Constants.BLIZZARD_EVENTS = {
"PLAYER_MOUNT_DISPLAY_CHANGED", -- Toggling crosshair when mounting/dismounting
"PLAYER_REGEN_ENABLED" -- Resetting crosshair when leaving combat
},
+ -- Events for Healing Radial
+ HEALING_RADIAL_EVENTS = {
+ "GROUP_ROSTER_UPDATE", -- Party composition changed
+ },
}
+---------------------------------------------------------------------------------------
+-- HEALING RADIAL POSITIONS --
+---------------------------------------------------------------------------------------
+-- Slice positions for 5-man content (angles in degrees, 0 = right, 90 = up)
+-- Each slice covers 72 degrees (360/5)
+CM.Constants.HealingRadialSlices = {
+ [1] = { defaultRole = "TANK", angle = 90, label = "12 o'clock (top)" },
+ [2] = { defaultRole = "DAMAGER", angle = 162, label = "10 o'clock (upper-left)" },
+ [3] = { defaultRole = "HEALER", angle = 234, label = "7 o'clock (lower-left)" },
+ [4] = { defaultRole = "DAMAGER", angle = 306, label = "5 o'clock (lower-right)" },
+ [5] = { defaultRole = "DAMAGER", angle = 18, label = "2 o'clock (upper-right)" },
+}
+
+CM.Constants.HealingRadialSliceArc = 72 -- degrees per slice
+
---------------------------------------------------------------------------------------
-- ASSETS --
---------------------------------------------------------------------------------------
@@ -370,7 +389,8 @@ CM.Constants.FramesToCheck = {
"WantAds",
"SubscriptionInterstitialFrame",
"CinematicFrameCloseDialog",
- "MovieFrame"
+ "MovieFrame",
+ "CombatModeHealingRadialFrame"
}
-- Default frames to check with a dynamic name: any frame containing a string defined here will be matched, e.g. "OPieRT" will match the frame "OPieRT-1234-5678"
@@ -642,7 +662,24 @@ CM.Constants.DatabaseDefaults = {
crosshairY = 50,
silenceAlerts = false,
debugMode = false,
- bindings = DefaultBindings
+ bindings = DefaultBindings,
+ -- Healing Radial settings
+ healingRadial = {
+ enabled = false,
+ sliceRadius = 120,
+ sliceSize = 80,
+ showHealthBars = true,
+ showHealthPercent = true,
+ showPlayerNames = true,
+ showRoleIcons = true,
+ highlightColor = {1, 1, 0, 0.4},
+ backgroundColor = {0, 0, 0, 0.7},
+ healthyColor = {0, 0.8, 0, 1},
+ damagedColor = {1, 1, 0, 1},
+ criticalColor = {1, 0, 0, 1},
+ fadeInDuration = 0.08,
+ fadeOutDuration = 0.05,
+ }
},
char = {
useGlobalBindings = false,
diff --git a/CombatMode/Core.lua b/CombatMode/Core.lua
index bace8de..3009510 100644
--- a/CombatMode/Core.lua
+++ b/CombatMode/Core.lua
@@ -642,10 +642,15 @@ local function IsUnlockFrameVisible()
CursorUnlockFrameGroupVisible(CM.Constants.WildcardFramesToCheck) or isGenericPanelOpen
end
+local function IsHealingRadialActive()
+ return CM.HealingRadial and CM.HealingRadial.IsActive and CM.HealingRadial.IsActive()
+end
+
local function ShouldFreeLookBeOff()
local evaluate = IsCustomConditionTrue() or
(FreeLookOverride or SpellIsTargeting() or InCinematic() or IsInCinematicScene() or
- IsUnlockFrameVisible() or IsVendorMountOut() or IsInPetBattle() or IsFeignDeathActive())
+ IsUnlockFrameVisible() or IsVendorMountOut() or IsInPetBattle() or IsFeignDeathActive() or
+ IsHealingRadialActive())
return evaluate
end
@@ -681,6 +686,11 @@ function CM.SetNewBinding(buttonSettings)
return
end
+ -- If healing radial is enabled, it manages its own bindings
+ if CM.DB.global.healingRadial and CM.DB.global.healingRadial.enabled then
+ return
+ end
+
local valueToUse
if buttonSettings.value == "MACRO" then
valueToUse = "MACRO " .. buttonSettings.macroName
@@ -753,6 +763,10 @@ local function LockFreeLook()
MouselookStart()
CenterCursor(true)
HandleFreeLookUIState(true, false)
+ -- Notify Healing Radial of mouselook state change
+ if CM.HealingRadial and CM.HealingRadial.OnMouselookChanged then
+ CM.HealingRadial.OnMouselookChanged(true)
+ end
CM.DebugPrint("Free Look Enabled")
end
end
@@ -767,6 +781,10 @@ local function UnlockFreeLook()
end
HandleFreeLookUIState(false, false)
+ -- Notify Healing Radial of mouselook state change
+ if CM.HealingRadial and CM.HealingRadial.OnMouselookChanged then
+ CM.HealingRadial.OnMouselookChanged(false)
+ end
CM.DebugPrint("Free Look Disabled")
end
end
@@ -781,6 +799,10 @@ local function UnlockFreeLookPermanent()
end
HandleFreeLookUIState(false, true)
+ -- Notify Healing Radial of mouselook state change
+ if CM.HealingRadial and CM.HealingRadial.OnMouselookChanged then
+ CM.HealingRadial.OnMouselookChanged(false)
+ end
CM.DebugPrint("Free Look Disabled (Permanent)")
end
end
@@ -856,10 +878,19 @@ local function HandleEventByCategory(category, event)
end,
FRIENDLY_TARGETING_EVENTS = function()
HandleFriendlyTargetingInCombat()
+ -- Also handle combat end for healing radial pending updates
+ if event == "PLAYER_REGEN_ENABLED" and CM.HealingRadial and CM.HealingRadial.OnCombatEnd then
+ CM.HealingRadial.OnCombatEnd()
+ end
end,
UNCATEGORIZED_EVENTS = function()
SetCrosshairAppearance(HideCrosshairWhileMounted() and "mounted" or "base")
end,
+ HEALING_RADIAL_EVENTS = function()
+ if CM.HealingRadial and CM.HealingRadial.OnGroupRosterUpdate then
+ CM.HealingRadial.OnGroupRosterUpdate()
+ end
+ end,
}
@@ -994,6 +1025,11 @@ function CM:OnEnable()
CreatePulse()
CreateTargetMacros()
+ -- Initialize Healing Radial module
+ if CM.HealingRadial and CM.HealingRadial.Initialize then
+ CM.HealingRadial.Initialize()
+ end
+
-- Registering Blizzard Events from Constants.lua
for _, events_to_register in pairs(CM.Constants.BLIZZARD_EVENTS) do
for _, event in ipairs(events_to_register) do
diff --git a/CombatMode/Embeds.xml b/CombatMode/Embeds.xml
index a92407c..515e99d 100644
--- a/CombatMode/Embeds.xml
+++ b/CombatMode/Embeds.xml
@@ -12,6 +12,7 @@
+
diff --git a/CombatMode/HealingRadial.lua b/CombatMode/HealingRadial.lua
new file mode 100644
index 0000000..d41b455
--- /dev/null
+++ b/CombatMode/HealingRadial.lua
@@ -0,0 +1,832 @@
+---------------------------------------------------------------------------------------
+-- HEALING RADIAL MODULE --
+---------------------------------------------------------------------------------------
+-- A radial menu for quick party member targeting, designed for healers.
+-- Shows party members as slices around screen center, cast on release.
+
+-- IMPORTS
+local _G = _G
+local AceAddon = _G.LibStub("AceAddon-3.0")
+
+-- CACHING GLOBAL VARIABLES
+local CreateFrame = _G.CreateFrame
+local GetActionInfo = _G.GetActionInfo
+local GetCursorPosition = _G.GetCursorPosition
+local GetTime = _G.GetTime
+local InCombatLockdown = _G.InCombatLockdown
+local IsInGroup = _G.IsInGroup
+local MouselookStart = _G.MouselookStart
+local MouselookStop = _G.MouselookStop
+local UnitClass = _G.UnitClass
+local UnitExists = _G.UnitExists
+local UnitGroupRolesAssigned = _G.UnitGroupRolesAssigned
+local UnitHealth = _G.UnitHealth
+local UnitHealthMax = _G.UnitHealthMax
+local UnitName = _G.UnitName
+local UIParent = _G.UIParent
+local unpack = _G.unpack
+local math = _G.math
+local pairs = _G.pairs
+local ipairs = _G.ipairs
+local select = _G.select
+local table = _G.table
+
+-- RETRIEVING ADDON TABLE
+local CM = AceAddon:GetAddon("CombatMode")
+
+-- Module namespace
+CM.HealingRadial = {}
+local HR = CM.HealingRadial
+
+---------------------------------------------------------------------------------------
+-- STATE VARIABLES --
+---------------------------------------------------------------------------------------
+local RadialState = {
+ isActive = false,
+ currentButton = nil,
+ currentModifier = nil,
+ selectedSlice = nil,
+ partyData = {},
+ sliceFrames = {},
+ secureButtons = {},
+ mainFrame = nil,
+ sliceContainer = nil,
+ pendingUpdate = false,
+ wasMouselooking = false,
+}
+
+---------------------------------------------------------------------------------------
+-- UTILITY FUNCTIONS --
+---------------------------------------------------------------------------------------
+-- Calculate angle from screen center to cursor position
+local function GetMouseAngleFromCenter()
+ local cursorX, cursorY = GetCursorPosition()
+ local scale = UIParent:GetEffectiveScale()
+ cursorX, cursorY = cursorX / scale, cursorY / scale
+
+ local centerX = UIParent:GetWidth() / 2
+ local centerY = UIParent:GetHeight() / 2 + (CM.DB.global.crosshairY or 50)
+
+ local dx = cursorX - centerX
+ local dy = cursorY - centerY
+
+ -- Convert to degrees (0 = right, counter-clockwise positive)
+ local angle = math.deg(math.atan2(dy, dx))
+ if angle < 0 then
+ angle = angle + 360
+ end
+
+ return angle
+end
+
+-- Check if an angle falls within an arc (handles wrap-around at 0/360)
+local function IsAngleInArc(angle, arcStart, arcEnd)
+ -- Normalize all angles to 0-360
+ angle = angle % 360
+ arcStart = arcStart % 360
+ arcEnd = arcEnd % 360
+
+ if arcStart <= arcEnd then
+ return angle >= arcStart and angle < arcEnd
+ else
+ -- Arc wraps around 0
+ return angle >= arcStart or angle < arcEnd
+ end
+end
+
+-- Get which slice the current mouse angle corresponds to
+local function GetSliceFromAngle(angle)
+ local sliceArc = CM.Constants.HealingRadialSliceArc
+ local halfArc = sliceArc / 2
+
+ for i, sliceData in ipairs(CM.Constants.HealingRadialSlices) do
+ local centerAngle = sliceData.angle
+ local arcStart = (centerAngle - halfArc) % 360
+ local arcEnd = (centerAngle + halfArc) % 360
+
+ if IsAngleInArc(angle, arcStart, arcEnd) then
+ return i
+ end
+ end
+
+ return nil
+end
+
+-- Get the action slot based on button and modifier
+local function GetActionSlotForButton(buttonKey, modifier)
+ local slotMap = {
+ ["BUTTON1"] = 1,
+ ["BUTTON2"] = 2,
+ ["SHIFT-BUTTON1"] = 3,
+ ["SHIFT-BUTTON2"] = 4,
+ ["CTRL-BUTTON1"] = 5,
+ ["CTRL-BUTTON2"] = 6,
+ ["ALT-BUTTON1"] = 7,
+ ["ALT-BUTTON2"] = 8,
+ }
+ return slotMap[buttonKey] or 1
+end
+
+---------------------------------------------------------------------------------------
+-- PARTY DATA MANAGEMENT --
+---------------------------------------------------------------------------------------
+local function RefreshPartyData()
+ RadialState.partyData = {}
+
+ if not IsInGroup() then
+ return
+ end
+
+ -- Collect all party members including self
+ local members = {}
+
+ -- Add self
+ local selfRole = UnitGroupRolesAssigned("player")
+ if selfRole == "NONE" then
+ selfRole = "DAMAGER" -- Default to DPS if no role assigned
+ end
+ table.insert(members, {
+ unitId = "player",
+ name = UnitName("player"),
+ role = selfRole,
+ class = select(2, UnitClass("player")),
+ })
+
+ -- Add party members
+ for i = 1, 4 do
+ local unitId = "party" .. i
+ if UnitExists(unitId) then
+ local role = UnitGroupRolesAssigned(unitId)
+ if role == "NONE" then
+ role = "DAMAGER"
+ end
+ table.insert(members, {
+ unitId = unitId,
+ name = UnitName(unitId),
+ role = role,
+ class = select(2, UnitClass(unitId)),
+ })
+ end
+ end
+
+ -- Sort members by role for slot assignment
+ local tanks = {}
+ local healers = {}
+ local dps = {}
+
+ for _, member in ipairs(members) do
+ if member.role == "TANK" then
+ table.insert(tanks, member)
+ elseif member.role == "HEALER" then
+ table.insert(healers, member)
+ else
+ table.insert(dps, member)
+ end
+ end
+
+ -- Assign to slice positions based on role
+ local assignments = {}
+
+ -- Slice 1 (top) = Tank
+ if #tanks > 0 then
+ assignments[1] = tanks[1]
+ table.remove(tanks, 1)
+ end
+
+ -- Slice 3 (bottom-left) = Healer
+ if #healers > 0 then
+ assignments[3] = healers[1]
+ table.remove(healers, 1)
+ end
+
+ -- Fill DPS slots (2, 4, 5)
+ local dpsSlots = {2, 5, 4}
+ local dpsIndex = 1
+ for _, slot in ipairs(dpsSlots) do
+ if not assignments[slot] then
+ if dps[dpsIndex] then
+ assignments[slot] = dps[dpsIndex]
+ dpsIndex = dpsIndex + 1
+ elseif tanks[1] then
+ -- Overflow: extra tanks go to DPS slots
+ assignments[slot] = tanks[1]
+ table.remove(tanks, 1)
+ elseif healers[1] then
+ -- Overflow: extra healers go to DPS slots
+ assignments[slot] = healers[1]
+ table.remove(healers, 1)
+ end
+ end
+ end
+
+ -- Fill any remaining empty slots with remaining DPS
+ for i = 1, 5 do
+ if not assignments[i] and dps[dpsIndex] then
+ assignments[i] = dps[dpsIndex]
+ dpsIndex = dpsIndex + 1
+ end
+ end
+
+ -- Store assignments with slice index
+ for sliceIndex, member in pairs(assignments) do
+ member.sliceIndex = sliceIndex
+ table.insert(RadialState.partyData, member)
+ end
+
+ CM.DebugPrint("Healing Radial: Refreshed party data, " .. #RadialState.partyData .. " members")
+end
+
+-- Update secure button unit attributes (only safe out of combat)
+local function UpdateSecureButtonTargets()
+ if InCombatLockdown() then
+ RadialState.pendingUpdate = true
+ CM.DebugPrint("Healing Radial: Queueing button update (in combat)")
+ return
+ end
+
+ -- Clear all buttons first
+ for i = 1, 5 do
+ local btn = RadialState.secureButtons[i]
+ if btn then
+ btn:SetAttribute("unit", nil)
+ end
+ end
+
+ -- Assign units to buttons based on party data
+ for _, member in ipairs(RadialState.partyData) do
+ local btn = RadialState.secureButtons[member.sliceIndex]
+ if btn then
+ btn:SetAttribute("unit", member.unitId)
+ CM.DebugPrint("Healing Radial: Slice " .. member.sliceIndex .. " = " .. member.unitId .. " (" .. member.name .. ")")
+ end
+ end
+
+ RadialState.pendingUpdate = false
+end
+
+---------------------------------------------------------------------------------------
+-- FRAME CREATION --
+---------------------------------------------------------------------------------------
+local function CreateSliceFrame(sliceIndex)
+ local config = CM.DB.global.healingRadial
+ local sliceData = CM.Constants.HealingRadialSlices[sliceIndex]
+ local angle = sliceData.angle
+ local radius = config.sliceRadius
+
+ -- Calculate position using trigonometry
+ local x = radius * math.cos(math.rad(angle))
+ local y = radius * math.sin(math.rad(angle))
+
+ local slice = CreateFrame("Frame", "CMHealRadialSlice" .. sliceIndex, RadialState.sliceContainer)
+ slice:SetSize(config.sliceSize, config.sliceSize)
+ slice:SetPoint("CENTER", RadialState.sliceContainer, "CENTER", x, y)
+
+ -- Background
+ slice.bg = slice:CreateTexture(nil, "BACKGROUND")
+ slice.bg:SetAllPoints()
+ slice.bg:SetColorTexture(unpack(config.backgroundColor))
+
+ -- Health bar background
+ slice.healthBG = slice:CreateTexture(nil, "BORDER")
+ slice.healthBG:SetColorTexture(0.15, 0.15, 0.15, 0.9)
+ slice.healthBG:SetSize(config.sliceSize - 16, 10)
+ slice.healthBG:SetPoint("BOTTOM", slice, "BOTTOM", 0, 8)
+
+ -- Health bar fill
+ slice.healthFill = slice:CreateTexture(nil, "ARTWORK")
+ slice.healthFill:SetColorTexture(unpack(config.healthyColor))
+ slice.healthFill:SetPoint("LEFT", slice.healthBG, "LEFT", 1, 0)
+ slice.healthFill:SetSize(config.sliceSize - 18, 8)
+
+ -- Name text
+ slice.nameText = slice:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
+ slice.nameText:SetPoint("CENTER", slice, "CENTER", 0, 8)
+ slice.nameText:SetTextColor(1, 1, 1, 1)
+
+ -- Health percent text
+ slice.healthText = slice:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
+ slice.healthText:SetPoint("CENTER", slice.healthBG, "CENTER", 0, 0)
+ slice.healthText:SetTextColor(1, 1, 1, 1)
+
+ -- Role icon
+ slice.roleIcon = slice:CreateTexture(nil, "OVERLAY")
+ slice.roleIcon:SetSize(18, 18)
+ slice.roleIcon:SetPoint("TOP", slice, "TOP", 0, -4)
+
+ -- Highlight overlay (shown when selected)
+ slice.highlight = slice:CreateTexture(nil, "OVERLAY", nil, 7)
+ slice.highlight:SetAllPoints()
+ slice.highlight:SetColorTexture(unpack(config.highlightColor))
+ slice.highlight:Hide()
+
+ -- Border when highlighted
+ slice.border = slice:CreateTexture(nil, "OVERLAY", nil, 6)
+ slice.border:SetPoint("TOPLEFT", -2, 2)
+ slice.border:SetPoint("BOTTOMRIGHT", 2, -2)
+ slice.border:SetColorTexture(1, 1, 0, 0.8)
+ slice.border:Hide()
+
+ slice:Hide()
+
+ RadialState.sliceFrames[sliceIndex] = slice
+ return slice
+end
+
+local function CreateSecureButtons()
+ -- Create a secure container
+ local container = CreateFrame("Frame", "CMHealRadialSecureContainer", UIParent, "SecureHandlerStateTemplate")
+ container:SetSize(1, 1)
+ container:SetPoint("CENTER")
+
+ for i = 1, 5 do
+ local btn = CreateFrame("Button", "CMHealRadialBtn" .. i, container, "SecureActionButtonTemplate")
+ btn:SetAttribute("type", "spell")
+ btn:SetAttribute("unit", nil)
+ btn:SetSize(1, 1)
+ btn:SetPoint("CENTER", UIParent, "BOTTOMLEFT", -100, -100)
+ btn:Hide()
+
+ RadialState.secureButtons[i] = btn
+ end
+
+ -- Store reference for secure execution
+ for i = 1, 5 do
+ container:SetFrameRef("slice" .. i, RadialState.secureButtons[i])
+ end
+
+ RadialState.secureContainer = container
+end
+
+-- Create secure action buttons for spell casting
+-- These buttons are clicked programmatically by ExecuteAndHide() when mouse is released
+-- The mouselook override binding triggers HR.Show() on mouse down via a simple frame click
+local function CreateMouseOverrideButtons()
+ -- Button key mappings
+ local buttonMappings = {
+ { key = "BUTTON1", actionSlot = 1 },
+ { key = "BUTTON2", actionSlot = 2 },
+ { key = "SHIFT-BUTTON1", actionSlot = 3 },
+ { key = "SHIFT-BUTTON2", actionSlot = 4 },
+ { key = "CTRL-BUTTON1", actionSlot = 5 },
+ { key = "CTRL-BUTTON2", actionSlot = 6 },
+ { key = "ALT-BUTTON1", actionSlot = 7 },
+ { key = "ALT-BUTTON2", actionSlot = 8 },
+ }
+
+ RadialState.overrideButtons = {}
+ RadialState.triggerButtons = {}
+
+ for _, mapping in ipairs(buttonMappings) do
+ -- Secure button for casting spells (clicked programmatically)
+ local castBtn = CreateFrame("Button", "CMHealRadialCast_" .. mapping.key:gsub("-", "_"), UIParent, "SecureActionButtonTemplate")
+ castBtn:SetSize(1, 1)
+ castBtn:SetPoint("CENTER", UIParent, "BOTTOMLEFT", -100, -100)
+ castBtn.actionSlot = mapping.actionSlot
+ castBtn.buttonKey = mapping.key
+ castBtn:SetAttribute("type", nil)
+
+ RadialState.overrideButtons[mapping.key] = castBtn
+
+ -- Non-secure trigger button that shows the radial on mouse down
+ -- This is what the mouselook override binding clicks
+ local triggerBtn = CreateFrame("Button", "CMHealRadialTrigger_" .. mapping.key:gsub("-", "_"), UIParent)
+ triggerBtn:SetSize(1, 1)
+ triggerBtn:SetPoint("CENTER", UIParent, "BOTTOMLEFT", -100, -100)
+ triggerBtn.buttonKey = mapping.key
+ triggerBtn:RegisterForClicks("AnyDown")
+
+ triggerBtn:SetScript("OnClick", function(self)
+ HR.Show(self.buttonKey)
+ end)
+
+ RadialState.triggerButtons[mapping.key] = triggerBtn
+ end
+end
+
+-- Set up the mouselook override bindings for healing radial
+function HR.SetupMouselookBindings()
+ if not RadialState.triggerButtons then
+ return
+ end
+
+ local SetMouselookOverrideBinding = _G.SetMouselookOverrideBinding
+
+ for key, triggerBtn in pairs(RadialState.triggerButtons) do
+ -- Bind the mouse button to click the trigger button (shows radial)
+ SetMouselookOverrideBinding(key, "CLICK " .. triggerBtn:GetName() .. ":LeftButton")
+ CM.DebugPrint("Healing Radial: Bound " .. key .. " to " .. triggerBtn:GetName())
+ end
+end
+
+-- Clear the mouselook override bindings (restore normal Combat Mode behavior)
+function HR.ClearMouselookBindings()
+ local SetMouselookOverrideBinding = _G.SetMouselookOverrideBinding
+
+ for _, key in ipairs({"BUTTON1", "BUTTON2", "SHIFT-BUTTON1", "SHIFT-BUTTON2", "CTRL-BUTTON1", "CTRL-BUTTON2", "ALT-BUTTON1", "ALT-BUTTON2"}) do
+ SetMouselookOverrideBinding(key, nil)
+ end
+ CM.DebugPrint("Healing Radial: Cleared mouselook bindings")
+end
+
+-- Update capture frame visibility based on mouselook state
+-- This should be called from Core.lua's mouselook handlers
+function HR.OnMouselookChanged(isMouselooking)
+ -- If radial was showing and we exit mouselook, hide it
+ if not isMouselooking and RadialState.isActive then
+ HR.Hide(false) -- false = don't execute spell
+ end
+end
+
+-- Toggle the healing radial system (called when enabled/disabled in settings)
+function HR.SetCaptureActive(active)
+ if active then
+ HR.SetupMouselookBindings()
+ CM.DebugPrint("Healing Radial: Activated")
+ else
+ HR.ClearMouselookBindings()
+ -- Restore normal Combat Mode bindings
+ CM.OverrideDefaultButtons()
+ CM.DebugPrint("Healing Radial: Deactivated")
+ end
+end
+
+local function CreateMainFrame()
+ -- Main frame (non-secure, for visuals)
+ local mainFrame = CreateFrame("Frame", "CombatModeHealingRadialFrame", UIParent)
+ mainFrame:SetFrameStrata("DIALOG")
+ mainFrame:SetSize(400, 400)
+ mainFrame:SetPoint("CENTER", 0, CM.DB.global.crosshairY or 50)
+ mainFrame:Hide()
+
+ -- Center indicator
+ local centerDot = mainFrame:CreateTexture(nil, "ARTWORK")
+ centerDot:SetSize(8, 8)
+ centerDot:SetPoint("CENTER")
+ centerDot:SetColorTexture(1, 1, 1, 0.5)
+ mainFrame.centerDot = centerDot
+
+ -- Slice container
+ local sliceContainer = CreateFrame("Frame", nil, mainFrame)
+ sliceContainer:SetAllPoints()
+ RadialState.sliceContainer = sliceContainer
+
+ -- Create slice frames
+ for i = 1, 5 do
+ CreateSliceFrame(i)
+ end
+
+ RadialState.mainFrame = mainFrame
+end
+
+---------------------------------------------------------------------------------------
+-- VISUAL UPDATES --
+---------------------------------------------------------------------------------------
+local function UpdateSliceVisual(sliceIndex)
+ local slice = RadialState.sliceFrames[sliceIndex]
+ local config = CM.DB.global.healingRadial
+
+ -- Find member assigned to this slice
+ local memberData = nil
+ for _, member in ipairs(RadialState.partyData) do
+ if member.sliceIndex == sliceIndex then
+ memberData = member
+ break
+ end
+ end
+
+ if not memberData or not UnitExists(memberData.unitId) then
+ slice:Hide()
+ return
+ end
+
+ slice:Show()
+
+ -- Update name
+ if config.showPlayerNames then
+ local name = memberData.name or "Unknown"
+ -- Truncate long names
+ if #name > 10 then
+ name = name:sub(1, 9) .. "..."
+ end
+ slice.nameText:SetText(name)
+ slice.nameText:Show()
+ else
+ slice.nameText:Hide()
+ end
+
+ -- Update health bar
+ if config.showHealthBars then
+ local health = UnitHealth(memberData.unitId)
+ local maxHealth = UnitHealthMax(memberData.unitId)
+ local healthPercent = maxHealth > 0 and (health / maxHealth) or 1
+
+ local maxWidth = config.sliceSize - 18
+ slice.healthFill:SetWidth(math.max(1, maxWidth * healthPercent))
+
+ -- Color by health level
+ if healthPercent > 0.5 then
+ slice.healthFill:SetColorTexture(unpack(config.healthyColor))
+ elseif healthPercent > 0.25 then
+ slice.healthFill:SetColorTexture(unpack(config.damagedColor))
+ else
+ slice.healthFill:SetColorTexture(unpack(config.criticalColor))
+ end
+
+ slice.healthBG:Show()
+ slice.healthFill:Show()
+
+ -- Health percent text
+ if config.showHealthPercent then
+ slice.healthText:SetText(math.floor(healthPercent * 100) .. "%")
+ slice.healthText:Show()
+ else
+ slice.healthText:Hide()
+ end
+ else
+ slice.healthBG:Hide()
+ slice.healthFill:Hide()
+ slice.healthText:Hide()
+ end
+
+ -- Update role icon
+ if config.showRoleIcons then
+ local roleAtlas = {
+ TANK = "roleicon-tank",
+ HEALER = "roleicon-healer",
+ DAMAGER = "roleicon-dps",
+ }
+ if roleAtlas[memberData.role] then
+ slice.roleIcon:SetAtlas(roleAtlas[memberData.role])
+ slice.roleIcon:Show()
+ else
+ slice.roleIcon:Hide()
+ end
+ else
+ slice.roleIcon:Hide()
+ end
+end
+
+local function UpdateAllSlices()
+ for i = 1, 5 do
+ UpdateSliceVisual(i)
+ end
+end
+
+local function HighlightSlice(sliceIndex)
+ -- Clear all highlights
+ for i = 1, 5 do
+ local slice = RadialState.sliceFrames[i]
+ if slice then
+ slice.highlight:Hide()
+ slice.border:Hide()
+ end
+ end
+
+ -- Highlight the selected slice
+ if sliceIndex and RadialState.sliceFrames[sliceIndex] then
+ local slice = RadialState.sliceFrames[sliceIndex]
+ if slice:IsShown() then
+ slice.highlight:Show()
+ slice.border:Show()
+ end
+ end
+end
+
+---------------------------------------------------------------------------------------
+-- RADIAL CONTROL --
+---------------------------------------------------------------------------------------
+-- Check if the triggering mouse button is still held down
+local function IsMouseButtonStillDown(buttonKey)
+ if not buttonKey then return false end
+
+ -- Check for modifier + button combinations
+ local isShift = _G.IsShiftKeyDown()
+ local isCtrl = _G.IsControlKeyDown()
+ local isAlt = _G.IsAltKeyDown()
+
+ -- Determine which base button we're checking
+ local isButton1 = buttonKey:find("BUTTON1")
+ local isButton2 = buttonKey:find("BUTTON2")
+
+ -- Check the actual mouse button state
+ local mouseDown = false
+ if isButton1 then
+ mouseDown = _G.IsMouseButtonDown("LeftButton")
+ elseif isButton2 then
+ mouseDown = _G.IsMouseButtonDown("RightButton")
+ end
+
+ return mouseDown
+end
+
+local function TrackMousePosition(self, elapsed)
+ if not RadialState.isActive then
+ return
+ end
+
+ -- Check if mouse button was released
+ if not IsMouseButtonStillDown(RadialState.currentButton) then
+ -- Mouse released - cast spell and hide radial
+ HR.ExecuteAndHide()
+ return
+ end
+
+ local angle = GetMouseAngleFromCenter()
+ local newSlice = GetSliceFromAngle(angle)
+
+ -- Only update if slice changed
+ if newSlice ~= RadialState.selectedSlice then
+ RadialState.selectedSlice = newSlice
+ HighlightSlice(newSlice)
+ end
+
+ -- Update health bars periodically
+ UpdateAllSlices()
+end
+
+function HR.Show(buttonKey)
+ if not CM.DB.global.healingRadial or not CM.DB.global.healingRadial.enabled then
+ return false
+ end
+
+ if not IsInGroup() then
+ return false
+ end
+
+ -- Store state
+ RadialState.isActive = true
+ RadialState.currentButton = buttonKey
+ RadialState.selectedSlice = nil
+ RadialState.wasMouselooking = _G.IsMouselooking()
+
+ -- Pause free look
+ if RadialState.wasMouselooking then
+ MouselookStop()
+ end
+
+ -- NOTE: Spell casting happens in ExecuteAndHide() when mouse is released
+ -- This is detected by TrackMousePosition checking IsMouseButtonDown()
+
+ -- Update visuals
+ UpdateAllSlices()
+
+ -- Show UI
+ RadialState.mainFrame:SetAlpha(1)
+ RadialState.mainFrame:Show()
+
+ -- Start mouse tracking
+ RadialState.mainFrame:SetScript("OnUpdate", TrackMousePosition)
+
+ -- Hide crosshair while radial is visible
+ if CM.DB.global.crosshair then
+ CM.DisplayCrosshair(false)
+ end
+
+ CM.DebugPrint("Healing Radial: Shown for " .. buttonKey)
+
+ return true
+end
+
+-- Execute spell on selected target and hide the radial
+-- Called when mouse button is released (detected in TrackMousePosition)
+function HR.ExecuteAndHide()
+ if not RadialState.isActive then
+ return
+ end
+
+ -- Get the override button for the current mouse button
+ local btn = RadialState.overrideButtons and RadialState.overrideButtons[RadialState.currentButton]
+
+ if btn and RadialState.selectedSlice then
+ -- Find the unit for the selected slice
+ local targetUnit = nil
+ for _, member in ipairs(RadialState.partyData) do
+ if member.sliceIndex == RadialState.selectedSlice then
+ targetUnit = member.unitId
+ break
+ end
+ end
+
+ if targetUnit and not InCombatLockdown() then
+ -- Configure the button with spell and target
+ local actionSlot = btn.actionSlot
+ local actionType, actionId = GetActionInfo(actionSlot)
+
+ if actionType == "spell" then
+ btn:SetAttribute("type", "spell")
+ btn:SetAttribute("spell", actionId)
+ elseif actionType == "macro" then
+ btn:SetAttribute("type", "macro")
+ btn:SetAttribute("macro", actionId)
+ elseif actionType == "item" then
+ btn:SetAttribute("type", "item")
+ btn:SetAttribute("item", actionId)
+ else
+ btn:SetAttribute("type", "action")
+ btn:SetAttribute("action", actionSlot)
+ end
+ btn:SetAttribute("unit", targetUnit)
+
+ -- Click the button to cast the spell
+ btn:Click()
+
+ CM.DebugPrint("Healing Radial: Cast on " .. targetUnit .. " (slice " .. RadialState.selectedSlice .. ")")
+
+ -- Clear the button attributes
+ btn:SetAttribute("type", nil)
+ btn:SetAttribute("unit", nil)
+ else
+ CM.DebugPrint("Healing Radial: No valid target or in combat lockdown")
+ end
+ else
+ CM.DebugPrint("Healing Radial: No slice selected, not casting")
+ end
+
+ -- Now hide the radial
+ HR.Hide(false)
+end
+
+function HR.Hide(executeSpell)
+ if not RadialState.isActive then
+ return
+ end
+
+ -- Stop mouse tracking
+ RadialState.mainFrame:SetScript("OnUpdate", nil)
+
+ -- Hide UI
+ RadialState.mainFrame:Hide()
+
+ -- Restore state
+ RadialState.isActive = false
+ RadialState.selectedSlice = nil
+
+ -- Restore crosshair
+ if CM.DB.global.crosshair then
+ CM.DisplayCrosshair(true)
+ end
+
+ -- Resume free look if it was active before
+ if RadialState.wasMouselooking then
+ MouselookStart()
+ end
+
+ CM.DebugPrint("Healing Radial: Hidden")
+end
+
+function HR.IsActive()
+ return RadialState.isActive
+end
+
+function HR.IsEnabled()
+ return CM.DB.global.healingRadial and CM.DB.global.healingRadial.enabled and IsInGroup()
+end
+
+---------------------------------------------------------------------------------------
+-- EVENT HANDLING --
+---------------------------------------------------------------------------------------
+function HR.OnGroupRosterUpdate()
+ RefreshPartyData()
+ UpdateSecureButtonTargets()
+
+ if RadialState.isActive then
+ UpdateAllSlices()
+ end
+end
+
+function HR.OnCombatEnd()
+ -- Apply any pending updates
+ if RadialState.pendingUpdate then
+ UpdateSecureButtonTargets()
+ end
+end
+
+---------------------------------------------------------------------------------------
+-- INITIALIZATION --
+---------------------------------------------------------------------------------------
+function HR.Initialize()
+ -- Ensure defaults exist
+ if not CM.DB.global.healingRadial then
+ CM.DB.global.healingRadial = CM.Constants.DatabaseDefaults.global.healingRadial
+ end
+
+ CreateMainFrame()
+ CreateSecureButtons()
+ CreateMouseOverrideButtons()
+
+ -- Initial party data
+ RefreshPartyData()
+ UpdateSecureButtonTargets()
+
+ -- If healing radial is already enabled, set up the bindings
+ if CM.DB.global.healingRadial.enabled then
+ HR.SetupMouselookBindings()
+ end
+
+ CM.DebugPrint("Healing Radial: Initialized")
+end
+
+-- Expose state for Core.lua integration
+function HR.GetState()
+ return RadialState
+end