-- SoulstoneTracker -- Tracks soulstones cast by warlocks on raid members -- Author: Waylock SoulstoneTracker = {} local m = SoulstoneTracker local SOULSTONE_DURATION = 30 * 60 local FONT = "Fonts\\FRIZQT__.TTF" local FRAME_W = 380 local ROW_H = 18 local TITLE_H = 24 local COL1 = 8 -- Caster x local COL2 = 148 -- Arrow x local COL3 = 172 -- Target x local COL4 = 300 -- Expires x m.stones = {} m.frame = nil ------------------------------------------------------------------------------- -- Utility ------------------------------------------------------------------------------- local function timeLeft(expires) return math.max(0, expires - time()) end local function formatTime(secs) if secs <= 0 then return "|cffff3333EXPIRED|r" elseif secs <= 300 then return "|cffff7c0a"..string.format("%dm %02ds", math.floor(secs/60), math.mod(secs,60)).."|r" else return "|cff00ff98"..string.format("%dm %02ds", math.floor(secs/60), math.mod(secs,60)).."|r" end end local function say(msg) DEFAULT_CHAT_FRAME:AddMessage("|cffa050ff[SS]|r " .. msg) end ------------------------------------------------------------------------------- -- Tracking ------------------------------------------------------------------------------- function m.addStone(target, caster) m.stones[target] = { caster = caster, expires = time() + SOULSTONE_DURATION } say("|cffa050ff"..caster.."|r soulstoned |cffffffff"..target.."|r") m.refresh() end function m.removeStone(target) if m.stones[target] then m.stones[target] = nil m.refresh() end end function m.clearExpired() local now = time() local changed = false for target, data in pairs(m.stones) do if now >= data.expires then say("|cffaaaaaa Soulstone expired on |cffffffff"..target.."|r") m.stones[target] = nil changed = true end end if changed then m.refresh() end end ------------------------------------------------------------------------------- -- UI ------------------------------------------------------------------------------- function m.createFrame() -- Outer frame local f = CreateFrame("Frame", "SoulstoneTrackerFrame", UIParent) f:SetWidth(FRAME_W) f:SetHeight(TITLE_H + 22 + ROW_H) f:SetFrameStrata("MEDIUM") f:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left=1, right=1, top=1, bottom=1 } }) f:SetBackdropColor(0.05, 0.05, 0.08, 0.95) f:SetBackdropBorderColor(0.25, 0.25, 0.35, 1) f:EnableMouse(true) f:SetMovable(true) f:SetClampedToScreen(true) f:RegisterForDrag("LeftButton") f:SetScript("OnDragStart", function() this:StartMoving() end) f:SetScript("OnDragStop", function() this:StopMovingOrSizing() local point, _, rpoint, x, y = this:GetPoint() SoulstoneTrackerDB.position = { point=point, rpoint=rpoint, x=x, y=y } end) -- Title bar - full width purple gradient local titleBar = CreateFrame("Frame", nil, f) titleBar:SetPoint("TopLeft", f, "TopLeft", 1, -1) titleBar:SetPoint("TopRight", f, "TopRight", -1, -1) titleBar:SetHeight(TITLE_H) titleBar:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) titleBar:SetBackdropColor(0.22, 0.1, 0.38, 1) -- Gradient overlay on title bar local grad = titleBar:CreateTexture(nil, "OVERLAY") grad:SetAllPoints(titleBar) grad:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") grad:SetBlendMode("ADD") grad:SetVertexColor(0.3, 0.1, 0.5, 0.5) -- Title text local title = titleBar:CreateFontString(nil, "OVERLAY") title:SetFont(FONT, 11, "OUTLINE") title:SetPoint("Left", titleBar, "Left", 8, 0) title:SetText("|cffffd700Soulstone Tracker v1.0|r") -- X close button (plain text style like Threatmeter) local closeBtn = CreateFrame("Button", nil, titleBar) closeBtn:SetWidth(16) closeBtn:SetHeight(16) closeBtn:SetPoint("Right", titleBar, "Right", -4, 0) local closeTxt = closeBtn:CreateFontString(nil, "OVERLAY") closeTxt:SetFont(FONT, 12, "OUTLINE") closeTxt:SetAllPoints(closeBtn) closeTxt:SetJustifyH("Center") closeTxt:SetText("|cffaaaaааX|r") closeBtn:SetScript("OnEnter", function() closeTxt:SetText("|cffffffffX|r") end) closeBtn:SetScript("OnLeave", function() closeTxt:SetText("|cffaaaaaaX|r") end) closeBtn:SetScript("OnClick", function() f:Hide() SoulstoneTrackerDB.hidden = true end) -- Column header row local hY = -(TITLE_H + 4) local hCaster = f:CreateFontString(nil, "OVERLAY") hCaster:SetFont(FONT, 10, "OUTLINE") hCaster:SetTextColor(1, 1, 1, 1) hCaster:SetPoint("TopLeft", f, "TopLeft", COL1, hY) hCaster:SetText("Caster") local hTarget = f:CreateFontString(nil, "OVERLAY") hTarget:SetFont(FONT, 10, "OUTLINE") hTarget:SetTextColor(1, 1, 1, 1) hTarget:SetPoint("TopLeft", f, "TopLeft", COL3, hY) hTarget:SetText("Target") local hExpires = f:CreateFontString(nil, "OVERLAY") hExpires:SetFont(FONT, 10, "OUTLINE") hExpires:SetTextColor(1, 1, 1, 1) hExpires:SetPoint("TopLeft", f, "TopLeft", COL4, hY) hExpires:SetText("Expires") -- Thin divider under headers local div = f:CreateTexture(nil, "ARTWORK") div:SetHeight(1) div:SetPoint("TopLeft", f, "TopLeft", 1, -(TITLE_H + 18)) div:SetPoint("TopRight", f, "TopRight", -1, -(TITLE_H + 18)) div:SetTexture(0.3, 0.2, 0.5, 0.8) -- Empty state local empty = f:CreateFontString(nil, "OVERLAY") empty:SetFont(FONT, 10, "OUTLINE") empty:SetTextColor(0.4, 0.4, 0.4, 1) empty:SetPoint("TopLeft", f, "TopLeft", COL1, -(TITLE_H + 22)) empty:SetText("No active soulstones") f.empty = empty -- Data rows f.rows = {} for i = 1, 10 do local rowY = -(TITLE_H + 20 + (i-1) * ROW_H) -- Alternating row background local bg = f:CreateTexture(nil, "BACKGROUND") bg:SetPoint("TopLeft", f, "TopLeft", 1, rowY) bg:SetPoint("TopRight", f, "TopRight", -1, rowY) bg:SetHeight(ROW_H) if math.mod(i, 2) == 0 then bg:SetTexture(1, 1, 1, 0.04) else bg:SetTexture(0, 0, 0, 0) end local caster = f:CreateFontString(nil, "OVERLAY") caster:SetFont(FONT, 11, "OUTLINE") caster:SetPoint("TopLeft", f, "TopLeft", COL1, rowY) caster:SetWidth(COL2 - COL1 - 4) caster:SetJustifyH("Left") local arrow = f:CreateFontString(nil, "OVERLAY") arrow:SetFont(FONT, 11, "OUTLINE") arrow:SetTextColor(0.4, 0.4, 0.4, 1) arrow:SetPoint("TopLeft", f, "TopLeft", COL2, rowY) arrow:SetText("->") local target = f:CreateFontString(nil, "OVERLAY") target:SetFont(FONT, 11, "OUTLINE") target:SetPoint("TopLeft", f, "TopLeft", COL3, rowY) target:SetWidth(COL4 - COL3 - 4) target:SetJustifyH("Left") local timer = f:CreateFontString(nil, "OVERLAY") timer:SetFont(FONT, 11, "OUTLINE") timer:SetPoint("TopLeft", f, "TopLeft", COL4, rowY) timer:SetJustifyH("Left") f.rows[i] = { caster=caster, arrow=arrow, target=target, timer=timer } end -- Resize grip (bottom-right corner) f:SetResizable(true) local grip = CreateFrame("Button", nil, f) grip:SetWidth(12) grip:SetHeight(12) grip:SetPoint("BottomRight", f, "BottomRight", -2, 2) local gripTex = grip:CreateTexture(nil, "OVERLAY") gripTex:SetAllPoints(grip) gripTex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") grip:SetScript("OnEnter", function() gripTex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") end) grip:SetScript("OnLeave", function() gripTex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") end) grip:SetScript("OnMouseDown", function() f:StartSizing("BOTTOMRIGHT") end) grip:SetScript("OnMouseUp", function() f:StopMovingOrSizing() local w, h = f:GetWidth(), f:GetHeight() SoulstoneTrackerDB.size = { w = w, h = h } end) -- Ticker local tick = 0 f:SetScript("OnUpdate", function() tick = tick + arg1 if tick >= 1 then tick = 0 m.clearExpired() m.refresh() end end) -- Restore saved position if SoulstoneTrackerDB and SoulstoneTrackerDB.position then local p = SoulstoneTrackerDB.position f:ClearAllPoints() f:SetPoint(p.point, UIParent, p.rpoint, p.x, p.y) else f:SetPoint("Center", UIParent, "Center", 300, 0) end -- Restore saved size if SoulstoneTrackerDB and SoulstoneTrackerDB.size then local s = SoulstoneTrackerDB.size f:SetWidth(s.w) f:SetHeight(s.h) end m.frame = f m.refresh() end function m.refresh() if not m.frame then return end local f = m.frame local list = {} for target, data in pairs(m.stones) do table.insert(list, { target=target, caster=data.caster, expires=data.expires }) end table.sort(list, function(a,b) return a.expires < b.expires end) local count = table.getn(list) if count == 0 then f.empty:Show() else f.empty:Hide() end for i = 1, 10 do local row = f.rows[i] local data = list[i] if data then row.caster:SetText("|cffa050ff"..data.caster.."|r") row.arrow:SetText("->") row.target:SetText("|cffffffff"..data.target.."|r") row.timer:SetText(formatTime(timeLeft(data.expires))) else row.caster:SetText("") row.arrow:SetText("") row.target:SetText("") row.timer:SetText("") end end local rows = math.max(1, count) f:SetHeight(TITLE_H + 20 + (rows * ROW_H) + 6) end ------------------------------------------------------------------------------- -- Events ------------------------------------------------------------------------------- local eFrame = CreateFrame("Frame") eFrame:RegisterEvent("PLAYER_LOGIN") eFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") eFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") eFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER") eFrame:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF") eFrame:RegisterEvent("CHAT_MSG_SPELL_PARTY_BUFF") eFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") eFrame:SetScript("OnEvent", function() if event == "PLAYER_LOGIN" then SoulstoneTrackerDB = SoulstoneTrackerDB or {} m.createFrame() if SoulstoneTrackerDB.hidden then m.frame:Hide() else m.frame:Show() end say("Loaded. |cffffd700/ss|r toggle |cffffd700/ss test|r preview |cffffd700/ss clear|r reset") elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then if arg1 and string.find(arg1, "Soulstone Resurrection") then m.addStone(UnitName("player"), "Unknown") end elseif event == "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF" or event == "CHAT_MSG_SPELL_PARTY_BUFF" then if arg1 then local caster, target = string.match(arg1, "(.+)'s Soulstone Resurrection is cast on (.+)%.") if caster and target then m.addStone(target, caster) return end local gained = string.match(arg1, "(.+) gains Soulstone Resurrection%.") if gained then m.addStone(gained, "Unknown") end end elseif event == "CHAT_MSG_SPELL_AURA_GONE_SELF" then if arg1 and string.find(arg1, "Soulstone Resurrection") then m.removeStone(UnitName("player")) end elseif event == "CHAT_MSG_SPELL_AURA_GONE_OTHER" then if arg1 then local target = string.match(arg1, "Soulstone Resurrection fades from (.+)%.") if target then m.removeStone(target) end end elseif event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" then if arg1 then local dead = string.match(arg1, "(.+) dies%.") if dead and m.stones[dead] then m.removeStone(dead) end end end end) ------------------------------------------------------------------------------- -- Slash ------------------------------------------------------------------------------- SLASH_SOULSTONETRACKER1 = "/ss" SLASH_SOULSTONETRACKER2 = "/soulstone" SlashCmdList["SOULSTONETRACKER"] = function(args) args = string.lower(args or "") args = string.gsub(args, "^%s*(.-)%s*$", "%1") if args == "clear" then m.stones = {} m.refresh() say("Cleared.") elseif args == "test" then m.addStone(UnitName("player"), UnitName("player")) say("Test stone added.") elseif args == "list" then local n = 0 for target, data in pairs(m.stones) do local s = timeLeft(data.expires) say(data.caster.." -> "..target.." ("..math.floor(s/60).."m "..math.mod(s,60).."s)") n = n + 1 end if n == 0 then say("No active soulstones.") end else if m.frame and m.frame:IsVisible() then m.frame:Hide() SoulstoneTrackerDB.hidden = true say("Hidden.") else if not m.frame then m.createFrame() end m.frame:Show() SoulstoneTrackerDB.hidden = false say("Shown.") end end end