From fb5230828caa2ff82d48d1676751480264bece15 Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:01:00 -0500 Subject: [PATCH] Add Loot History module A pfUI-native group-loot roll history window built on ClassicAPI's C_LootHistory backport, adapted from the anniversary Blizzard reference. - Movable/scrollable window (ESC-closable, Clear button) listing rolled items; each row expands to per-player rolls. - Item icon/name/quality rendered via the !!!ClassicAPI Item mixin (Item:CreateFromItemLink + ContinueOnItemLoad), so uncached items load asynchronously and repaint their row. - Winner shown on the collapsed row (name/roll/roll-type) and marked in the expanded list with a checkmark left of the name (matches the reference). - Expansion state keys on the stable rollID; events (FULL_UPDATE / ROLL_CHANGED / ROLL_COMPLETE) drive a rebuild while shown, and re-attach the scroll child so a growing list scrolls without a /reload. - Toggle via /loothistory or /lh; optional auto-show on new rolls behind loothistory.autoshow (default off). --- api/config.lua | 2 + env/translations_enUS.lua | 4 + init/modules.xml | 1 + modules/loothistory.lua | 390 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 modules/loothistory.lua diff --git a/api/config.lua b/api/config.lua index bfbe0733..ee777361 100644 --- a/api/config.lua +++ b/api/config.lua @@ -196,6 +196,8 @@ function pfUI:LoadConfig() pfUI:UpdateConfig("loot", nil, "rollannounce", "0") pfUI:UpdateConfig("loot", nil, "raritytimer", "1") + pfUI:UpdateConfig("loothistory", nil, "autoshow", "0") + pfUI:UpdateConfig("unitframes", nil, "disable", "0") pfUI:UpdateConfig("unitframes", nil, "pastel", "1") pfUI:UpdateConfig("unitframes", nil, "custom", "0") diff --git a/env/translations_enUS.lua b/env/translations_enUS.lua index ad6166e3..18d6bcf1 100644 --- a/env/translations_enUS.lua +++ b/env/translations_enUS.lua @@ -19,6 +19,7 @@ pfUI_translation["enUS"] = { ["Align Chat Windows"] = nil, ["Aligned Position"] = nil, ["All messages will be forwarded to:"] = nil, + ["All players passed"] = nil, ["Alt-Click Action"] = nil, ["Always Allow Drag Via Shift Key"] = nil, ["Always Show"] = nil, @@ -119,6 +120,7 @@ pfUI_translation["enUS"] = { ["Chat Bubble Transparency"] = nil, ["Chat Default Brackets"] = nil, ["Class"] = nil, + ["Clear"] = nil, ["Clear Rolls"] = nil, ["Click Action"] = nil, ["Click Casting"] = nil, @@ -478,6 +480,7 @@ pfUI_translation["enUS"] = { ["Look & Feel"] = nil, ["Loot"] = nil, ["Loot & Spam"] = nil, + ["Loot History"] = nil, ["Macro Text Color"] = nil, ["Macro Text Size"] = nil, ["Main Actionbar"] = nil, @@ -648,6 +651,7 @@ pfUI_translation["enUS"] = { ["Resting"] = nil, ["Reveal Unexplored Areas"] = nil, ["Switch to current zone"] = nil, + ["Retrieving item information..."] = nil, ["Right"] = nil, ["Right Actionbar"] = nil, ["Right Anchor"] = nil, diff --git a/init/modules.xml b/init/modules.xml index a4caf30a..949eda24 100644 --- a/init/modules.xml +++ b/init/modules.xml @@ -78,4 +78,5 @@ + \ No newline at end of file diff --git a/modules/loothistory.lua b/modules/loothistory.lua new file mode 100644 index 00000000..ebaa47ae --- /dev/null +++ b/modules/loothistory.lua @@ -0,0 +1,390 @@ +pfUI:RegisterNewModule("loothistory", "Loot History") +pfUI:RegisterModule("loothistory", function () + local rawborder, border = GetBorderSize() + + -- Layout + local ITEM_H, PLAYER_H = 24, 18 + local ITEM_W, PLAYER_W = 350, 330 + + -- rollType constants returned by C_LootHistory.GetPlayerInfo (0/1/2; vanilla + -- has no disenchant roll). + local ROLL_PASS, ROLL_NEED, ROLL_GREED = 0, 1, 2 + local ROLL_TEX = { + [ROLL_NEED] = "Interface\\Buttons\\UI-GroupLoot-Dice-Up", + [ROLL_GREED] = "Interface\\Buttons\\UI-GroupLoot-Coin-Up", + [ROLL_PASS] = "Interface\\Buttons\\UI-GroupLoot-Pass-Up", + } + local WINMARK = "Interface\\Buttons\\UI-CheckBox-Check" + local QUESTIONMARK = "Interface\\Icons\\INV_Misc_QuestionMark" + + local function ClassColor(class) + local c = class and RAID_CLASS_COLORS[class] + if c then return c.r, c.g, c.b end + return 1, 1, 1 + end + + -- Paint an item row's icon/name/quality from a loaded Item mixin. + local function RenderItemVisual(f, item) + local r, g, b = 1, 1, 1 + local qc = item:GetItemQualityColor() + if qc then r, g, b = qc.r, qc.g, qc.b end + f.icon:SetTexture(item:GetItemIcon()) + f.iconbg:SetBackdropBorderColor(r, g, b, 1) + f.name:SetText(item:GetItemName() or UNKNOWN) + f.name:SetTextColor(r, g, b) + end + + local function ShowRetrieving(f) + f.icon:SetTexture(QUESTIONMARK) + f.iconbg:SetBackdropBorderColor(1, .3, .3, 1) + f.name:SetText(T["Retrieving item information..."]) + f.name:SetTextColor(1, .3, .3) + end + + -- expansion state keyed on the stable rollID (survives ring-index shifts) + local expanded = {} + + -- ========================================================================== + -- Window + -- ========================================================================== + pfUI.loothistory = CreateFrame("Frame", "pfLootHistory", UIParent) + pfUI.loothistory:SetFrameStrata("DIALOG") + pfUI.loothistory:SetSize(380, 490) + pfUI.loothistory:SetPoint("CENTER", 0, 0) + pfUI.loothistory:SetMovable(true) + pfUI.loothistory:EnableMouse(true) + pfUI.loothistory:RegisterForDrag("LeftButton") + pfUI.loothistory:SetScript("OnDragStart", function() this:StartMoving() end) + pfUI.loothistory:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + pfUI.loothistory:Hide() + + CreateBackdrop(pfUI.loothistory, nil, true, .75) + CreateBackdropShadow(pfUI.loothistory) + tinsert(UISpecialFrames, "pfLootHistory") + + pfUI.loothistory.caption = pfUI.loothistory:CreateFontString("Status", "LOW", "GameFontNormal") + pfUI.loothistory.caption:SetFont(pfUI.font_default, C.global.font_size + 4, "OUTLINE") + pfUI.loothistory.caption:SetTextColor(.2, 1, .8, 1) + pfUI.loothistory.caption:SetPoint("TOP", 0, -10) + pfUI.loothistory.caption:SetText(T["Loot History"]) + + -- close button + pfUI.loothistory.close = CreateFrame("Button", nil, pfUI.loothistory) + pfUI.loothistory.close:SetPoint("TOPRIGHT", -border*2, -border*2) + CreateBackdrop(pfUI.loothistory.close) + pfUI.loothistory.close:SetSize(15, 15) + pfUI.loothistory.close.texture = pfUI.loothistory.close:CreateTexture("pfLootHistoryClose") + pfUI.loothistory.close.texture:SetTexture(pfUI.media["img:close"]) + pfUI.loothistory.close.texture:SetPoint("TOPLEFT", pfUI.loothistory.close, "TOPLEFT", 4, -4) + pfUI.loothistory.close.texture:SetPoint("BOTTOMRIGHT", pfUI.loothistory.close, "BOTTOMRIGHT", -4, 4) + pfUI.loothistory.close.texture:SetVertexColor(1, .25, .25, 1) + pfUI.loothistory.close:SetScript("OnEnter", function() + CreateBackdrop(pfUI.loothistory.close) + pfUI.loothistory.close.backdrop:SetBackdropBorderColor(1, .25, .25, 1) + end) + pfUI.loothistory.close:SetScript("OnLeave", function() CreateBackdrop(pfUI.loothistory.close) end) + pfUI.loothistory.close:SetScript("OnClick", function() pfUI.loothistory:Hide() end) + + -- clear button + pfUI.loothistory.clear = CreateFrame("Button", nil, pfUI.loothistory, "UIPanelButtonTemplate") + SkinButton(pfUI.loothistory.clear) + pfUI.loothistory.clear:SetSize(60, 16) + pfUI.loothistory.clear:SetPoint("TOPLEFT", 10, -8) + pfUI.loothistory.clear:SetText(T["Clear"]) + pfUI.loothistory.clear:SetScript("OnClick", function() C_LootHistory.Clear() end) + + -- scroll frame + pfUI.loothistory.scroll = CreateScrollFrame("pfLootHistoryScroll", pfUI.loothistory) + pfUI.loothistory.scroll:SetSize(360, 440) + pfUI.loothistory.scroll:SetPoint("BOTTOM", 0, 10) + + pfUI.loothistory.scroll.backdrop = CreateFrame("Frame", nil, pfUI.loothistory.scroll) + pfUI.loothistory.scroll.backdrop:SetFrameLevel(1) + pfUI.loothistory.scroll.backdrop:SetPoint("TOPLEFT", pfUI.loothistory.scroll, "TOPLEFT", -5, 5) + pfUI.loothistory.scroll.backdrop:SetPoint("BOTTOMRIGHT", pfUI.loothistory.scroll, "BOTTOMRIGHT", 5, -5) + CreateBackdrop(pfUI.loothistory.scroll.backdrop, nil, true) + + local list = CreateScrollChild("pfLootHistoryList", pfUI.loothistory.scroll) + pfUI.loothistory.list = list + + -- ========================================================================== + -- Frame pools + -- ========================================================================== + local itemFrames = {} + local usedPlayers, freePlayers = {}, {} + + local FullUpdate -- forward declaration (toggle handlers call it) + + local function CreateItemFrame() + local f = CreateFrame("Button", nil, list) + f:SetSize(ITEM_W, ITEM_H) + f:SetBackdrop(pfUI.backdrop_hover) + f:SetBackdropBorderColor(1, 1, 1, .04) + f:EnableMouse(1) + + -- expand / collapse toggle + f.toggle = CreateFrame("Button", nil, f) + f.toggle:SetSize(14, 14) + f.toggle:SetPoint("LEFT", 4, 0) + f.toggle:SetScript("OnClick", function() + local id = f.rollID + if id then expanded[id] = not expanded[id]; FullUpdate() end + end) + + -- icon + quality-colored border + f.iconbg = CreateFrame("Frame", nil, f) + f.iconbg:SetSize(ITEM_H - 8, ITEM_H - 8) + f.iconbg:SetPoint("LEFT", f.toggle, "RIGHT", 4, 0) + CreateBackdrop(f.iconbg, nil, true) + f.icon = f.iconbg:CreateTexture(nil, "ARTWORK") + f.icon:SetPoint("TOPLEFT", f.iconbg, "TOPLEFT", 2, -2) + f.icon:SetPoint("BOTTOMRIGHT", f.iconbg, "BOTTOMRIGHT", -2, 2) + f.icon:SetTexCoord(.08, .92, .08, .92) + + -- winner block (right side, shown for decided rolls) + f.winicon = f:CreateTexture(nil, "OVERLAY") + f.winicon:SetSize(14, 14) + f.winicon:SetPoint("RIGHT", f, "RIGHT", -6, 0) + + f.winroll = f:CreateFontString("Status", "OVERLAY", "GameFontNormal") + f.winroll:SetFont(pfUI.font_default, C.global.font_size, "OUTLINE") + f.winroll:SetPoint("RIGHT", f.winicon, "LEFT", -2, 0) + f.winroll:SetTextColor(1, 1, 1, 1) + + f.winname = f:CreateFontString("Status", "OVERLAY", "GameFontNormal") + f.winname:SetFont(pfUI.font_default, C.global.font_size, "OUTLINE") + f.winname:SetPoint("RIGHT", f.winroll, "LEFT", -4, 0) + f.winname:SetJustifyH("RIGHT") + + -- item name (leaves room on the right for the winner block) + f.name = f:CreateFontString("Status", "OVERLAY", "GameFontNormal") + f.name:SetFont(pfUI.font_default, C.global.font_size, "OUTLINE") + f.name:SetPoint("LEFT", f.iconbg, "RIGHT", 5, 0) + f.name:SetPoint("RIGHT", f, "RIGHT", -100, 0) + f.name:SetJustifyH("LEFT") + + f:SetScript("OnEnter", function() + this:SetBackdropBorderColor(1, 1, 1, .08) + if this.itemLink then + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetHyperlink(this.itemLink) + GameTooltip:Show() + end + end) + f:SetScript("OnLeave", function() + this:SetBackdropBorderColor(1, 1, 1, .04) + GameTooltip:Hide() + end) + f:SetScript("OnClick", function() + local id = this.rollID + if id then expanded[id] = not expanded[id]; FullUpdate() end + end) + + return f + end + + local function CreatePlayerFrame() + local f = CreateFrame("Frame", nil, list) + f:SetSize(PLAYER_W, PLAYER_H) + + -- name is indented to leave room for the winner checkmark on its left + f.name = f:CreateFontString("Status", "OVERLAY", "GameFontNormal") + f.name:SetFont(pfUI.font_default, C.global.font_size, "OUTLINE") + f.name:SetPoint("LEFT", 20, 0) + f.name:SetJustifyH("LEFT") + + f.rollicon = f:CreateTexture(nil, "OVERLAY") + f.rollicon:SetSize(16, 16) + f.rollicon:SetPoint("RIGHT", -4, 0) + + f.rolltext = f:CreateFontString("Status", "OVERLAY", "GameFontNormal") + f.rolltext:SetFont(pfUI.font_default, C.global.font_size, "OUTLINE") + f.rolltext:SetPoint("RIGHT", f.rollicon, "LEFT", -3, 0) + f.rolltext:SetTextColor(1, 1, 1, 1) + + -- winner checkmark, just left of the player name (matches reference) + f.winmark = f:CreateTexture(nil, "OVERLAY") + f.winmark:SetSize(16, 16) + f.winmark:SetTexture(WINMARK) + f.winmark:SetPoint("RIGHT", f.name, "LEFT", -1, 0) + + return f + end + + local function RecycleAllPlayers() + for i = 1, table.getn(usedPlayers) do + local pf = usedPlayers[i] + pf:Hide() + table.insert(freePlayers, pf) + end + usedPlayers = {} + end + + local function GetPlayerFrame() + local pf = table.remove(freePlayers) or CreatePlayerFrame() + table.insert(usedPlayers, pf) + return pf + end + + local function SetToggleTexture(toggle, isExpanded) + if isExpanded then + toggle:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up") + toggle:SetPushedTexture("Interface\\Buttons\\UI-MinusButton-Down") + else + toggle:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up") + toggle:SetPushedTexture("Interface\\Buttons\\UI-PlusButton-Down") + end + end + + -- ========================================================================== + -- Rendering + -- ========================================================================== + local function UpdateItemFrame(f, i) + local rollID, itemLink, numPlayers, isDone, winnerIdx = C_LootHistory.GetItem(i) + f.rollID = rollID + f.itemIdx = i + f.itemLink = itemLink + f.numPlayers = numPlayers or 0 + f.isDone = isDone + + local isExpanded = rollID and expanded[rollID] + SetToggleTexture(f.toggle, isExpanded) + + -- Item icon/name/quality via the ClassicAPI Item mixin. When the item + -- isn't cached yet, show a placeholder and re-paint this row from the + -- ContinueOnItemLoad callback (guarded on rollID: rows are pooled, so the + -- callback must no-op if the row has since been reused for another roll). + local item = itemLink and Item:CreateFromItemLink(itemLink) + if item and not item:IsItemEmpty() then + if item:IsItemDataCached() then + RenderItemVisual(f, item) + else + ShowRetrieving(f) + local pending = rollID + item:ContinueOnItemLoad(function() + if f.rollID == pending then RenderItemVisual(f, item) end + end) + end + else + ShowRetrieving(f) + end + + -- winner summary only on a decided, collapsed row + if isDone and not isExpanded then + if winnerIdx then + local wname, wclass, wrollType, wroll = C_LootHistory.GetPlayerInfo(i, winnerIdx) + f.winicon:SetTexture(ROLL_TEX[wrollType] or ROLL_TEX[ROLL_NEED]) + f.winicon:Show() + if wroll and wroll > 0 then f.winroll:SetText(wroll) else f.winroll:SetText("") end + f.winroll:Show() + f.winname:SetText(wname or UNKNOWN) + f.winname:SetTextColor(ClassColor(wclass)) + f.winname:Show() + else + -- nobody won: everyone passed + f.winicon:SetTexture(ROLL_TEX[ROLL_PASS]) + f.winicon:Show() + f.winroll:SetText("") + f.winroll:Show() + f.winname:SetText(T["All players passed"]) + f.winname:SetTextColor(1, .4, .4) + f.winname:Show() + end + else + f.winicon:Hide() + f.winroll:Hide() + f.winname:Hide() + end + end + + local function RenderPlayerFrame(pf, name, class, rollType, roll, isWinner) + pf.name:SetText(name or UNKNOWN) + pf.name:SetTextColor(ClassColor(class)) + + pf.rollicon:SetTexture(ROLL_TEX[rollType] or ROLL_TEX[ROLL_PASS]) + if roll and roll > 0 then pf.rolltext:SetText(roll) else pf.rolltext:SetText("") end + + if isWinner then pf.winmark:Show() else pf.winmark:Hide() end + end + + -- A player row is worth showing while the roll is undecided (see everyone), + -- or afterwards only if they actually rolled or it's you (hide the passers' + -- noise on a decided roll) — mirrors Blizzard's ShouldDisplayPlayer. + local function ShouldDisplayPlayer(isDone, roll, isMe) + return isMe or (roll and roll > 0) or not isDone + end + + function FullUpdate() + if not pfUI.loothistory:IsShown() then return end + RecycleAllPlayers() + + local num = C_LootHistory.GetNumItems() + local y = -2 + + for i = 1, num do + local f = itemFrames[i] or CreateItemFrame() + itemFrames[i] = f + UpdateItemFrame(f, i) + f:ClearAllPoints() + f:SetPoint("TOPLEFT", list, "TOPLEFT", 4, y) + f:Show() + y = y - ITEM_H - 2 + + if f.rollID and expanded[f.rollID] then + for p = 1, f.numPlayers do + local name, class, rollType, roll, isWinner, isMe = C_LootHistory.GetPlayerInfo(i, p) + if ShouldDisplayPlayer(f.isDone, roll, isMe) then + local pf = GetPlayerFrame() + RenderPlayerFrame(pf, name, class, rollType, roll, isWinner) + pf:ClearAllPoints() + pf:SetPoint("TOPLEFT", list, "TOPLEFT", 22, y) + pf:Show() + y = y - PLAYER_H + end + end + y = y - 2 + end + end + + for i = num + 1, table.getn(itemFrames) do + itemFrames[i]:Hide() + end + + -- Resize the scroll child and re-attach it: SetHeight alone on a + -- SetAllPoints'd child doesn't make the ScrollFrame recompute its scroll + -- range, so a dynamically-grown list wouldn't scroll until a /reload. + list:SetHeight(math.max(1, -y + 2)) + pfUI.loothistory.scroll:SetScrollChild(list) + pfUI.loothistory.scroll:UpdateScrollState() + end + + pfUI.loothistory:SetScript("OnShow", function() FullUpdate() end) + + -- ========================================================================== + -- Events + -- ========================================================================== + local events = CreateFrame("Frame") + events:RegisterEvent("LOOT_HISTORY_FULL_UPDATE") + events:RegisterEvent("LOOT_HISTORY_ROLL_CHANGED") + events:RegisterEvent("LOOT_HISTORY_ROLL_COMPLETE") + events:SetScript("OnEvent", function() + -- auto-show on a new roll opening / completing, if enabled + if C.loothistory.autoshow == "1" + and (event == "LOOT_HISTORY_FULL_UPDATE" or event == "LOOT_HISTORY_ROLL_COMPLETE") + and not pfUI.loothistory:IsShown() then + pfUI.loothistory:Show() -- OnShow runs FullUpdate + return + end + FullUpdate() -- no-op while hidden + end) + + -- ========================================================================== + -- Slash command + -- ========================================================================== + local function Toggle() + pfUI.loothistory:SetShown(not pfUI.loothistory:IsShown()) + end + + _G.SLASH_PFLOOTHISTORY1, _G.SLASH_PFLOOTHISTORY2 = "/loothistory", "/pfloothistory" + _G.SlashCmdList.PFLOOTHISTORY = Toggle +end)