From c0f1ecc71340948c6d21f0bacca95c6c36284350 Mon Sep 17 00:00:00 2001 From: rucky Date: Tue, 24 Mar 2026 15:56:28 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E7=89=88=E6=9C=AC=200.8.19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/mcp.json | 13 + .cursor/rules/mcp-messenger.mdc | 27 ++ ActionBars.lua | 7 + BeastTrainingUI.lua | 85 ++++-- CharacterPanel.lua | 16 ++ Chat.lua | 62 ++-- ConfigUI.lua | 4 +- Core.lua | 1 + DarkmoonGuide.lua | 9 +- LootDisplay.lua | 485 +++++++++++++++++++++---------- Nanami-UI.toc | 1 + Tooltip.lua | 10 + TradeSkillUI.lua | 48 +++- Tweaks.lua | 124 ++++++-- Units/Pet.lua | 434 +++++++++++++++++++++++++++- Units/Player.lua | 70 ++++- Units/Target.lua | 493 ++++++++++++++++++++++++++++++-- ZoneLevelRange.lua | 385 +++++++++++++++++++++++++ docs/LootDisplay-技术要点.md | 212 ++++++++++++++ 19 files changed, 2227 insertions(+), 259 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/mcp-messenger.mdc create mode 100644 ZoneLevelRange.lua create mode 100644 docs/LootDisplay-技术要点.md diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..4fb4a2e --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "CursorMCP对话插件": { + "command": "node", + "args": [ + "c:\\Users\\rucky\\.cursor\\extensions\\local.xw-cursor-message-8.8.8\\dist\\mcp-server.mjs" + ], + "env": { + "MESSENGER_DATA_DIR": "C:\\Users\\rucky\\.moyu-message\\46dbbb48af19" + } + } + } +} \ No newline at end of file diff --git a/.cursor/rules/mcp-messenger.mdc b/.cursor/rules/mcp-messenger.mdc new file mode 100644 index 0000000..9745167 --- /dev/null +++ b/.cursor/rules/mcp-messenger.mdc @@ -0,0 +1,27 @@ +--- +description: "自动调用 Messenger MCP 检查待发送消息" +globs: "**/*" +alwaysApply: true +--- + +# MCP 工具调用规则 + +1. 每轮对话回复**完整输出后**,最后一步必须调用 `check_messages`,适用于 Agent/Plan/Debug/Ask 所有模式,无一例外 +2. `check_messages` 会持续等待直到用户发来新消息;收到后处理并回复,然后再次调用 `check_messages`,形成永续循环:回复 → check_messages → 收到消息 → 回复 → check_messages +3. 严禁在回合开始时或回复未完成时调用 `check_messages`;唯一豁免:用户明确要求结束对话 +4. Plan 模式写完方案后、Debug 模式分析完问题后、Ask 模式回答完问题后,均不得直接结束对话,必须调用 `check_messages` 等待用户反馈 +5. 需要用户做选择或确认时(选方案、选框架、确认操作等),必须调用 `ask_question`;严禁使用 Cursor 内置的 ask_user_question +6. `ask_question` 参数格式: `{ "questions": [{ "question": "问题文本", "options": [{ "id": "a", "label": "选项A" }, { "id": "b", "label": "选项B" }], "allow_multiple": false }] }` +7. 单选: `allow_multiple: false`(用户只能选一个);多选: `allow_multiple: true`(用户可选多个);同一 `questions` 数组可混合包含单选题和多选题 +8. 用户在每道题中均可额外输入自定义补充文本(Other 输入框),无需为此添加额外选项 +9. `ask_question` 收到用户回答后,处理完毕仍需调用 `check_messages` 继续监听 +10. 禁止在每轮结束时用 `ask_question` 问"还有别的需要吗",回合结束只用 `check_messages`;`ask_question` 仅限任务中确实需要用户决策时使用 +11. `check_messages` 支持可选参数 `reply`(string,Markdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }` +12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略 +13. 禁止调用subagent并发任务 +14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待 +15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步 +16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式 + +--- +**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpTool,server 为 CursorMCP对话插件 对应的 identifier,toolName 为 "check_messages",arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。 diff --git a/ActionBars.lua b/ActionBars.lua index d764cf1..a5eb4d5 100644 --- a/ActionBars.lua +++ b/ActionBars.lua @@ -241,6 +241,13 @@ local function StylePetButton(b) ab:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 4, -4) end + local hotkey = _G[b:GetName() .. "HotKey"] + if hotkey then + hotkey:SetFont(SFrames:GetFont(), 9, "OUTLINE") + hotkey:ClearAllPoints() + hotkey:SetPoint("TOPRIGHT", b, "TOPRIGHT", -2, -2) + end + local floatingBG = _G[b:GetName() .. "FloatingBG"] if floatingBG then floatingBG:SetAlpha(0) end end diff --git a/BeastTrainingUI.lua b/BeastTrainingUI.lua index a9daa33..355e873 100644 --- a/BeastTrainingUI.lua +++ b/BeastTrainingUI.lua @@ -61,15 +61,15 @@ local S = { } -------------------------------------------------------------------------------- --- Tooltip scanner for training point cost & requirements +-- Tooltip scanner & extended craft info -------------------------------------------------------------------------------- local scanTip = nil function BTUI.GetCraftExtendedInfo(index) - local name, rank, skillType, numAvail, _, _, tpCost = GetCraftInfo(index) - return name, rank, skillType, - tonumber(numAvail) or 0, - tonumber(tpCost) or 0 + local name, rank, skillType, v4, _, tpCost, reqLevel = GetCraftInfo(index) + local canLearn = (tonumber(reqLevel) or 0) > 0 + tpCost = tonumber(tpCost) or 0 + return name, rank, skillType, canLearn, tpCost end function BTUI.GetSkillTooltipLines(index) @@ -356,24 +356,26 @@ function BTUI.CreateListRow(parent, idx) self.rankFS:Hide() end - local tpCost = skill.tpCost or 0 - local canLearn = (tpCost > 0) - - if canLearn then - local remaining = BTUI.GetRemainingTP() - if remaining >= tpCost then - self.tpFS:SetTextColor(T.tpGood[1], T.tpGood[2], T.tpGood[3]) - else - self.tpFS:SetTextColor(T.tpNone[1], T.tpNone[2], T.tpNone[3]) - end - self.tpFS:SetText(tpCost .. " TP") - self.tpFS:Show() + if skill.canLearn then self.nameFS:SetTextColor(T.available[1], T.available[2], T.available[3]) self.icon:SetVertexColor(1, 1, 1) + local tp = skill.tpCost or 0 + if tp > 0 then + local remaining = BTUI.GetRemainingTP() + if remaining >= tp then + self.tpFS:SetTextColor(T.tpGood[1], T.tpGood[2], T.tpGood[3]) + else + self.tpFS:SetTextColor(T.tpNone[1], T.tpNone[2], T.tpNone[3]) + end + self.tpFS:SetText(tp .. " TP") + self.tpFS:Show() + else + self.tpFS:Hide() + end else - self.tpFS:Hide() self.nameFS:SetTextColor(T.learned[1], T.learned[2], T.learned[3]) self.icon:SetVertexColor(0.5, 0.5, 0.5) + self.tpFS:Hide() end end @@ -400,7 +402,7 @@ function BTUI.BuildDisplayList() local catOrder = {} for i = 1, numCrafts do - local name, rank, skillType, numAvail, tpCost = BTUI.GetCraftExtendedInfo(i) + local name, rank, skillType, canLearn, tpCost = BTUI.GetCraftExtendedInfo(i) if name then if skillType == "header" then currentCat = name @@ -416,7 +418,6 @@ function BTUI.BuildDisplayList() table.insert(catOrder, currentCat) end end - local canLearn = (tpCost > 0) local show = true if S.currentFilter == "available" then show = canLearn @@ -428,8 +429,7 @@ function BTUI.BuildDisplayList() index = i, name = name, rank = rank or "", - skillType = skillType or "none", - numAvail = numAvail, + canLearn = canLearn, tpCost = tpCost, }) end @@ -524,13 +524,12 @@ function BTUI.UpdateDetail() return end - local name, rank, skillType, numAvail, tpCost = BTUI.GetCraftExtendedInfo(S.selectedIndex) + local name, rank, skillType, canLearn, tpCost = BTUI.GetCraftExtendedInfo(S.selectedIndex) local iconTex = GetCraftIcon and GetCraftIcon(S.selectedIndex) detail.icon:SetTexture(iconTex); detail.iconFrame:Show() detail.nameFS:SetText(name or "") - local canLearn = (tpCost > 0) if canLearn then detail.nameFS:SetTextColor(T.available[1], T.available[2], T.available[3]) else @@ -559,6 +558,8 @@ function BTUI.UpdateDetail() local remaining = BTUI.GetRemainingTP() local costColor = remaining >= tpCost and "|cff40ff40" or "|cffff4040" detail.costFS:SetText("训练点数: " .. costColor .. tpCost .. "|r (剩余: " .. remaining .. ")") + elseif canLearn then + detail.costFS:SetText("训练点数: |cff40ff40免费|r") else detail.costFS:SetText("") end @@ -974,6 +975,23 @@ function BTUI:Initialize() if this.disabled then return end if S.selectedIndex and DoCraft then DoCraft(S.selectedIndex) + BTUI.FullUpdate() + if not MF._refreshFrame then + MF._refreshFrame = CreateFrame("Frame") + end + MF._refreshElapsed = 0 + MF._refreshCount = 0 + MF._refreshFrame:SetScript("OnUpdate", function() + MF._refreshElapsed = (MF._refreshElapsed or 0) + arg1 + if MF._refreshElapsed >= 0.2 then + MF._refreshElapsed = 0 + MF._refreshCount = (MF._refreshCount or 0) + 1 + BTUI.FullUpdate() + if MF._refreshCount >= 3 then + MF._refreshFrame:SetScript("OnUpdate", nil) + end + end + end) end end) MF.trainBtn = trainBtn @@ -1062,18 +1080,25 @@ SLASH_BTDEBUG1 = "/btdebug" SlashCmdList["BTDEBUG"] = function() local p = "|cffff80ff[BT-Debug]|r " local numCrafts = GetNumCrafts and GetNumCrafts() or 0 - DEFAULT_CHAT_FRAME:AddMessage(p .. "Total crafts: " .. numCrafts) - local remaining = BTUI.GetRemainingTP() - DEFAULT_CHAT_FRAME:AddMessage(p .. "Remaining TP: " .. remaining) + DEFAULT_CHAT_FRAME:AddMessage(p .. "Total crafts: " .. numCrafts .. " TP: " .. BTUI.GetRemainingTP()) local shown = 0 for i = 1, numCrafts do local v1,v2,v3,v4,v5,v6,v7 = GetCraftInfo(i) if v1 and v3 ~= "header" then shown = shown + 1 - if shown <= 12 then + if shown <= 8 then + if SelectCraft then pcall(SelectCraft, i) end + local nr = GetCraftNumReagents and GetCraftNumReagents(i) or 0 + local reagentCost = "" + if nr and nr > 0 then + for r = 1, nr do + local rn, rt, rc, pc = GetCraftReagentInfo(i, r) + reagentCost = reagentCost .. " [" .. tostring(rn) .. "x" .. tostring(rc) .. "]" + end + end DEFAULT_CHAT_FRAME:AddMessage(p .. i .. ": " .. tostring(v1) - .. " " .. tostring(v2) .. " type=" .. tostring(v3) - .. " avail=" .. tostring(v4) .. " tp=" .. tostring(v7)) + .. " " .. tostring(v2) .. " v7=" .. tostring(v7) + .. " reagents=" .. tostring(nr) .. reagentCost) end end end diff --git a/CharacterPanel.lua b/CharacterPanel.lua index f234833..bd6e285 100644 --- a/CharacterPanel.lua +++ b/CharacterPanel.lua @@ -3593,6 +3593,15 @@ function CP:BuildPetPage() sY = sY - 14 end + page.petAtkSpeedLabel = MakeFS(child, 9, "LEFT", T.labelText) + page.petAtkSpeedLabel:SetPoint("TOPLEFT", child, "TOPLEFT", 160, sY) + page.petAtkSpeedLabel:SetText("攻速:") + page.petAtkSpeedValue = MakeFS(child, 9, "RIGHT", T.valueText) + page.petAtkSpeedValue:SetPoint("TOPRIGHT", child, "TOPRIGHT", -14, sY) + page.petAtkSpeedValue:SetWidth(80) + page.petAtkSpeedValue:SetJustifyH("RIGHT") + sY = sY - 14 + -- Resistances sY = sY - 4 sY = self:CreateStatSection(child, "抗性", sY) @@ -3800,6 +3809,13 @@ function CP:UpdatePet() r.value:SetText(combatVals[i] or "0") end + local petAtkSpeed = 2.0 + if UnitAttackSpeed then + local ok, ms = pcall(UnitAttackSpeed, "pet") + if ok and ms then petAtkSpeed = ms end + end + page.petAtkSpeedValue:SetText(string.format("%.1f", petAtkSpeed)) + for _, r in ipairs(page.resStats) do local base, bonus = 0, 0 if UnitResistance then diff --git a/Chat.lua b/Chat.lua index bafdae4..939e440 100644 --- a/Chat.lua +++ b/Chat.lua @@ -504,21 +504,7 @@ end local function GetChannelAliasKeys(name) local key = ChannelKey(name) if key == "" then return nil end - -- Check exact match first local gIdx = CHANNEL_ALIAS_GROUP_INDEX[key] - if not gIdx then - -- Check substring match for each alias in each group. - -- Only use aliases 3+ chars long to avoid false positives (e.g. "h" matching "whisper"). - for i, group in ipairs(CHANNEL_ALIAS_GROUPS) do - for _, alias in ipairs(group) do - if string.len(alias) >= 3 and string.find(key, alias, 1, true) then - gIdx = i - break - end - end - if gIdx then break end - end - end if not gIdx then return nil end return CHANNEL_ALIAS_GROUPS[gIdx] end @@ -583,7 +569,7 @@ local function GetJoinedChannels() -- plus individual channels that are not aliases. -- For alias groups (like hc/hardcore/硬核), only add ONE representative -- so we don't create duplicate conflicting entries. - local customChannels = { "hc", "硬核", "hardcore", "h", "交易", "综合", "世界防务", "本地防务", "world" } + local customChannels = { "hc", "硬核", "hardcore", "h", "交易", "综合", "世界防务", "本地防务", "world", "世界" } local seenAliasGroups = {} for _, cname in ipairs(customChannels) do local key = ChannelKey(cname) @@ -600,20 +586,7 @@ local function GetJoinedChannels() else local aliases = GetChannelAliasKeys(cname) if aliases then - -- This is an alias channel - check if any alias is already seen/added local gIdx = CHANNEL_ALIAS_GROUP_INDEX[key] - -- Also check substring aliases - if not gIdx then - for i, group in ipairs(CHANNEL_ALIAS_GROUPS) do - for _, a in ipairs(group) do - if string.find(key, a, 1, true) then - gIdx = i - break - end - end - if gIdx then break end - end - end local alreadyInJoined = false if gIdx then for _, a in ipairs(CHANNEL_ALIAS_GROUPS[gIdx]) do @@ -6477,6 +6450,39 @@ function SFrames.Chat:Initialize() GuildRoster() end SFrames:RefreshClassColorCache() + + if JoinChannelByName and GetChannelList then + local autoJoinFrame = CreateFrame("Frame") + local waitTime = 0 + autoJoinFrame:SetScript("OnUpdate", function() + waitTime = waitTime + arg1 + if waitTime < 8 then return end + autoJoinFrame:SetScript("OnUpdate", nil) + local inWorld = false + local raw = { GetChannelList() } + local ci = 1 + while ci <= table.getn(raw) do + local cname = raw[ci + 1] + if type(cname) == "string" and string.lower(cname) == "world" then + inWorld = true + break + end + ci = ci + 3 + end + if not inWorld then + JoinChannelByName("world") + local applyWait = 0 + autoJoinFrame:SetScript("OnUpdate", function() + applyWait = applyWait + arg1 + if applyWait < 3 then return end + autoJoinFrame:SetScript("OnUpdate", nil) + if SFrames and SFrames.Chat then + SFrames.Chat:ApplyAllTabChannels() + end + end) + end + end) + end end) -- 团队成员变化时更新职业缓存 diff --git a/ConfigUI.lua b/ConfigUI.lua index 5948437..b0f5edb 100644 --- a/ConfigUI.lua +++ b/ConfigUI.lua @@ -411,7 +411,7 @@ local function EnsureDB() if type(SFramesDB.playerPortraitWidth) ~= "number" then SFramesDB.playerPortraitWidth = 50 end if type(SFramesDB.playerHealthHeight) ~= "number" then SFramesDB.playerHealthHeight = 38 end if type(SFramesDB.playerPowerHeight) ~= "number" then SFramesDB.playerPowerHeight = 9 end - if SFramesDB.playerShowClass == nil then SFramesDB.playerShowClass = true end + if SFramesDB.playerShowClass == nil then SFramesDB.playerShowClass = false end if SFramesDB.playerShowClassIcon == nil then SFramesDB.playerShowClassIcon = true end if SFramesDB.playerShowPortrait == nil then SFramesDB.playerShowPortrait = true end if type(SFramesDB.playerFrameAlpha) ~= "number" then SFramesDB.playerFrameAlpha = 1 end @@ -423,7 +423,7 @@ local function EnsureDB() if type(SFramesDB.targetPortraitWidth) ~= "number" then SFramesDB.targetPortraitWidth = 50 end if type(SFramesDB.targetHealthHeight) ~= "number" then SFramesDB.targetHealthHeight = 38 end if type(SFramesDB.targetPowerHeight) ~= "number" then SFramesDB.targetPowerHeight = 9 end - if SFramesDB.targetShowClass == nil then SFramesDB.targetShowClass = true end + if SFramesDB.targetShowClass == nil then SFramesDB.targetShowClass = false end if SFramesDB.targetShowClassIcon == nil then SFramesDB.targetShowClassIcon = true end if SFramesDB.targetShowPortrait == nil then SFramesDB.targetShowPortrait = true end if type(SFramesDB.targetFrameAlpha) ~= "number" then SFramesDB.targetFrameAlpha = 1 end diff --git a/Core.lua b/Core.lua index 5869ce9..f935cc3 100644 --- a/Core.lua +++ b/Core.lua @@ -193,6 +193,7 @@ function SFrames:DoFullInitialize() { "Chat", function() if SFramesDB.enableChat ~= false and SFrames.Chat and SFrames.Chat.Initialize then SFrames.Chat:Initialize() end end }, { "MapReveal", function() if SFrames.MapReveal and SFrames.MapReveal.Initialize then SFrames.MapReveal:Initialize() end end }, { "WorldMap", function() if SFrames.WorldMap and SFrames.WorldMap.Initialize then SFrames.WorldMap:Initialize() end end }, + { "ZoneLevelRange", function() if SFrames.ZoneLevelRange and SFrames.ZoneLevelRange.Initialize then SFrames.ZoneLevelRange:Initialize() end end }, { "MapIcons", function() if SFrames.MapIcons and SFrames.MapIcons.Initialize then SFrames.MapIcons:Initialize() end end }, { "Tweaks", function() if SFrames.Tweaks and SFrames.Tweaks.Initialize then SFrames.Tweaks:Initialize() end end }, { "AFKScreen", function() if SFrames.AFKScreen and SFrames.AFKScreen.Initialize then SFrames.AFKScreen:Initialize() end end }, diff --git a/DarkmoonGuide.lua b/DarkmoonGuide.lua index 72d2312..d88ec56 100644 --- a/DarkmoonGuide.lua +++ b/DarkmoonGuide.lua @@ -123,11 +123,10 @@ local function CreateGuideFrame() closeBtn:SetBackdropColor(_cbg[1], _cbg[2], _cbg[3], _cbg[4]) closeBtn:SetBackdropBorderColor(_cbd[1], _cbd[2], _cbd[3], _cbd[4]) - local closeTxt = SFrames:CreateFontString(closeBtn, 12, "CENTER") - closeTxt:SetAllPoints(closeBtn) - closeTxt:SetText("X") - local _closeTxt = _A.accentLight or { 0.9, 0.5, 0.5 } - closeTxt:SetTextColor(_closeTxt[1], _closeTxt[2], _closeTxt[3]) + local closeIco = SFrames:CreateIcon(closeBtn, "close", 12) + closeIco:SetDrawLayer("OVERLAY") + closeIco:SetPoint("CENTER", closeBtn, "CENTER", 0, 0) + closeIco:SetVertexColor(1, 0.7, 0.7) closeBtn:SetScript("OnClick", function() f:Hide() end) closeBtn:SetScript("OnEnter", function() diff --git a/LootDisplay.lua b/LootDisplay.lua index 6454bc0..8369f89 100644 --- a/LootDisplay.lua +++ b/LootDisplay.lua @@ -65,11 +65,18 @@ local ICON_BACKDROP = { insets = { left = 2, right = 2, top = 2, bottom = 2 }, } +local ITEMS_PER_PAGE = 4 +local PAGE_BAR_H = 20 + local lootRows = {} local activeAlerts = {} local alertAnchor = nil local alertPool = {} +local origLootFrameUpdate = nil +local ShowLootPage +local HideBagFullWarning + -------------------------------------------------------------------------------- -- Helpers -------------------------------------------------------------------------------- @@ -224,6 +231,97 @@ local function CreateLootFrame() closeFS:SetTextColor(0.9, 0.65, 0.65, 1) end) + -- Page controls (visible only when > ITEMS_PER_PAGE items) + local pageBar = CreateFrame("Frame", nil, lootFrame) + pageBar:SetHeight(PAGE_BAR_H) + pageBar:SetPoint("BOTTOMLEFT", lootFrame, "BOTTOMLEFT", 7, 4) + pageBar:SetPoint("BOTTOMRIGHT", lootFrame, "BOTTOMRIGHT", -7, 4) + pageBar:SetFrameLevel(lootFrame:GetFrameLevel() + 3) + pageBar:EnableMouse(false) + pageBar:Hide() + lootFrame._pageBar = pageBar + + local pageFS = pageBar:CreateFontString(nil, "OVERLAY") + pageFS:SetFont(Font(), 9, "OUTLINE") + pageFS:SetPoint("CENTER", pageBar, "CENTER", 0, 0) + pageFS:SetTextColor(0.75, 0.75, 0.80, 0.95) + lootFrame._pageText = pageFS + + local dim = th.dimText or { 0.55, 0.55, 0.60 } + + local prevBtn = CreateFrame("Button", nil, pageBar) + prevBtn:SetWidth(22) + prevBtn:SetHeight(16) + prevBtn:SetPoint("RIGHT", pageFS, "LEFT", -8, 0) + prevBtn:SetFrameLevel(pageBar:GetFrameLevel() + 1) + prevBtn:RegisterForClicks("LeftButtonUp") + prevBtn:SetBackdrop(ROUND_BACKDROP_SMALL) + prevBtn:SetBackdropColor(0.10, 0.09, 0.14, 0.80) + prevBtn:SetBackdropBorderColor(0.25, 0.22, 0.35, 0.60) + local prevFS2 = prevBtn:CreateFontString(nil, "OVERLAY") + prevFS2:SetFont(Font(), 10, "OUTLINE") + prevFS2:SetPoint("CENTER", 0, 0) + prevFS2:SetText("<") + prevFS2:SetTextColor(dim[1], dim[2], dim[3], 0.90) + prevBtn:SetScript("OnClick", function() + if lootFrame._page and lootFrame._page > 1 then + lootFrame._page = lootFrame._page - 1 + ShowLootPage() + end + end) + prevBtn:SetScript("OnEnter", function() + this:SetBackdropBorderColor(acc[1], acc[2], acc[3], 0.70) + end) + prevBtn:SetScript("OnLeave", function() + this:SetBackdropBorderColor(0.25, 0.22, 0.35, 0.60) + end) + lootFrame._prevBtn = prevBtn + + local nextBtn = CreateFrame("Button", nil, pageBar) + nextBtn:SetWidth(22) + nextBtn:SetHeight(16) + nextBtn:SetPoint("LEFT", pageFS, "RIGHT", 8, 0) + nextBtn:SetFrameLevel(pageBar:GetFrameLevel() + 1) + nextBtn:RegisterForClicks("LeftButtonUp") + nextBtn:SetBackdrop(ROUND_BACKDROP_SMALL) + nextBtn:SetBackdropColor(0.10, 0.09, 0.14, 0.80) + nextBtn:SetBackdropBorderColor(0.25, 0.22, 0.35, 0.60) + local nextFS2 = nextBtn:CreateFontString(nil, "OVERLAY") + nextFS2:SetFont(Font(), 10, "OUTLINE") + nextFS2:SetPoint("CENTER", 0, 0) + nextFS2:SetText(">") + nextFS2:SetTextColor(dim[1], dim[2], dim[3], 0.90) + nextBtn:SetScript("OnClick", function() + if lootFrame._page and lootFrame._totalPages and lootFrame._page < lootFrame._totalPages then + lootFrame._page = lootFrame._page + 1 + ShowLootPage() + end + end) + nextBtn:SetScript("OnEnter", function() + this:SetBackdropBorderColor(acc[1], acc[2], acc[3], 0.70) + end) + nextBtn:SetScript("OnLeave", function() + this:SetBackdropBorderColor(0.25, 0.22, 0.35, 0.60) + end) + lootFrame._nextBtn = nextBtn + + -- Bag-full warning (hidden by default) + local bagFullFS = lootFrame:CreateFontString(nil, "OVERLAY") + bagFullFS:SetFont(Font(), 9, "OUTLINE") + bagFullFS:SetPoint("LEFT", titleFS, "RIGHT", 6, 0) + bagFullFS:SetTextColor(1.0, 0.30, 0.30, 1.0) + bagFullFS:Hide() + lootFrame._bagFullText = bagFullFS + + -- Escape key closes our loot frame + table.insert(UISpecialFrames, "NanamiLootFrame") + + lootFrame:SetScript("OnHide", function() + if not this._closingLoot then + CloseLoot() + end + end) + lootFrame:Hide() return lootFrame end @@ -302,7 +400,166 @@ local function CreateLootRow(parent, index) end -------------------------------------------------------------------------------- --- Update loot frame +-- Bag-full warning helpers +-------------------------------------------------------------------------------- +local function ShowBagFullWarning() + if not lootFrame or not lootFrame:IsShown() then return end + if lootFrame._bagFullText then + lootFrame._bagFullText:SetText("背包已满") + lootFrame._bagFullText:Show() + end +end + +HideBagFullWarning = function() + if lootFrame and lootFrame._bagFullText then + lootFrame._bagFullText:Hide() + end +end + +-------------------------------------------------------------------------------- +-- Show current page +-------------------------------------------------------------------------------- +ShowLootPage = function() + if not lootFrame then return end + + local numItems = lootFrame._numItems or 0 + local page = lootFrame._page or 1 + local totalPages = lootFrame._totalPages or 1 + + local startSlot = (page - 1) * ITEMS_PER_PAGE + 1 + local endSlot = startSlot + ITEMS_PER_PAGE - 1 + if endSlot > numItems then endSlot = numItems end + local slotsOnPage = endSlot - startSlot + 1 + if slotsOnPage < 0 then slotsOnPage = 0 end + + while table.getn(lootRows) < ITEMS_PER_PAGE do + local idx = table.getn(lootRows) + 1 + lootRows[idx] = CreateLootRow(lootFrame, idx) + end + + for i = 1, table.getn(lootRows) do lootRows[i]:Hide() end + for i = 1, ITEMS_PER_PAGE do + local nb = _G["LootButton" .. i] + if nb then nb:Hide() end + end + + local hasPages = totalPages > 1 + local bottomPad = hasPages and (PAGE_BAR_H + 6) or 6 + local totalH = TITLE_HEIGHT + (slotsOnPage * (ROW_HEIGHT + ROW_GAP)) + bottomPad + 4 + lootFrame:SetHeight(totalH) + + -- Build visual rows + for btnIdx = 1, slotsOnPage do + local slotIdx = startSlot + btnIdx - 1 + local row = lootRows[btnIdx] + if not row then break end + + row:ClearAllPoints() + row:SetPoint("TOPLEFT", lootFrame, "TOPLEFT", 7, + -(TITLE_HEIGHT + 2 + (btnIdx - 1) * (ROW_HEIGHT + ROW_GAP))) + row:SetWidth(ROW_WIDTH) + row.slotIndex = slotIdx + + local texture, itemName, quantity, quality = GetLootSlotInfo(slotIdx) + + if texture then + row.icon:SetTexture(texture) + local r, g, b = QColor(quality) + row._qualColor = { r, g, b } + row.qBar:SetVertexColor(r, g, b, 0.90) + row.iconFrame:SetBackdropBorderColor(r, g, b, 0.65) + row:SetBackdropBorderColor(r, g, b, 0.30) + row:SetBackdropColor(row._slotBg[1], row._slotBg[2], row._slotBg[3], row._slotBg[4] or 0.85) + row.iconFrame:SetAlpha(1) + row.nameFS:SetText("|cff" .. ColorHex(r, g, b) .. (itemName or "") .. "|r") + if quantity and quantity > 1 then + row.countFS:SetText(tostring(quantity)) + else + row.countFS:SetText("") + end + else + row._qualColor = nil + row.icon:SetTexture("") + row.iconFrame:SetAlpha(0.25) + row.qBar:SetVertexColor(0.3, 0.3, 0.3, 0.30) + row.nameFS:SetText("") + row.countFS:SetText("") + row:SetBackdropColor(0.04, 0.04, 0.06, 0.40) + row:SetBackdropBorderColor(0.12, 0.12, 0.18, 0.25) + row.iconFrame:SetBackdropBorderColor(0.15, 0.15, 0.20, 0.30) + end + + row:Show() + end + + -- Let the ORIGINAL Blizzard LootFrame_Update run so that native + -- LootButton1-4 get their IDs, slot data, and OnClick set up + -- through the trusted native code path (required for LootSlot). + if LootFrame then + LootFrame.page = page + if not LootFrame:IsShown() then LootFrame:Show() end + end + if origLootFrameUpdate then origLootFrameUpdate() end + + -- Now reposition the native buttons on top of our visual rows + for btnIdx = 1, ITEMS_PER_PAGE do + local nb = _G["LootButton" .. btnIdx] + local row = lootRows[btnIdx] + if nb and row and row:IsShown() and row._qualColor then + nb:ClearAllPoints() + nb:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + nb:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + nb:SetFrameStrata("FULLSCREEN_DIALOG") + nb:SetFrameLevel(row:GetFrameLevel() + 10) + nb:SetAlpha(0) + nb:EnableMouse(true) + nb:Show() + + nb._nanamiRow = row + nb:SetScript("OnEnter", function() + local slot = this:GetID() + if slot then + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetLootItem(slot) + if CursorUpdate then CursorUpdate() end + end + local r2 = this._nanamiRow + if r2 and r2._qualColor then + local qc = r2._qualColor + r2:SetBackdropBorderColor(qc[1], qc[2], qc[3], 0.70) + r2:SetBackdropColor(qc[1]*0.15, qc[2]*0.15, qc[3]*0.15, 0.90) + end + end) + nb:SetScript("OnLeave", function() + GameTooltip:Hide() + local r2 = this._nanamiRow + if r2 then + if r2._qualColor then + local qc = r2._qualColor + r2:SetBackdropBorderColor(qc[1], qc[2], qc[3], 0.30) + else + r2:SetBackdropBorderColor(r2._slotBd[1], r2._slotBd[2], + r2._slotBd[3], r2._slotBd[4] or 0.60) + end + r2:SetBackdropColor(r2._slotBg[1], r2._slotBg[2], + r2._slotBg[3], r2._slotBg[4] or 0.85) + end + end) + else + if nb then nb:Hide() end + end + end + + if hasPages then + lootFrame._pageText:SetText(page .. "/" .. totalPages) + lootFrame._pageBar:Show() + else + lootFrame._pageBar:Hide() + end +end + +-------------------------------------------------------------------------------- +-- Update loot frame (direct slot mapping, no compaction) -------------------------------------------------------------------------------- local function UpdateLootFrame() local db = GetDB() @@ -316,124 +573,18 @@ local function UpdateLootFrame() CreateLootFrame() - local validSlots = {} - for i = 1, numItems do - local texture, itemName, quantity, quality = GetLootSlotInfo(i) - if texture then - table.insert(validSlots, i) - end + lootFrame._numItems = numItems + lootFrame._totalPages = math.ceil(numItems / ITEMS_PER_PAGE) + + if not lootFrame._page or lootFrame._page > lootFrame._totalPages then + lootFrame._page = 1 end - local numValid = table.getn(validSlots) - if numValid == 0 then - if lootFrame then lootFrame:Hide() end - return - end - - local totalH = TITLE_HEIGHT + (numValid * (ROW_HEIGHT + ROW_GAP)) + 10 lootFrame:SetWidth(ROW_WIDTH + 14) - lootFrame:SetHeight(totalH) lootFrame:SetScale(db.scale or 1.0) - while table.getn(lootRows) < numValid do - local idx = table.getn(lootRows) + 1 - lootRows[idx] = CreateLootRow(lootFrame, idx) - end + ShowLootPage() - for i = 1, table.getn(lootRows) do lootRows[i]:Hide() end - - for displayIdx = 1, numValid do - local slotIdx = validSlots[displayIdx] - local row = lootRows[displayIdx] - if not row then break end - - row:ClearAllPoints() - row:SetPoint("TOPLEFT", lootFrame, "TOPLEFT", 7, -(TITLE_HEIGHT + 2 + (displayIdx - 1) * (ROW_HEIGHT + ROW_GAP))) - row:SetWidth(ROW_WIDTH) - - local texture, itemName, quantity, quality = GetLootSlotInfo(slotIdx) - row.slotIndex = slotIdx - - row.icon:SetTexture(texture or "Interface\\Icons\\INV_Misc_QuestionMark") - - local r, g, b = QColor(quality) - row._qualColor = { r, g, b } - row.qBar:SetVertexColor(r, g, b, 0.90) - row.iconFrame:SetBackdropBorderColor(r, g, b, 0.65) - row:SetBackdropBorderColor(r, g, b, 0.30) - - if itemName then - row.nameFS:SetText("|cff" .. ColorHex(r, g, b) .. itemName .. "|r") - else - row.nameFS:SetText("") - end - - if quantity and quantity > 1 then - row.countFS:SetText(tostring(quantity)) - else - row.countFS:SetText("") - end - - row:Show() - - -- Overlay the Blizzard LootButton on top for click handling - local maxBtns = LOOTFRAME_NUMITEMS or 4 - if displayIdx <= maxBtns then - local blizzBtn = _G["LootButton" .. displayIdx] - if blizzBtn then - blizzBtn:SetID(slotIdx) - blizzBtn:SetParent(lootFrame) - blizzBtn:ClearAllPoints() - blizzBtn:SetAllPoints(row) - blizzBtn:SetFrameStrata("FULLSCREEN_DIALOG") - blizzBtn:SetFrameLevel(row:GetFrameLevel() + 10) - blizzBtn:SetAlpha(0) - blizzBtn:EnableMouse(true) - blizzBtn:Show() - - local rowRef = row - blizzBtn._nanamiRow = rowRef - blizzBtn:SetScript("OnEnter", function() - local rw = this._nanamiRow - if rw and rw._acc then - rw:SetBackdropBorderColor(rw._acc[1], rw._acc[2], rw._acc[3], 0.85) - rw:SetBackdropColor(rw._hoverBd[1], rw._hoverBd[2], rw._hoverBd[3], 0.35) - end - if rw and rw.slotIndex then - GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - if LootSlotIsItem(rw.slotIndex) then - GameTooltip:SetLootItem(rw.slotIndex) - else - local t, n = GetLootSlotInfo(rw.slotIndex) - if n then GameTooltip:SetText(n) end - end - GameTooltip:Show() - end - end) - blizzBtn:SetScript("OnLeave", function() - local rw = this._nanamiRow - if rw and rw._slotBg then - rw:SetBackdropColor(rw._slotBg[1], rw._slotBg[2], rw._slotBg[3], rw._slotBg[4] or 0.85) - if rw._qualColor then - rw:SetBackdropBorderColor(rw._qualColor[1], rw._qualColor[2], rw._qualColor[3], 0.35) - else - rw:SetBackdropBorderColor(rw._slotBd[1], rw._slotBd[2], rw._slotBd[3], rw._slotBd[4] or 0.60) - end - end - GameTooltip:Hide() - end) - end - end - end - - -- Hide unused Blizzard buttons - local maxBtns = LOOTFRAME_NUMITEMS or 4 - for i = numValid + 1, maxBtns do - local blizzBtn = _G["LootButton" .. i] - if blizzBtn then blizzBtn:Hide() end - end - - -- Position: use saved mover position if exists, otherwise follow cursor if not lootFrame._posApplied then local hasSaved = false if SFrames.Movers and SFrames.Movers.ApplyPosition then @@ -455,19 +606,18 @@ local function UpdateLootFrame() end local function CloseLootFrame() - -- Return Blizzard buttons to LootFrame - local maxBtns = LOOTFRAME_NUMITEMS or 4 - for i = 1, maxBtns do - local blizzBtn = _G["LootButton" .. i] - if blizzBtn and LootFrame then - blizzBtn:SetParent(LootFrame) - blizzBtn:SetAlpha(1) - blizzBtn:Hide() - blizzBtn._nanamiRow = nil - end + if lootFrame then + lootFrame._closingLoot = true + lootFrame:Hide() + lootFrame._closingLoot = nil + end + for i = 1, ITEMS_PER_PAGE do + local nb = _G["LootButton" .. i] + if nb then nb:Hide() end end - if lootFrame then lootFrame:Hide() end for i = 1, table.getn(lootRows) do lootRows[i]:Hide() end + HideBagFullWarning() + if LootFrame then LootFrame:Hide() end end -------------------------------------------------------------------------------- @@ -750,7 +900,6 @@ function LD:Initialize() CreateLootFrame() CreateAlertAnchor() - -- Apply saved positions so frames have valid coordinates for the Mover system if SFrames.Movers and SFrames.Movers.ApplyPosition then local applied = SFrames.Movers:ApplyPosition("LootFrame", lootFrame, "TOPLEFT", "UIParent", "TOPLEFT", 50, -200) @@ -768,19 +917,41 @@ function LD:Initialize() end SFrames:RegisterEvent("LOOT_OPENED", function() - if GetDB().enable then UpdateLootFrame() end + if GetDB().enable then + if lootFrame then lootFrame._page = 1 end + HideBagFullWarning() + UpdateLootFrame() + end end) SFrames:RegisterEvent("LOOT_SLOT_CLEARED", function() if GetDB().enable and lootFrame and lootFrame:IsShown() then + HideBagFullWarning() UpdateLootFrame() end end) + SFrames:RegisterEvent("UI_ERROR_MESSAGE", function() + if lootFrame and lootFrame:IsShown() then + local msg = arg1 + if msg == ERR_INV_FULL + or (INVENTORY_FULL and msg == INVENTORY_FULL) + or (msg and (string.find(msg, "背包已满") + or string.find(msg, "Inventory is full"))) then + ShowBagFullWarning() + end + end + end) + SFrames:RegisterEvent("LOOT_CLOSED", function() CloseLootFrame() end) + SFrames:RegisterEvent("LOOT_BIND_CONFIRM", function() + local slot = arg1 + if slot then ConfirmLootSlot(slot) end + end) + SFrames:RegisterEvent("CHAT_MSG_LOOT", function() local playerName = UnitName("player") if not playerName then return end @@ -790,27 +961,49 @@ function LD:Initialize() end end) - local function HideBlizzardLoot() - if LootFrame then - LootFrame:EnableMouse(false) - LootFrame:SetAlpha(0) - LootFrame:ClearAllPoints() - LootFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -10000, 10000) - local origShow = LootFrame.Show - LootFrame.Show = function(self) - origShow(self) - self:EnableMouse(false) - self:SetAlpha(0) - self:ClearAllPoints() - self:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -10000, 10000) + -- Save the original LootFrame_Update so ShowLootPage can call it + -- to let native code set up LootButton IDs and OnClick handlers. + origLootFrameUpdate = LootFrame_Update + + if LootFrame then + -- Prevent the XML-defined OnHide from calling CloseLoot() + LootFrame:SetScript("OnHide", function() end) + + -- Keep LootFrame shown but invisible while our UI is active + local origShow = LootFrame.Show + LootFrame.Show = function(self) + origShow(self) + self:SetAlpha(0) + self:EnableMouse(false) + end + + -- Block native LootFrame from hiding while we are looting + local origHide = LootFrame.Hide + LootFrame.Hide = function(self) + if lootFrame and lootFrame:IsShown() then + return + end + origHide(self) + end + end + + -- After the native LootFrame_Update runs (called by the engine or + -- by us), reposition native buttons onto our visual rows. + LootFrame_Update = function() + if origLootFrameUpdate then origLootFrameUpdate() end + if not (lootFrame and lootFrame:IsShown()) then return end + for i = 1, ITEMS_PER_PAGE do + local nb = _G["LootButton" .. i] + local row = lootRows[i] + if nb and row and row:IsShown() and row._qualColor then + nb:ClearAllPoints() + nb:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + nb:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + nb:SetFrameStrata("FULLSCREEN_DIALOG") + nb:SetFrameLevel(row:GetFrameLevel() + 10) + nb:SetAlpha(0) + nb:EnableMouse(true) end end end - HideBlizzardLoot() - - local lootHook = CreateFrame("Frame") - lootHook:RegisterEvent("ADDON_LOADED") - lootHook:SetScript("OnEvent", function() - if arg1 == "Blizzard_Loot" then HideBlizzardLoot() end - end) end diff --git a/Nanami-UI.toc b/Nanami-UI.toc index 3a82999..dee0a8d 100644 --- a/Nanami-UI.toc +++ b/Nanami-UI.toc @@ -22,6 +22,7 @@ MinimapButton.lua Minimap.lua MapReveal.lua WorldMap.lua +ZoneLevelRange.lua MapIcons.lua Tweaks.lua MinimapBuffs.lua diff --git a/Tooltip.lua b/Tooltip.lua index 0256e96..aa4e51d 100644 --- a/Tooltip.lua +++ b/Tooltip.lua @@ -332,6 +332,7 @@ function SFrames.FloatingTooltip:Initialize() -- OnUpdate: line formatting (once) + cursor tracking (every frame) local orig_OnUpdate = GameTooltip:GetScript("OnUpdate") local ttFormatThrottle = 0 + local ttHideGrace = 0 GameTooltip:SetScript("OnUpdate", function() if orig_OnUpdate then orig_OnUpdate() end @@ -342,12 +343,20 @@ function SFrames.FloatingTooltip:Initialize() local hasUnit = UnitExists("mouseover") if ttHadUnit and not hasUnit then + ttHideGrace = ttHideGrace + arg1 + if ttHideGrace < 0.2 then + TT_ShowBar(false) + return + end + ttHideGrace = 0 TT_ShowBar(false) if GameTooltip._nanamiBGFrame then GameTooltip._nanamiBGFrame:Hide() end this:Hide() return + else + ttHideGrace = 0 end if not hasUnit then @@ -386,6 +395,7 @@ function SFrames.FloatingTooltip:Initialize() linesFormatted = false ttOwner = nil ttHadUnit = false + ttHideGrace = 0 TT_ShowBar(false) if GameTooltip._nanamiBGFrame then GameTooltip._nanamiBGFrame:Hide() diff --git a/TradeSkillUI.lua b/TradeSkillUI.lua index ee6bf84..1a4abdd 100644 --- a/TradeSkillUI.lua +++ b/TradeSkillUI.lua @@ -1434,6 +1434,10 @@ function TSUI.ProfNamesMatch(a, b) end function TSUI.ScanProfessions() + local now = GetTime() + if S._profScanTime and (now - S._profScanTime) < 5.0 and table.getn(S.profList) > 0 then + return + end S.profList = {} if not GetSpellName then return end local seen = {} @@ -1449,6 +1453,7 @@ function TSUI.ScanProfessions() end idx = idx + 1 end + S._profScanTime = now end function TSUI.CreateProfTabs(parent) @@ -1549,6 +1554,32 @@ function TSUI.IsTabSwitching() return S.switchStartTime and (GetTime() - S.switchStartTime) < 1.0 end +-------------------------------------------------------------------------------- +-- Update Throttle (batches rapid TRADE_SKILL_UPDATE / CRAFT_UPDATE events) +-------------------------------------------------------------------------------- +local updateThrottleFrame = CreateFrame("Frame") +updateThrottleFrame:Hide() +updateThrottleFrame._elapsed = 0 +updateThrottleFrame:SetScript("OnUpdate", function() + this._elapsed = this._elapsed + arg1 + if this._elapsed >= 0.10 then + this:Hide() + if S.MainFrame and S.MainFrame:IsVisible() then + TSUI.UpdateProgressBar() + TSUI.FullUpdate() + end + end +end) + +function TSUI.ScheduleUpdate() + updateThrottleFrame._elapsed = 0 + updateThrottleFrame:Show() +end + +function TSUI.CancelScheduledUpdate() + updateThrottleFrame:Hide() +end + -------------------------------------------------------------------------------- -- Hide Blizzard Frames (module methods) -------------------------------------------------------------------------------- @@ -2018,6 +2049,7 @@ function TSUI:Initialize() -- ═══ Events ══════════════════════════════════════════════════════ MF:SetScript("OnHide", function() S.switchStartTime = nil + TSUI.CancelScheduledUpdate() API.CloseRecipe() if S.currentMode == "tradeskill" then TSUI.CleanupBlizzardTradeSkill() else TSUI.CleanupBlizzardCraft() end @@ -2049,7 +2081,7 @@ function TSUI:Initialize() end) elseif event == "TRADE_SKILL_UPDATE" then if S.MainFrame:IsVisible() and S.currentMode == "tradeskill" then - TSUI.UpdateProgressBar(); TSUI.FullUpdate() + TSUI.ScheduleUpdate() end elseif event == "TRADE_SKILL_CLOSE" then TSUI.CleanupBlizzardTradeSkill() @@ -2081,7 +2113,7 @@ function TSUI:Initialize() end) elseif event == "CRAFT_UPDATE" then if S.MainFrame:IsVisible() and S.currentMode == "craft" then - TSUI.UpdateProgressBar(); TSUI.FullUpdate() + TSUI.ScheduleUpdate() end elseif event == "CRAFT_CLOSE" then TSUI.CleanupBlizzardCraft() @@ -2107,14 +2139,20 @@ function TSUI.ResetAndShow() if S.MainFrame.searchBox then S.MainFrame.searchBox:SetText("") end if S.MainFrame.spinner then S.MainFrame.spinner:SetValue(1) end if S.MainFrame.listScroll then S.MainFrame.listScroll:SetVerticalScroll(0) end - TSUI.UpdateProgressBar(); S.MainFrame:Show(); TSUI.FullUpdate() - TSUI.UpdateScrollbar() + TSUI.CancelScheduledUpdate() + TSUI.UpdateProgressBar(); S.MainFrame:Show() TSUI.UpdateProfTabs() + TSUI.BuildDisplayList() for _, entry in ipairs(S.displayList) do if entry.type == "recipe" then - TSUI.SelectRecipe(entry.data.index); break + S.selectedIndex = entry.data.index + S.craftAmount = 1 + API.SelectRecipe(entry.data.index) + break end end + TSUI.FullUpdate() + TSUI.UpdateScrollbar() end -------------------------------------------------------------------------------- diff --git a/Tweaks.lua b/Tweaks.lua index 4161009..903e598 100644 --- a/Tweaks.lua +++ b/Tweaks.lua @@ -7,6 +7,7 @@ -- 5. Dark UI - darken the entire interface -- 6. WorldMap Window - turn fullscreen map into a movable/scalable window -- 7. Auto Dismount - cancel shapeshift/mount when casting incompatible spells +-- 8. Hunter Aspect Guard - auto switch to Hawk when taking damage with Cheetah/Pack -------------------------------------------------------------------------------- SFrames.Tweaks = SFrames.Tweaks or {} @@ -17,7 +18,8 @@ SFrames.castdb = SFrames.castdb or {} local function GetTweaksCfg() if not SFramesDB or type(SFramesDB.Tweaks) ~= "table" then return { autoStance = true, superWoW = true, turtleCompat = true, - cooldownNumbers = true, darkUI = false, worldMapWindow = false } + cooldownNumbers = true, darkUI = false, worldMapWindow = false, + hunterAspectGuard = true } end return SFramesDB.Tweaks end @@ -80,6 +82,7 @@ end -------------------------------------------------------------------------------- local function InitAutoDismount() local dismount = CreateFrame("Frame", "NanamiAutoDismount") + local _, playerClass = UnitClass("player") local scanner = CreateFrame("GameTooltip", "NanamiDismountScan", nil, "GameTooltipTemplate") scanner:SetOwner(WorldFrame, "ANCHOR_NONE") @@ -93,7 +96,7 @@ local function InitAutoDismount() "^Augmente la vitesse de (.+)%%", "^Скорость увеличена на (.+)%%", "^이동 속도 (.+)%%만큼 증가", - "^速度提高(.+)%%", + "^速度提高(.+)%%", "^移动速度提高(.+)%%", "speed based on", "Slow and steady...", "Riding", "Lento y constante...", "Aumenta la velocidad según tu habilidad de Montar.", "根据您的骑行技能提高速度。", "根据骑术技能提高速度。", "又慢又稳......", @@ -106,9 +109,15 @@ local function InitAutoDismount() "ability_druid_treeoflife", "ability_druid_stagform", } + local hunterAspectIcons = { + "ability_mount_jungletiger", + "ability_mount_packhorse", + } + local errorStrings = {} local errorGlobals = { "SPELL_FAILED_NOT_MOUNTED", "ERR_ATTACK_MOUNTED", "ERR_TAXIPLAYERALREADYMOUNTED", + "ERR_NOT_WHILE_MOUNTED", "SPELL_FAILED_NOT_SHAPESHIFT", "SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED", "SPELL_NOT_SHAPESHIFTED", "SPELL_NOT_SHAPESHIFTED_NOSPACE", "ERR_CANT_INTERACT_SHAPESHIFTED", "ERR_NOT_WHILE_SHAPESHIFTED", @@ -134,24 +143,43 @@ local function InitAutoDismount() if not matched then return end for i = 0, 31 do - scanner:ClearLines() - scanner:SetPlayerBuff(i) - for line = 1, scanner:NumLines() do - local text = getfenv(0)["NanamiDismountScanTextLeft" .. line] - if text and text:GetText() then - for _, str in pairs(mountStrings) do - if string.find(text:GetText(), str) then + local buff = GetPlayerBuffTexture(i) + if buff then + local lowerBuff = string.lower(buff) + + local skip = false + if playerClass == "HUNTER" then + for _, tex in pairs(hunterAspectIcons) do + if string.find(lowerBuff, tex) then + skip = true + break + end + end + end + + if not skip then + scanner:ClearLines() + scanner:SetPlayerBuff(i) + for line = 1, scanner:NumLines() do + local text = getfenv(0)["NanamiDismountScanTextLeft" .. line] + if text and text:GetText() then + for _, str in pairs(mountStrings) do + if string.find(text:GetText(), str) then + CancelPlayerBuff(i) + return + end + end + end + end + + for _, icon in pairs(shapeshiftIcons) do + if string.find(lowerBuff, icon) then CancelPlayerBuff(i) return end end - end - end - local buff = GetPlayerBuffTexture(i) - if buff then - for _, icon in pairs(shapeshiftIcons) do - if string.find(string.lower(buff), icon) then + if string.find(lowerBuff, "ability_mount_") then CancelPlayerBuff(i) return end @@ -167,7 +195,7 @@ end -- Data stored in SFrames.castdb[guid] for consumption by castbar features. -------------------------------------------------------------------------------- local function InitSuperWoW() - if not GetPlayerBuffID or not CombatLogAdd or not SpellInfo then return end + if not SpellInfo and not UnitGUID and not SUPERWOW_VERSION then return end local castdb = SFrames.castdb @@ -1031,6 +1059,63 @@ local function InitDarkUI() end) end +-------------------------------------------------------------------------------- +-- Hunter Aspect Guard +-- When a Hunter takes damage with Aspect of the Cheetah or Aspect of the Pack +-- active, automatically cancel the aspect to prevent repeated dazing. +-------------------------------------------------------------------------------- +local function InitHunterAspectGuard() + local _, playerClass = UnitClass("player") + if playerClass ~= "HUNTER" then return end + + local CHEETAH_TEX = "ability_mount_jungletiger" + local PACK_TEX = "ability_mount_packhorse" + + local function CancelDangerousAspect() + for i = 0, 31 do + local buffIdx = GetPlayerBuff(i, "HELPFUL") + if buffIdx and buffIdx >= 0 then + local tex = GetPlayerBuffTexture(buffIdx) + if tex then + local lower = string.lower(tex) + if string.find(lower, CHEETAH_TEX) or string.find(lower, PACK_TEX) then + CancelPlayerBuff(buffIdx) + SFrames:Print("受到伤害,已自动取消守护") + return true + end + end + end + end + return false + end + + local lastHP = UnitHealth("player") or 0 + local lastCancel = 0 + local elapsed = 0 + local frame = CreateFrame("Frame", "NanamiHunterAspectGuard") + frame:SetScript("OnUpdate", function() + elapsed = elapsed + (arg1 or 0) + if elapsed < 0.1 then return end + elapsed = 0 + + local hp = UnitHealth("player") + if hp <= 0 then + lastHP = 0 + return + end + + if lastHP > 0 and hp < lastHP then + if GetTime() - lastCancel >= 1.0 then + if CancelDangerousAspect() then + lastCancel = GetTime() + end + end + end + + lastHP = hp + end) +end + -------------------------------------------------------------------------------- -- Module API -------------------------------------------------------------------------------- @@ -1088,6 +1173,13 @@ function Tweaks:Initialize() end end + if cfg.hunterAspectGuard ~= false then + local ok, err = pcall(InitHunterAspectGuard) + if not ok then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444Nanami-UI: HunterAspectGuard init failed: " .. tostring(err) .. "|r") + end + end + if cfg.darkUI then local ok, err = pcall(InitDarkUI) if not ok then diff --git a/Units/Pet.lua b/Units/Pet.lua index 3970667..cb3c0f8 100644 --- a/Units/Pet.lua +++ b/Units/Pet.lua @@ -350,7 +350,9 @@ function SFrames.Pet:Initialize() self.frame = f self.frame.unit = "pet" f:Hide() - + + self:CreateAuras() + self:CreateHappinessWarning() self:CreateCastbar() SFrames:RegisterEvent("UNIT_PET", function() if arg1 == "player" then self:UpdateAll() end end) @@ -395,6 +397,7 @@ function SFrames.Pet:UpdateAll() if SFramesDB and SFramesDB.showPetFrame == false then self.frame:Hide() if self.foodPanel then self.foodPanel:Hide() end + self:HideAuras() return end @@ -403,6 +406,7 @@ function SFrames.Pet:UpdateAll() self:UpdatePowerType() self:UpdatePower() self:UpdateHappiness() + self:UpdateAuras() local name = UnitName("pet") if name == UNKNOWNOBJECT or name == "未知目标" or name == "Unknown" then @@ -415,6 +419,7 @@ function SFrames.Pet:UpdateAll() else self.frame:Hide() if self.foodPanel then self.foodPanel:Hide() end + self:HideAuras() end end @@ -452,6 +457,7 @@ function SFrames.Pet:UpdateHappiness() local happiness = GetPetHappiness() if not happiness then self.frame.happinessBG:Hide() + self:HideHappinessWarning() self:UpdateFoodButton() return end @@ -471,8 +477,10 @@ function SFrames.Pet:UpdateHappiness() self.frame.happiness:SetTexCoord(0, 0.1875, 0, 0.359375) self.frame.happinessBG:Show() end + self:ShowHappinessWarning(happiness) else self.frame.happinessBG:Hide() + self:HideHappinessWarning() end self:UpdateFoodButton() end @@ -1018,6 +1026,430 @@ function SFrames.Pet:UpdateFoodButton() end end +-------------------------------------------------------------------------------- +-- Pet Buff / Debuff Auras +-------------------------------------------------------------------------------- + +local PET_AURA_SIZE = 20 +local PET_AURA_SPACING = 2 +local PET_AURA_ROW_SPACING = 1 +local PET_AURAS_PER_ROW = 6 +local PET_BUFF_COUNT = 16 +local PET_DEBUFF_COUNT = 16 + +function SFrames.Pet:CreateAuras() + local f = self.frame + f.buffs = {} + f.debuffs = {} + + for i = 1, PET_BUFF_COUNT do + local b = CreateFrame("Button", "SFramesPetBuff" .. i, f) + b:SetWidth(PET_AURA_SIZE) + b:SetHeight(PET_AURA_SIZE) + SFrames:CreateUnitBackdrop(b) + + b.icon = b:CreateTexture(nil, "ARTWORK") + b.icon:SetAllPoints() + b.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + + b.cdText = SFrames:CreateFontString(b, 8, "CENTER") + b.cdText:SetPoint("BOTTOM", b, "BOTTOM", 0, 1) + b.cdText:SetTextColor(1, 0.82, 0) + b.cdText:SetShadowColor(0, 0, 0, 1) + b.cdText:SetShadowOffset(1, -1) + + b:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") + GameTooltip:SetUnitBuff("pet", this:GetID()) + end) + b:SetScript("OnLeave", function() GameTooltip:Hide() end) + + if i == 1 then + b:SetPoint("TOPLEFT", f, "BOTTOMLEFT", 0, -1) + elseif math.mod(i - 1, PET_AURAS_PER_ROW) == 0 then + b:SetPoint("TOP", f.buffs[i - PET_AURAS_PER_ROW], "BOTTOM", 0, -PET_AURA_ROW_SPACING) + else + b:SetPoint("LEFT", f.buffs[i - 1], "RIGHT", PET_AURA_SPACING, 0) + end + b:Hide() + f.buffs[i] = b + end + + for i = 1, PET_DEBUFF_COUNT do + local b = CreateFrame("Button", "SFramesPetDebuff" .. i, f) + b:SetWidth(PET_AURA_SIZE) + b:SetHeight(PET_AURA_SIZE) + SFrames:CreateUnitBackdrop(b) + + b.icon = b:CreateTexture(nil, "ARTWORK") + b.icon:SetAllPoints() + b.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + + b.cdText = SFrames:CreateFontString(b, 8, "CENTER") + b.cdText:SetPoint("BOTTOM", b, "BOTTOM", 0, 1) + b.cdText:SetTextColor(1, 0.82, 0) + b.cdText:SetShadowColor(0, 0, 0, 1) + b.cdText:SetShadowOffset(1, -1) + + b:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") + GameTooltip:SetUnitDebuff("pet", this:GetID()) + end) + b:SetScript("OnLeave", function() GameTooltip:Hide() end) + + if i == 1 then + b:SetPoint("TOPLEFT", f, "BOTTOMLEFT", 0, -1) + elseif math.mod(i - 1, PET_AURAS_PER_ROW) == 0 then + b:SetPoint("TOP", f.debuffs[i - PET_AURAS_PER_ROW], "BOTTOM", 0, -PET_AURA_ROW_SPACING) + else + b:SetPoint("LEFT", f.debuffs[i - 1], "RIGHT", PET_AURA_SPACING, 0) + end + b:Hide() + f.debuffs[i] = b + end + + SFrames:RegisterEvent("UNIT_AURA", function() + if arg1 == "pet" then SFrames.Pet:UpdateAuras() end + end) + + self.petAuraUpdater = CreateFrame("Frame", nil, f) + self.petAuraUpdater.timer = 0 + self.petAuraUpdater:SetScript("OnUpdate", function() + this.timer = this.timer + arg1 + if this.timer >= 0.25 then + SFrames.Pet:TickAuras() + this.timer = 0 + end + end) +end + +function SFrames.Pet:UpdateAuras() + if not UnitExists("pet") then return end + local f = self.frame + if not f.buffs then return end + + local numBuffs = 0 + for i = 1, PET_BUFF_COUNT do + local texture = UnitBuff("pet", i) + local b = f.buffs[i] + b:SetID(i) + if texture then + b.icon:SetTexture(texture) + + if SFrames.Tooltip then + SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") + SFrames.Tooltip:ClearLines() + SFrames.Tooltip:SetUnitBuff("pet", i) + end + local timeLeft = SFrames:GetAuraTimeLeft("pet", i, true) + if SFrames.Tooltip then SFrames.Tooltip:Hide() end + + if timeLeft and timeLeft > 0 then + b.expirationTime = GetTime() + timeLeft + b.cdText:SetText(SFrames:FormatTime(timeLeft)) + else + b.expirationTime = nil + b.cdText:SetText("") + end + + b:Show() + numBuffs = numBuffs + 1 + else + b.expirationTime = nil + b.cdText:SetText("") + b:Hide() + end + end + + local firstDebuff = f.debuffs[1] + if firstDebuff then + firstDebuff:ClearAllPoints() + if numBuffs > 0 then + local lastRowStart = math.floor((numBuffs - 1) / PET_AURAS_PER_ROW) * PET_AURAS_PER_ROW + 1 + firstDebuff:SetPoint("TOP", f.buffs[lastRowStart], "BOTTOM", 0, -PET_AURA_ROW_SPACING) + else + firstDebuff:SetPoint("TOPLEFT", f, "BOTTOMLEFT", 0, -1) + end + end + + local hasNP = NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff + local npFormat = NanamiPlates_Auras and NanamiPlates_Auras.FormatTime + + for i = 1, PET_DEBUFF_COUNT do + local texture, debuffCount, debuffType = UnitDebuff("pet", i) + local b = f.debuffs[i] + b:SetID(i) + if texture then + b.icon:SetTexture(texture) + + if debuffType and DebuffTypeColor and DebuffTypeColor[debuffType] then + local c = DebuffTypeColor[debuffType] + b:SetBackdropBorderColor(c.r, c.g, c.b, 1) + else + b:SetBackdropBorderColor(0.8, 0, 0, 1) + end + + local timeLeft = 0 + local effectName = nil + + if hasNP then + local effect, rank, _, stacks, dtype, duration, npTimeLeft, isOwn = NanamiPlates_SpellDB:UnitDebuff("pet", i) + effectName = effect + if npTimeLeft and npTimeLeft > 0 then + timeLeft = npTimeLeft + elseif effect and effect ~= "" and duration and duration > 0 + and NanamiPlates_Auras and NanamiPlates_Auras.timers then + local unitKey = (UnitGUID and UnitGUID("pet")) or UnitName("pet") or "" + local cached = NanamiPlates_Auras.timers[unitKey .. "_" .. effect] + if not cached and UnitName("pet") then + cached = NanamiPlates_Auras.timers[UnitName("pet") .. "_" .. effect] + end + if cached and cached.startTime and cached.duration then + local remaining = cached.duration - (GetTime() - cached.startTime) + if remaining > 0 then timeLeft = remaining end + end + end + end + + if timeLeft <= 0 then + if SFrames.Tooltip then + SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") + SFrames.Tooltip:ClearLines() + SFrames.Tooltip:SetUnitDebuff("pet", i) + end + timeLeft = SFrames:GetAuraTimeLeft("pet", i, false) + if SFrames.Tooltip then SFrames.Tooltip:Hide() end + end + + if timeLeft and timeLeft > 0 then + b.expirationTime = GetTime() + timeLeft + b.effectName = effectName + if npFormat then + local text, r, g, bc, a = npFormat(timeLeft) + b.cdText:SetText(text) + if r then b.cdText:SetTextColor(r, g, bc, a or 1) end + else + b.cdText:SetText(SFrames:FormatTime(timeLeft)) + end + else + b.expirationTime = nil + b.effectName = nil + b.cdText:SetText("") + end + + b:Show() + else + b.expirationTime = nil + b.effectName = nil + b.cdText:SetText("") + b:SetBackdropBorderColor(0, 0, 0, 1) + b:Hide() + end + end +end + +function SFrames.Pet:TickAuras() + if not UnitExists("pet") then return end + local f = self.frame + if not f.buffs then return end + + local timeNow = GetTime() + local npFormat = NanamiPlates_Auras and NanamiPlates_Auras.FormatTime + local hasNP = NanamiPlates_SpellDB and NanamiPlates_SpellDB.FindEffectData + + local petName, petLevel, petGUID + if hasNP then + petName = UnitName("pet") + petLevel = UnitLevel("pet") or 0 + petGUID = UnitGUID and UnitGUID("pet") + end + + for i = 1, PET_BUFF_COUNT do + local b = f.buffs[i] + if b:IsShown() and b.expirationTime then + local timeLeft = b.expirationTime - timeNow + if timeLeft > 0 and timeLeft < 3600 then + if npFormat then + local text, r, g, bc, a = npFormat(timeLeft) + b.cdText:SetText(text) + if r then b.cdText:SetTextColor(r, g, bc, a or 1) end + else + b.cdText:SetText(SFrames:FormatTime(timeLeft)) + end + else + b.cdText:SetText("") + end + end + end + + for i = 1, PET_DEBUFF_COUNT do + local b = f.debuffs[i] + if b:IsShown() then + local timeLeft = nil + + if hasNP and b.effectName then + local data = petGUID and NanamiPlates_SpellDB:FindEffectData(petGUID, petLevel, b.effectName) + if not data and petName then + data = NanamiPlates_SpellDB:FindEffectData(petName, petLevel, b.effectName) + end + if data and data.start and data.duration then + local remaining = data.duration + data.start - timeNow + if remaining > 0 then + timeLeft = remaining + b.expirationTime = timeNow + remaining + end + end + end + + if not timeLeft and b.expirationTime then + timeLeft = b.expirationTime - timeNow + end + + if timeLeft and timeLeft > 0 and timeLeft < 3600 then + if npFormat then + local text, r, g, bc, a = npFormat(timeLeft) + b.cdText:SetText(text) + if r then b.cdText:SetTextColor(r, g, bc, a or 1) end + else + b.cdText:SetText(SFrames:FormatTime(timeLeft)) + end + else + b.cdText:SetText("") + end + end + end +end + +function SFrames.Pet:HideAuras() + local f = self.frame + if not f or not f.buffs then return end + for i = 1, PET_BUFF_COUNT do + f.buffs[i].expirationTime = nil + f.buffs[i].cdText:SetText("") + f.buffs[i]:Hide() + end + for i = 1, PET_DEBUFF_COUNT do + f.debuffs[i].expirationTime = nil + f.debuffs[i].effectName = nil + f.debuffs[i].cdText:SetText("") + f.debuffs[i]:SetBackdropBorderColor(0, 0, 0, 1) + f.debuffs[i]:Hide() + end +end + +-------------------------------------------------------------------------------- +-- Pet Happiness Warning +-------------------------------------------------------------------------------- + +local WARNING_REMIND_INTERVAL_YELLOW = 60 +local WARNING_REMIND_INTERVAL_RED = 30 + +function SFrames.Pet:CreateHappinessWarning() + local f = self.frame + local fontPath = SFrames:GetFont() + local outline = (SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" + + local warnFrame = CreateFrame("Frame", "SFramesPetHappinessWarn", f) + warnFrame:SetWidth(180) + warnFrame:SetHeight(22) + warnFrame:SetPoint("BOTTOM", f, "TOP", 0, 2) + warnFrame:SetFrameStrata("HIGH") + + local warnBg = warnFrame:CreateTexture(nil, "BACKGROUND") + warnBg:SetAllPoints() + warnBg:SetTexture("Interface\\Buttons\\WHITE8X8") + warnBg:SetVertexColor(0, 0, 0, 0.55) + warnFrame.bg = warnBg + + local warnText = warnFrame:CreateFontString(nil, "OVERLAY") + warnText:SetFont(fontPath, 11, outline) + warnText:SetPoint("CENTER", warnFrame, "CENTER", 0, 0) + warnText:SetShadowColor(0, 0, 0, 1) + warnText:SetShadowOffset(1, -1) + warnFrame.text = warnText + + warnFrame:Hide() + self.warnFrame = warnFrame + self.lastHappiness = nil + self.lastWarnTime = 0 + self.warnFlashAlpha = 1 + self.warnFlashDir = -1 + + warnFrame:SetScript("OnUpdate", function() + SFrames.Pet:WarningFlashUpdate() + end) +end + +function SFrames.Pet:WarningFlashUpdate() + if not self.warnFrame or not self.warnFrame:IsShown() then return end + if not self.warnSeverity or self.warnSeverity ~= "red" then return end + + local speed = 2.5 + local dt = arg1 or 0.016 + self.warnFlashAlpha = self.warnFlashAlpha + self.warnFlashDir * speed * dt + + if self.warnFlashAlpha <= 0.25 then + self.warnFlashAlpha = 0.25 + self.warnFlashDir = 1 + elseif self.warnFlashAlpha >= 1 then + self.warnFlashAlpha = 1 + self.warnFlashDir = -1 + end + + self.warnFrame.text:SetAlpha(self.warnFlashAlpha) + self.warnFrame.bg:SetVertexColor(0.4, 0, 0, 0.55 * self.warnFlashAlpha) +end + +function SFrames.Pet:ShowHappinessWarning(happiness) + if not self.warnFrame then return end + + if happiness == 3 then + self:HideHappinessWarning() + return + end + + local now = GetTime() + local isNewState = (self.lastHappiness ~= happiness) + + if happiness == 2 then + self.warnFrame.text:SetText("宠物心情一般,攻击力受影响!") + self.warnFrame.text:SetTextColor(1, 0.82, 0.2) + self.warnFrame.bg:SetVertexColor(0.3, 0.25, 0, 0.55) + self.warnFrame.text:SetAlpha(1) + self.warnSeverity = "yellow" + self.warnFrame:Show() + + if isNewState or (now - self.lastWarnTime >= WARNING_REMIND_INTERVAL_YELLOW) then + SFrames:Print("|cffffff00宠物心情一般|r - 攻击力下降,请及时喂食!") + self.lastWarnTime = now + end + elseif happiness == 1 then + self.warnFrame.text:SetText("宠物很不开心,快要跑了!") + self.warnFrame.text:SetTextColor(1, 0.2, 0.2) + self.warnFrame.bg:SetVertexColor(0.4, 0, 0, 0.55) + self.warnFlashAlpha = 1 + self.warnFlashDir = -1 + self.warnSeverity = "red" + self.warnFrame:Show() + + if isNewState or (now - self.lastWarnTime >= WARNING_REMIND_INTERVAL_RED) then + SFrames:Print("|cffff3333宠物非常不开心,即将离你而去!|r 请立即喂食!") + UIErrorsFrame:AddMessage("宠物快要跑了!请立即喂食!", 1, 0.2, 0.2, 1, 3) + self.lastWarnTime = now + end + end + + self.lastHappiness = happiness +end + +function SFrames.Pet:HideHappinessWarning() + if self.warnFrame then + self.warnFrame:Hide() + end + self.warnSeverity = nil + self.lastHappiness = nil +end + -------------------------------------------------------------------------------- -- Pet Castbar -------------------------------------------------------------------------------- diff --git a/Units/Player.lua b/Units/Player.lua index 46c4a2f..6c890c3 100644 --- a/Units/Player.lua +++ b/Units/Player.lua @@ -1613,13 +1613,24 @@ function SFrames.Player:CastbarStart(spellName, duration) cb:SetMinMaxValues(0, cb.maxValue) cb:SetValue(0) cb.text:SetText(spellName) - + local texture local _UnitCastingInfo = UnitCastingInfo or (ShaguTweaks and ShaguTweaks.UnitCastingInfo) if _UnitCastingInfo then local _, _, _, tex = _UnitCastingInfo("player") texture = tex end + if not texture and SFrames.castdb and UnitGUID then + local guid = UnitGUID("player") + if guid and SFrames.castdb[guid] and SFrames.castdb[guid].icon then + texture = SFrames.castdb[guid].icon + end + end + if (not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark") + and SFrames.GetSpellIcon then + texture = SFrames.GetSpellIcon(spellName) or texture + end + if texture then cb.icon:SetTexture(texture) cb.icon:Show() @@ -1650,13 +1661,24 @@ function SFrames.Player:CastbarChannelStart(duration, spellName) cb:SetMinMaxValues(0, cb.maxValue) cb:SetValue(cb.maxValue) cb.text:SetText(spellName) - + local texture local _UnitChannelInfo = UnitChannelInfo or (ShaguTweaks and ShaguTweaks.UnitChannelInfo) if _UnitChannelInfo then local _, _, _, tex = _UnitChannelInfo("player") texture = tex end + if not texture and SFrames.castdb and UnitGUID then + local guid = UnitGUID("player") + if guid and SFrames.castdb[guid] and SFrames.castdb[guid].icon then + texture = SFrames.castdb[guid].icon + end + end + if (not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark") + and SFrames.GetSpellIcon then + texture = SFrames.GetSpellIcon(spellName) or texture + end + if texture then cb.icon:SetTexture(texture) cb.icon:Show() @@ -1714,6 +1736,9 @@ function SFrames.Player:CastbarOnUpdate() end cb:SetValue(elapsed) cb.time:SetText(string.format("%.1f", math.max(cb.maxValue - elapsed, 0))) + if not cb.icon:IsShown() then + self:CastbarTryResolveIcon() + end elseif cb.channeling then local timeRemaining = cb.endTime - GetTime() if timeRemaining <= 0 then @@ -1724,6 +1749,9 @@ function SFrames.Player:CastbarOnUpdate() end cb:SetValue(timeRemaining) cb.time:SetText(string.format("%.1f", timeRemaining)) + if not cb.icon:IsShown() then + self:CastbarTryResolveIcon() + end elseif cb.fadeOut then local alpha = cb:GetAlpha() - 0.05 if alpha > 0 then @@ -1740,3 +1768,41 @@ function SFrames.Player:CastbarOnUpdate() end end end + +function SFrames.Player:CastbarTryResolveIcon() + local cb = self.frame.castbar + local spellName = cb.text:GetText() + local texture + + if SFrames.castdb and UnitGUID then + local guid = UnitGUID("player") + if guid and SFrames.castdb[guid] then + local entry = SFrames.castdb[guid] + if entry.icon and entry.icon ~= "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = entry.icon + end + end + end + + if not texture and NanamiPlates and NanamiPlates.castDB and UnitGUID then + local guid = UnitGUID("player") + if guid and NanamiPlates.castDB[guid] then + local entry = NanamiPlates.castDB[guid] + if entry.icon and entry.icon ~= "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = entry.icon + end + end + end + + if not texture and SFrames.GetSpellIcon then + texture = SFrames.GetSpellIcon(spellName) + end + + if texture then + cb.icon:SetTexture(texture) + cb.icon:SetAlpha(1) + cb.ibg:SetAlpha(1) + cb.icon:Show() + cb.ibg:Show() + end +end diff --git a/Units/Target.lua b/Units/Target.lua index 6e127c8..39f671c 100644 --- a/Units/Target.lua +++ b/Units/Target.lua @@ -1,6 +1,177 @@ SFrames.Target = {} local _A = SFrames.ActiveTheme +local targetCLCast = nil +local spellIconCache = {} + +local SPELL_ICONS = { + -- Mage + ["Fireball"] = "Spell_Fire_FlameBolt", + ["火球术"] = "Spell_Fire_FlameBolt", + ["Frostbolt"] = "Spell_Frost_FrostBolt02", + ["寒冰箭"] = "Spell_Frost_FrostBolt02", + ["Polymorph"] = "Spell_Nature_Polymorph", + ["变形术"] = "Spell_Nature_Polymorph", + ["Arcane Missiles"] = "Spell_Nature_StarFall", + ["奥术飞弹"] = "Spell_Nature_StarFall", + ["Pyroblast"] = "Spell_Fire_Fireball02", + ["炎爆术"] = "Spell_Fire_Fireball02", + ["Scorch"] = "Spell_Fire_SoulBurn", + ["灼烧"] = "Spell_Fire_SoulBurn", + ["Flamestrike"] = "Spell_Fire_SelfDestruct", + ["烈焰风暴"] = "Spell_Fire_SelfDestruct", + ["Blizzard"] = "Spell_Frost_IceStorm", + ["暴风雪"] = "Spell_Frost_IceStorm", + -- Warlock + ["Shadow Bolt"] = "Spell_Shadow_ShadowBolt", + ["暗影箭"] = "Spell_Shadow_ShadowBolt", + ["Fear"] = "Spell_Shadow_Possession", + ["恐惧术"] = "Spell_Shadow_Possession", + ["Immolate"] = "Spell_Fire_Immolation", + ["献祭"] = "Spell_Fire_Immolation", + ["Soul Fire"] = "Spell_Fire_Fireball02", + ["灵魂之火"] = "Spell_Fire_Fireball02", + ["Drain Life"] = "Spell_Shadow_LifeDrain02", + ["吸取生命"] = "Spell_Shadow_LifeDrain02", + ["Drain Mana"] = "Spell_Shadow_SiphonMana", + ["吸取法力"] = "Spell_Shadow_SiphonMana", + ["Rain of Fire"] = "Spell_Shadow_RainOfFire", + ["火焰之雨"] = "Spell_Shadow_RainOfFire", + ["Hellfire"] = "Spell_Fire_Incinerate", + ["地狱烈焰"] = "Spell_Fire_Incinerate", + -- Priest + ["Greater Heal"] = "Spell_Holy_GreaterHeal", + ["强效治疗术"] = "Spell_Holy_GreaterHeal", + ["Flash Heal"] = "Spell_Holy_FlashHeal", + ["快速治疗"] = "Spell_Holy_FlashHeal", + ["Heal"] = "Spell_Holy_Heal", + ["治疗术"] = "Spell_Holy_Heal", + ["Smite"] = "Spell_Holy_HolySmite", + ["惩击"] = "Spell_Holy_HolySmite", + ["Mind Blast"] = "Spell_Shadow_UnholyFrenzy", + ["心灵震爆"] = "Spell_Shadow_UnholyFrenzy", + ["Mind Flay"] = "Spell_Shadow_SiphonMana", + ["精神鞭笞"] = "Spell_Shadow_SiphonMana", + ["Mind Control"] = "Spell_Shadow_ShadowWordDominate", + ["精神控制"] = "Spell_Shadow_ShadowWordDominate", + ["Holy Fire"] = "Spell_Holy_SearingLight", + ["神圣之火"] = "Spell_Holy_SearingLight", + ["Resurrection"] = "Spell_Holy_Resurrection", + ["复活术"] = "Spell_Holy_Resurrection", + -- Shaman + ["Lightning Bolt"] = "Spell_Nature_Lightning", + ["闪电箭"] = "Spell_Nature_Lightning", + ["Chain Lightning"] = "Spell_Nature_ChainLightning", + ["闪电链"] = "Spell_Nature_ChainLightning", + ["Healing Wave"] = "Spell_Nature_MagicImmunity", + ["治疗波"] = "Spell_Nature_MagicImmunity", + ["Lesser Healing Wave"] = "Spell_Nature_HealingWaveLesser", + ["次级治疗波"] = "Spell_Nature_HealingWaveLesser", + ["Chain Heal"] = "Spell_Nature_HealingWaveGreater", + ["治疗链"] = "Spell_Nature_HealingWaveGreater", + ["Ancestral Spirit"] = "Spell_Nature_Regenerate", + ["先祖之魂"] = "Spell_Nature_Regenerate", + -- Druid + ["Wrath"] = "Spell_Nature_AbolishMagic", + ["愤怒"] = "Spell_Nature_AbolishMagic", + ["Starfire"] = "Spell_Arcane_StarFire", + ["星火术"] = "Spell_Arcane_StarFire", + ["Regrowth"] = "Spell_Nature_ResistNature", + ["愈合"] = "Spell_Nature_ResistNature", + ["Healing Touch"] = "Spell_Nature_HealingTouch", + ["治疗之触"] = "Spell_Nature_HealingTouch", + ["Entangling Roots"] = "Spell_Nature_StrangleVines", + ["纠缠根须"] = "Spell_Nature_StrangleVines", + ["Hibernate"] = "Spell_Nature_Sleep", + ["休眠"] = "Spell_Nature_Sleep", + ["Rebirth"] = "Spell_Nature_Reincarnation", + ["复生"] = "Spell_Nature_Reincarnation", + ["Tranquility"] = "Spell_Nature_Tranquility", + ["宁静"] = "Spell_Nature_Tranquility", + ["Moonfire"] = "Spell_Nature_StarFall", + ["月火术"] = "Spell_Nature_StarFall", + -- Paladin + ["Holy Light"] = "Spell_Holy_HolyBolt", + ["圣光术"] = "Spell_Holy_HolyBolt", + ["Flash of Light"] = "Spell_Holy_FlashHeal", + ["圣光闪现"] = "Spell_Holy_FlashHeal", + ["Hammer of Wrath"] = "Spell_Holy_SealOfMight", + ["愤怒之锤"] = "Spell_Holy_SealOfMight", + ["Exorcism"] = "Spell_Holy_Excorcism_02", + ["驱邪术"] = "Spell_Holy_Excorcism_02", + ["Redemption"] = "Spell_Holy_Resurrection", + ["救赎"] = "Spell_Holy_Resurrection", + -- Hunter + ["Aimed Shot"] = "INV_Spear_07", + ["瞄准射击"] = "INV_Spear_07", + ["Multi-Shot"] = "Ability_UpgradeMoonGlaive", + ["多重射击"] = "Ability_UpgradeMoonGlaive", + ["Volley"] = "Ability_Marksmanship", + ["乱射"] = "Ability_Marksmanship", + ["Revive Pet"] = "Ability_Hunter_BeastSoothe", + ["复活宠物"] = "Ability_Hunter_BeastSoothe", + -- Common NPC / generic + ["Shoot"] = "Ability_Marksmanship", + ["射击"] = "Ability_Marksmanship", + ["Mend"] = "Spell_Holy_Heal", + ["修补"] = "Spell_Holy_Heal", + ["Rejuvenation"] = "Spell_Nature_Rejuvenation", + ["回春术"] = "Spell_Nature_Rejuvenation", +} +for k, v in pairs(SPELL_ICONS) do + SPELL_ICONS[k] = "Interface\\Icons\\" .. v +end + +local function BuildSpellIconCache() + if not GetSpellName or not GetSpellTexture then return end + local i = 1 + while true do + local name = GetSpellName(i, "spell") + if not name then break end + local tex = GetSpellTexture(i, "spell") + if tex then spellIconCache[name] = tex end + i = i + 1 + end +end + +local function GetSpellIcon(spellName) + if not spellName then return nil end + local tex = spellIconCache[spellName] + or SPELL_ICONS[spellName] + or (NanamiPlates_CombatLog and NanamiPlates_CombatLog.castIcons + and NanamiPlates_CombatLog.castIcons[spellName]) + if tex then return tex end + if GetSpellName and GetSpellTexture then + local i = 1 + while true do + local name = GetSpellName(i, "spell") + if not name then break end + if name == spellName then + tex = GetSpellTexture(i, "spell") + if tex then + spellIconCache[spellName] = tex + return tex + end + end + i = i + 1 + end + end + return nil +end + +SFrames.GetSpellIcon = GetSpellIcon +SFrames.BuildSpellIconCache = BuildSpellIconCache + +local function CLMatch(str, pattern) + if not str or not pattern then return nil end + local pat = string.gsub(pattern, "%%%d?%$?s", "(.+)") + pat = string.gsub(pat, "%%%d?%$?d", "(%%d+)") + for a, b, c, d in string.gfind(str, pat) do + return a, b, c, d + end + return nil +end + local function Clamp(value, minValue, maxValue) if value < minValue then return minValue @@ -467,7 +638,11 @@ function SFrames.Target:Initialize() self:CreateAuras() self:CreateCastbar() self:InitializeDistanceFrame() - + self:InitCastDetection() + + BuildSpellIconCache() + SFrames:RegisterEvent("SPELLS_CHANGED", BuildSpellIconCache) + f.unit = "target" f:SetScript("OnEnter", function() GameTooltip_SetDefaultAnchor(GameTooltip, this) @@ -492,11 +667,181 @@ function SFrames.Target:Initialize() end end +function SFrames.Target:InitCastDetection() + local castFrame = CreateFrame("Frame", nil, UIParent) + + castFrame:RegisterEvent("UNIT_CASTEVENT") + castFrame:RegisterEvent("SPELLCAST_START") + castFrame:RegisterEvent("SPELLCAST_STOP") + castFrame:RegisterEvent("SPELLCAST_FAILED") + castFrame:RegisterEvent("SPELLCAST_INTERRUPTED") + castFrame:RegisterEvent("SPELLCAST_CHANNEL_START") + castFrame:RegisterEvent("SPELLCAST_CHANNEL_STOP") + + local CL_EVENTS = { + "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF", + "CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF", + "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF", + "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF", + "CHAT_MSG_SPELL_PARTY_DAMAGE", + "CHAT_MSG_SPELL_PARTY_BUFF", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_SELF_BUFF", + } + for _, ev in ipairs(CL_EVENTS) do + castFrame:RegisterEvent(ev) + end + + local function ResolveSelfIcon(spellName) + local texture + local _UCI = UnitCastingInfo or (ShaguTweaks and ShaguTweaks.UnitCastingInfo) + if _UCI then + local _, _, _, tex = _UCI("player") + texture = tex + end + if not texture then + local _UCH = UnitChannelInfo or (ShaguTweaks and ShaguTweaks.UnitChannelInfo) + if _UCH then + local _, _, _, tex = _UCH("player") + texture = tex + end + end + if not texture and SFrames.castdb and UnitGUID then + local guid = UnitGUID("player") + if guid and SFrames.castdb[guid] and SFrames.castdb[guid].icon then + texture = SFrames.castdb[guid].icon + end + end + if not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = GetSpellIcon(spellName) or texture + end + return texture or "Interface\\Icons\\INV_Misc_QuestionMark" + end + + castFrame:SetScript("OnEvent", function() + -- Player's own cast events (for self-target and friendly-target-is-self) + if event == "SPELLCAST_START" then + if UnitExists("target") and UnitIsUnit("target", "player") then + local spellName = arg1 + local duration = arg2 + targetCLCast = { + spell = spellName, + startTime = GetTime(), + duration = (duration or 2000) / 1000, + icon = ResolveSelfIcon(spellName), + channel = false, + } + end + return + end + if event == "SPELLCAST_CHANNEL_START" then + if UnitExists("target") and UnitIsUnit("target", "player") then + local duration = arg1 + local spellName = arg2 + targetCLCast = { + spell = spellName, + startTime = GetTime(), + duration = (duration or 2000) / 1000, + icon = ResolveSelfIcon(spellName), + channel = true, + } + end + return + end + if event == "SPELLCAST_STOP" or event == "SPELLCAST_FAILED" + or event == "SPELLCAST_INTERRUPTED" or event == "SPELLCAST_CHANNEL_STOP" then + if UnitExists("target") and UnitIsUnit("target", "player") then + targetCLCast = nil + end + return + end + + -- UNIT_CASTEVENT (SuperWoW / TurtleWoW): works for all units + if event == "UNIT_CASTEVENT" then + if not UnitGUID or not UnitExists("target") then return end + local targetGUID = UnitGUID("target") + if not targetGUID or arg1 ~= targetGUID then return end + + if arg3 == "START" or arg3 == "CAST" or arg3 == "CHANNEL" then + local spell, icon + if SpellInfo and arg4 then + spell, _, icon = SpellInfo(arg4) + end + spell = spell or "Casting" + if not icon or icon == "" or icon == "Interface\\Icons\\INV_Misc_QuestionMark" then + icon = GetSpellIcon(spell) or icon + end + icon = icon or "Interface\\Icons\\INV_Misc_QuestionMark" + targetCLCast = { + spell = spell, + startTime = GetTime(), + duration = (arg5 or 2000) / 1000, + icon = icon, + channel = (arg3 == "CHANNEL"), + } + elseif arg3 == "FAIL" then + targetCLCast = nil + end + return + end + + -- Combat log parsing: "X begins to cast Y" (third-person, all other units) + if not arg1 or not UnitExists("target") then return end + local targetName = UnitName("target") + if not targetName then return end + + local msg = arg1 + local caster, spell + + local castStart = SPELLCASTOTHERSTART or "%s begins to cast %s." + caster, spell = CLMatch(msg, castStart) + if not caster then + local perfStart = SPELLPERFORMOTHERSTART or "%s begins to perform %s." + caster, spell = CLMatch(msg, perfStart) + end + + if caster and caster == targetName and spell then + local icon = GetSpellIcon(spell) or "Interface\\Icons\\INV_Misc_QuestionMark" + targetCLCast = { + spell = spell, + startTime = GetTime(), + duration = 2.0, + icon = icon, + channel = false, + } + return + end + + if targetCLCast then + local isFail = false + for u in string.gfind(msg, "(.+)'s .+ is interrupted%.") do + if u == targetName then isFail = true end + end + if not isFail then + for u in string.gfind(msg, "(.+)'s .+ fails%.") do + if u == targetName then isFail = true end + end + end + if not isFail and SPELLINTERRUPTOTHEROTHER then + local a = CLMatch(msg, SPELLINTERRUPTOTHEROTHER) + if a == targetName then isFail = true end + end + if isFail then targetCLCast = nil end + end + end) +end + function SFrames.Target:OnTargetChanged() + targetCLCast = nil if UnitExists("target") then self.frame:Show() self:UpdateAll() - -- Force distance update immediately if SFrames.Target.distanceFrame then local dist = self:GetDistance("target") SFrames.Target.distanceFrame.text:SetText(dist or "---") @@ -1141,49 +1486,149 @@ function SFrames.Target:CastbarOnUpdate() cb.ibg:Hide() return end - - -- Try to read cast from Vanilla extensions (SuperWoW or TurtleWoW modern API, or ShaguTweaks) - local cast, nameSubtext, text, texture, startTime, endTime + + local cast, texture, startTime, endTime, channel + + -- 1) UnitCastingInfo / UnitChannelInfo (TurtleWoW / ShaguTweaks) local _UnitCastingInfo = UnitCastingInfo or (ShaguTweaks and ShaguTweaks.UnitCastingInfo) if _UnitCastingInfo then - cast, nameSubtext, text, texture, startTime, endTime = _UnitCastingInfo("target") + local c, _, _, tex, st, et = _UnitCastingInfo("target") + if c then + cast, texture, startTime, endTime = c, tex, st, et + end end - - local channel - local _UnitChannelInfo = UnitChannelInfo or (ShaguTweaks and ShaguTweaks.UnitChannelInfo) - if not cast and _UnitChannelInfo then - channel, nameSubtext, text, texture, startTime, endTime = _UnitChannelInfo("target") - cast = channel + + if not cast then + local _UnitChannelInfo = UnitChannelInfo or (ShaguTweaks and ShaguTweaks.UnitChannelInfo) + if _UnitChannelInfo then + local c, _, _, tex, st, et = _UnitChannelInfo("target") + if c then + cast, texture, startTime, endTime = c, tex, st, et + channel = true + end + end + end + + -- 2) SFrames.castdb (UNIT_CASTEVENT via Tweaks.lua, has SpellInfo icon) + if SFrames.castdb and UnitGUID then + local guid = UnitGUID("target") + if guid then + local entry = SFrames.castdb[guid] + if entry and entry.cast and entry.start and entry.casttime then + local elapsed = GetTime() - entry.start + local duration = entry.casttime / 1000 + if elapsed < duration + 0.5 then + if not cast then + cast = entry.cast + startTime = entry.start * 1000 + endTime = (entry.start + duration) * 1000 + channel = entry.channel + end + if not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = entry.icon + end + end + end + end + end + + -- 3) NanamiPlates castDB (GUID-based, UNIT_CASTEVENT with SpellInfo icon) + if NanamiPlates and NanamiPlates.castDB and UnitGUID then + local guid = UnitGUID("target") + if guid then + local entry = NanamiPlates.castDB[guid] + if entry and entry.spell and entry.startTime and entry.duration then + local elapsed = GetTime() - entry.startTime + local duration = entry.duration / 1000 + if elapsed < duration + 0.5 then + if not cast then + cast = entry.spell + startTime = entry.startTime * 1000 + endTime = (entry.startTime + duration) * 1000 + channel = entry.channel + end + if not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = entry.icon + end + end + end + end + end + + -- 4) NanamiPlates castTracker (name-based, combat log) + if not cast and NanamiPlates and NanamiPlates.castTracker then + local targetName = UnitName("target") + if targetName and NanamiPlates.castTracker[targetName] then + local entries = NanamiPlates.castTracker[targetName] + if entries and entries[1] then + local entry = entries[1] + if entry.spell and entry.startTime then + local duration = (entry.duration or 2000) / 1000 + local elapsed = GetTime() - entry.startTime + if elapsed < duration + 0.5 then + cast = entry.spell + texture = entry.icon + startTime = entry.startTime * 1000 + endTime = (entry.startTime + duration) * 1000 + channel = false + end + end + end + end + end + + -- 5) Local UNIT_CASTEVENT / SPELLCAST_* tracker + if targetCLCast then + local elapsed = GetTime() - targetCLCast.startTime + if elapsed < targetCLCast.duration + 0.5 then + if not cast then + cast = targetCLCast.spell + startTime = targetCLCast.startTime * 1000 + endTime = (targetCLCast.startTime + targetCLCast.duration) * 1000 + channel = targetCLCast.channel + end + if not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = targetCLCast.icon + end + else + targetCLCast = nil + end end if cast and startTime and endTime then local duration = (endTime - startTime) / 1000 local cur = GetTime() - (startTime / 1000) - + if channel then cur = duration + (startTime / 1000) - GetTime() end - + if cur > duration then cur = duration end if cur < 0 then cur = 0 end - + cb:SetMinMaxValues(0, duration) cb:SetValue(cur) cb.text:SetText(cast) - - if texture then - cb.icon:SetTexture(texture) + + if not texture or texture == "Interface\\Icons\\INV_Misc_QuestionMark" then + texture = GetSpellIcon(cast) or texture end - + cb:SetAlpha(1) cb.cbbg:SetAlpha(1) - cb.icon:SetAlpha(1) - cb.ibg:SetAlpha(1) - cb:Show() cb.cbbg:Show() - cb.icon:Show() - cb.ibg:Show() + + if texture then + cb.icon:SetTexture(texture) + cb.icon:SetAlpha(1) + cb.ibg:SetAlpha(1) + cb.icon:Show() + cb.ibg:Show() + else + cb.icon:Hide() + cb.ibg:Hide() + end else cb:Hide() cb.cbbg:Hide() diff --git a/ZoneLevelRange.lua b/ZoneLevelRange.lua new file mode 100644 index 0000000..d899f64 --- /dev/null +++ b/ZoneLevelRange.lua @@ -0,0 +1,385 @@ +-------------------------------------------------------------------------------- +-- Nanami-UI: Zone Level Range Module (ZoneLevelRange.lua) +-- Displays zone level ranges, territory status, instances and raids +-- on the world map continent view using Nanami-themed tooltip. +-- Self-contained data; supersedes LevelRange-Turtle's display. +-------------------------------------------------------------------------------- + +SFrames = SFrames or {} +SFrames.ZoneLevelRange = {} +local ZLR = SFrames.ZoneLevelRange + +-------------------------------------------------------------------------------- +-- Locale helper +-------------------------------------------------------------------------------- +local isZH = (GetLocale() == "zhCN") +local function N(en, zh) return isZH and zh or en end + +-------------------------------------------------------------------------------- +-- Faction constants +-------------------------------------------------------------------------------- +local F_A = "Alliance" +local F_H = "Horde" +local F_C = "Contested" + +-------------------------------------------------------------------------------- +-- Zone Level Ranges: [localizedName] = { min, max, factionType } +-------------------------------------------------------------------------------- +local ZONE_RANGES = { + -- Eastern Kingdoms + [N("Elwynn Forest", "艾尔文森林")] = { 1, 10, F_A }, + [N("Dun Morogh", "丹莫罗")] = { 1, 10, F_A }, + [N("Tirisfal Glades", "提瑞斯法林地")] = { 1, 10, F_H }, + [N("Loch Modan", "洛克莫丹")] = { 10, 20, F_A }, + [N("Silverpine Forest", "银松森林")] = { 10, 20, F_H }, + [N("Westfall", "西部荒野")] = { 10, 20, F_A }, + [N("Redridge Mountains", "赤脊山")] = { 15, 25, F_C }, + [N("Duskwood", "暮色森林")] = { 18, 30, F_C }, + [N("Hillsbrad Foothills", "希尔斯布莱德丘陵")] = { 20, 30, F_C }, + [N("Wetlands", "湿地")] = { 20, 30, F_C }, + [N("Alterac Mountains", "奥特兰克山脉")] = { 30, 40, F_C }, + [N("Arathi Highlands", "阿拉希高地")] = { 30, 40, F_C }, + [N("Stranglethorn Vale", "荆棘谷")] = { 30, 45, F_C }, + [N("Badlands", "荒芜之地")] = { 35, 45, F_C }, + [N("Swamp of Sorrows", "悲伤沼泽")] = { 35, 45, F_C }, + [N("The Hinterlands", "辛特兰")] = { 40, 50, F_C }, + [N("Searing Gorge", "灼热峡谷")] = { 43, 50, F_C }, + [N("Blasted Lands", "诅咒之地")] = { 45, 55, F_C }, + [N("Burning Steppes", "燃烧平原")] = { 50, 58, F_C }, + [N("Western Plaguelands", "西瘟疫之地")] = { 51, 58, F_C }, + [N("Eastern Plaguelands", "东瘟疫之地")] = { 53, 60, F_C }, + [N("Deadwind Pass", "逆风小径")] = { 55, 60, F_C }, + + -- Kalimdor + [N("Durotar", "杜隆塔尔")] = { 1, 10, F_H }, + [N("Mulgore", "莫高雷")] = { 1, 10, F_H }, + [N("Teldrassil", "泰达希尔")] = { 1, 10, F_A }, + [N("Darkshore", "黑海岸")] = { 10, 20, F_A }, + [N("The Barrens", "贫瘠之地")] = { 10, 25, F_H }, + [N("Stonetalon Mountains", "石爪山脉")] = { 15, 27, F_C }, + [N("Ashenvale", "灰谷")] = { 18, 30, F_C }, + [N("Thousand Needles", "千针石林")] = { 25, 35, F_C }, + [N("Desolace", "凄凉之地")] = { 30, 40, F_C }, + [N("Dustwallow Marsh", "尘泥沼泽")] = { 35, 45, F_C }, + [N("Feralas", "菲拉斯")] = { 40, 50, F_C }, + [N("Tanaris", "塔纳利斯")] = { 40, 50, F_C }, + [N("Azshara", "艾萨拉")] = { 45, 55, F_C }, + [N("Felwood", "费伍德森林")] = { 48, 55, F_C }, + [N("Un'Goro Crater", "安戈洛环形山")] = { 48, 55, F_C }, + [N("Silithus", "希利苏斯")] = { 55, 60, F_C }, + [N("Winterspring", "冬泉谷")] = { 55, 60, F_C }, + [N("Moonglade", "月光林地")] = { 1, 60, F_C }, + + -- Turtle WoW + [N("Thalassian Highlands", "阿尔萨拉斯")] = { 1, 10, F_A }, + [N("Blackstone Island", "黑石岛")] = { 1, 10, F_H }, + [N("Gilneas", "吉尔尼斯")] = { 39, 46, F_C }, + [N("Gillijim's Isle", "吉利吉姆之岛")] = { 48, 53, F_C }, + [N("Lapidis Isle", "拉匹迪斯之岛")] = { 48, 53, F_C }, + [N("Tel'Abim", "泰拉比姆")] = { 54, 60, F_C }, + [N("Scarlet Enclave", "东瘟疫之地:血色领地")] = { 55, 60, F_C }, + [N("Hyjal", "海加尔山")] = { 58, 60, F_C }, + [N("Grim Reaches", "冷酷海岸")] = { 33, 38, F_C }, + [N("Northwind", "北风领")] = { 28, 34, F_C }, + [N("Balor", "巴洛")] = { 29, 34, F_C }, + [N("Moonsong Coast", "月语海岸")] = { 53, 58, F_C }, +} + +-------------------------------------------------------------------------------- +-- Instances: [zoneName] = { { instanceName, levelString }, ... } +-------------------------------------------------------------------------------- +local ZONE_INSTANCES = { + [N("Westfall", "西部荒野")] = { { N("Deadmines", "死亡矿井"), "17-26" } }, + [N("The Barrens", "贫瘠之地")] = { { N("Wailing Caverns", "哀嚎洞穴"), "17-24" }, + { N("Razorfen Kraul", "剃刀高地"), "25-30" }, + { N("Razorfen Downs", "剃刀沼泽"), "33-45" } }, + [N("Silverpine Forest", "银松森林")] = { { N("Shadowfang Keep", "影牙城堡"), "22-30" } }, + [N("Dun Morogh", "丹莫罗")] = { { N("Gnomeregan", "诺莫瑞根"), "29-38" } }, + [N("Tirisfal Glades", "提瑞斯法林地")] = { { N("The Scarlet Monastery","血色修道院"), "34-45" } }, + [N("Badlands", "荒芜之地")] = { { N("Uldaman", "奥达曼"), "35-47" } }, + [N("Desolace", "凄凉之地")] = { { N("Maraudon", "玛拉顿"), "46-55" } }, + [N("Swamp of Sorrows", "悲伤沼泽")] = { { N("The Sunken Temple", "沉没的神庙"), "45-55" } }, + [N("Searing Gorge", "灼热峡谷")] = { { N("Blackrock Depths", "黑石深渊"), "52-60" }, + { N("Blackrock Spire", "黑石塔"), "58-60" } }, + [N("Eastern Plaguelands", "东瘟疫之地")] = { { N("Stratholme", "斯坦索姆"), "58-60" } }, + [N("Feralas", "菲拉斯")] = { { N("Dire Maul", "厄运之槌"), "55-60" } }, + [N("Western Plaguelands", "西瘟疫之地")] = { { N("Scholomance", "通灵学院"), "57-60" } }, + [N("Durotar", "杜隆塔尔")] = { { N("Ragefire Chasm", "怒焰裂谷"), "13-18" } }, + [N("Ashenvale", "灰谷")] = { { N("Blackfathom Deeps", "黑暗深渊"), "24-32" }, + { N("The Crescent Grove", "新月林地"), "32-38" } }, + [N("Gilneas", "吉尔尼斯")] = { { N("Gilneas City", "吉尔尼斯城"), "43-49" } }, + [N("Burning Steppes", "燃烧平原")] = { { N("Hateforge Quarry", "仇恨熔炉采石场"), "52-60" }, + { N("Blackrock Depths", "黑石深渊"), "52-60" }, + { N("Blackrock Spire", "黑石塔"), "58-60" } }, + [N("Deadwind Pass", "逆风小径")] = { { N("Karazhan Crypt", "卡拉赞地穴"), "58-60" } }, + [N("Elwynn Forest", "艾尔文森林")] = { { N("The Stockades", "监狱"), "24-32" }, + { N("Stormwind Vault", "暴风城地窖"), "60+" } }, + [N("Tanaris", "塔纳利斯")] = { { N("Zul'Farrak", "祖尔法拉克"), "44-54" }, + { N("Caverns of Time: The Black Morass", "时光之穴:黑色沼泽"), "60+" } }, + [N("Balor", "巴洛")] = { { N("Stormwrought Ruins", "风暴废墟"), "35-41" } }, + [N("Wetlands", "湿地")] = { { N("Dragonmaw Retreat", "龙喉要塞"), "27-33" } }, + [N("Moonsong Coast", "月语海岸")] = { { N("Timbermaw Hold", "木喉要塞"), "60+" } }, +} + +-------------------------------------------------------------------------------- +-- Raids +-------------------------------------------------------------------------------- +local ZONE_RAIDS = { + [N("Eastern Plaguelands", "东瘟疫之地")] = { { N("Naxxramas", "纳克萨玛斯"), "60+" } }, + [N("Dustwallow Marsh", "尘泥沼泽")] = { { N("Onyxia's Lair", "奥妮克希亚的巢穴"), "60+" } }, + [N("Silithus", "希利苏斯")] = { { N("Ruins of Ahn'Qiraj", "安其拉废墟"), "60+" }, + { N("Temple of Ahn'Qiraj", "安其拉神庙"), "60+" } }, + [N("Stranglethorn Vale", "荆棘谷")] = { { N("Zul'Gurub", "祖尔格拉布"), "60+" } }, + [N("Hyjal", "海加尔山")] = { { N("Emerald Sanctum", "翡翠圣所"), "60+" } }, + [N("Deadwind Pass", "逆风小径")] = { { N("Lower Karazhan Halls", "下层卡拉赞大厅"), "60+" } }, +} + +-------------------------------------------------------------------------------- +-- Fishing Level Requirements +-------------------------------------------------------------------------------- +local ZONE_FISHING = { + [N("Elwynn Forest", "艾尔文森林")] = 25, + [N("Dun Morogh", "丹莫罗")] = 25, + [N("Tirisfal Glades", "提瑞斯法林地")] = 25, + [N("Loch Modan", "洛克莫丹")] = 75, + [N("Silverpine Forest", "银松森林")] = 75, + [N("Westfall", "西部荒野")] = 75, + [N("Redridge Mountains", "赤脊山")] = 150, + [N("Duskwood", "暮色森林")] = 150, + [N("Hillsbrad Foothills", "希尔斯布莱德丘陵")] = 150, + [N("Wetlands", "湿地")] = 150, + [N("Alterac Mountains", "奥特兰克山脉")] = 225, + [N("Arathi Highlands", "阿拉希高地")] = 225, + [N("Stranglethorn Vale", "荆棘谷")] = 225, + [N("Swamp of Sorrows", "悲伤沼泽")] = 225, + [N("The Hinterlands", "辛特兰")] = 300, + [N("Western Plaguelands", "西瘟疫之地")] = 300, + [N("Durotar", "杜隆塔尔")] = 25, + [N("Mulgore", "莫高雷")] = 25, + [N("Teldrassil", "泰达希尔")] = 25, + [N("Darkshore", "黑海岸")] = 75, + [N("The Barrens", "贫瘠之地")] = 75, + [N("Stonetalon Mountains","石爪山脉")] = 150, + [N("Ashenvale", "灰谷")] = 150, + [N("Thousand Needles", "千针石林")] = 225, + [N("Desolace", "凄凉之地")] = 225, + [N("Dustwallow Marsh", "尘泥沼泽")] = 225, + [N("Feralas", "菲拉斯")] = 300, + [N("Tanaris", "塔纳利斯")] = 300, + [N("Azshara", "艾萨拉")] = 300, + [N("Felwood", "费伍德森林")] = 300, + [N("Un'Goro Crater", "安戈洛环形山")] = 300, + [N("Moonglade", "月光林地")] = 300, +} + +-------------------------------------------------------------------------------- +-- Sub-zone -> Parent Zone Mapping +-------------------------------------------------------------------------------- +local ZONE_SUBZONES = { + [N("Orgrimmar", "奥格瑞玛")] = N("Durotar", "杜隆塔尔"), + [N("Thunder Bluff", "雷霆崖")] = N("Mulgore", "莫高雷"), + [N("Undercity", "幽暗城")] = N("Tirisfal Glades", "提瑞斯法林地"), + [N("Ironforge", "铁炉堡")] = N("Dun Morogh", "丹莫罗"), + [N("Stormwind City", "暴风城")] = N("Elwynn Forest", "艾尔文森林"), + [N("Darnassus", "达纳苏斯")] = N("Teldrassil", "泰达希尔"), + [N("Alah'Thalas", "萨拉斯高地")] = N("Thalassian Highlands", "阿尔萨拉斯"), +} + +-------------------------------------------------------------------------------- +-- Display strings & colors +-------------------------------------------------------------------------------- +local S_LEVELS = N("Level %d-%d", "等级 %d-%d") +local S_FISHING = N("Fishing Level %d", "钓鱼等级 %d") +local S_INSTANCES = N("Instances:", "地下城:") +local S_RAIDS = N("Raids:", "团队副本:") +local S_FRIENDLY = N("Friendly Territory", "友好领土") +local S_HOSTILE = N("Hostile Territory", "敌对领土") +local S_CONTESTED = N("Contested Territory", "争夺中的领土") + +local COLORS = { + friendly = { 0.20, 0.90, 0.20 }, + hostile = { 0.90, 0.20, 0.20 }, + contested = { 0.80, 0.60, 0.40 }, + levels = { 0.80, 0.60, 0.00 }, + header = { 1.00, 0.84, 0.00 }, + instance = { 0.81, 0.81, 0.81 }, + fishing = { 0.50, 0.70, 0.90 }, +} + +-------------------------------------------------------------------------------- +-- Tooltip frame & display logic +-------------------------------------------------------------------------------- +local zlrTT +local zlrCurrentZone, zlrCurrentArea +local zlrOldUpdate + +local function CreateZLRTooltip() + if zlrTT then return end + zlrTT = CreateFrame("GameTooltip", "NanamiZoneLevelTT", UIParent, "GameTooltipTemplate") + zlrTT:SetFrameStrata("TOOLTIP") + zlrTT:SetFrameLevel(255) +end + +local function StyleTooltip() + if not zlrTT then return end + local _A = SFrames.ActiveTheme + if not _A then return end + zlrTT:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + zlrTT:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], _A.panelBg[4] or 0.95) + zlrTT:SetBackdropBorderColor(_A.panelBorder[1], _A.panelBorder[2], _A.panelBorder[3], _A.panelBorder[4] or 1) +end + +local function UpdateZLRTooltip(zoneName) + if not zlrTT then return end + + if not zoneName or zoneName == "" then + zlrTT:Hide() + return + end + + local data = ZONE_RANGES[zoneName] + if not data then + zlrTT:Hide() + return + end + + zlrTT:SetOwner(UIParent, "ANCHOR_NONE") + zlrTT:ClearAllPoints() + if WorldMapDetailFrame then + zlrTT:SetPoint("BOTTOMLEFT", WorldMapDetailFrame, "BOTTOMLEFT", 2, 2) + end + + local minLv, maxLv, faction = data[1], data[2], data[3] + + zlrTT:SetText(zoneName, 1, 1, 1) + + local lvStr = string.format(S_LEVELS, minLv, maxLv) + zlrTT:AddLine(lvStr, COLORS.levels[1], COLORS.levels[2], COLORS.levels[3]) + + local _, playerFaction = UnitFactionGroup("player") + local territoryText, territoryColor + if faction == F_C then + territoryText = S_CONTESTED + territoryColor = COLORS.contested + elseif playerFaction == faction then + territoryText = S_FRIENDLY + territoryColor = COLORS.friendly + else + territoryText = S_HOSTILE + territoryColor = COLORS.hostile + end + zlrTT:AddLine(territoryText, territoryColor[1], territoryColor[2], territoryColor[3]) + + local instances = ZONE_INSTANCES[zoneName] + if instances then + zlrTT:AddLine(" ") + zlrTT:AddLine(S_INSTANCES, COLORS.header[1], COLORS.header[2], COLORS.header[3]) + for _, inst in ipairs(instances) do + local iName, iLevels = inst[1], inst[2] + zlrTT:AddDoubleLine( + iName, "(" .. iLevels .. ")", + COLORS.instance[1], COLORS.instance[2], COLORS.instance[3], + COLORS.instance[1], COLORS.instance[2], COLORS.instance[3] + ) + end + end + + local raids = ZONE_RAIDS[zoneName] + if raids then + zlrTT:AddLine(" ") + zlrTT:AddLine(S_RAIDS, COLORS.header[1], COLORS.header[2], COLORS.header[3]) + for _, raid in ipairs(raids) do + local rName, rLevels = raid[1], raid[2] + zlrTT:AddDoubleLine( + rName, "(" .. rLevels .. ")", + COLORS.instance[1], COLORS.instance[2], COLORS.instance[3], + COLORS.instance[1], COLORS.instance[2], COLORS.instance[3] + ) + end + end + + local fishLv = ZONE_FISHING[zoneName] + if fishLv then + zlrTT:AddLine(" ") + zlrTT:AddLine(string.format(S_FISHING, fishLv), COLORS.fishing[1], COLORS.fishing[2], COLORS.fishing[3]) + end + + StyleTooltip() + zlrTT:Show() +end + +-------------------------------------------------------------------------------- +-- Hook WorldMapButton_OnUpdate +-------------------------------------------------------------------------------- +local function HookWorldMapUpdate() + zlrOldUpdate = WorldMapButton_OnUpdate + WorldMapButton_OnUpdate = function(a1) + if zlrOldUpdate then zlrOldUpdate(a1) end + + local areaNameRaw = WorldMapFrame and WorldMapFrame.areaName or "" + local _, _, trimmed = string.find(areaNameRaw, "^%s*(.-)%s*$") + if not trimmed then trimmed = areaNameRaw end + local zoneNum = GetCurrentMapZone and GetCurrentMapZone() or -1 + + if ZONE_SUBZONES[trimmed] then + trimmed = ZONE_SUBZONES[trimmed] + end + + if zoneNum == zlrCurrentZone and areaNameRaw == zlrCurrentArea then + return + end + zlrCurrentZone = zoneNum + zlrCurrentArea = areaNameRaw + + if zoneNum == 0 then + UpdateZLRTooltip(trimmed) + else + UpdateZLRTooltip(nil) + end + end +end + +-------------------------------------------------------------------------------- +-- Suppress LevelRange-Turtle's original tooltip +-------------------------------------------------------------------------------- +local function SuppressLevelRangeTooltip() + local _G = getfenv(0) + if _G["LevelRangeTooltip"] then + _G["LevelRangeTooltip"]:Hide() + _G["LevelRangeTooltip"].Show = function(self) self:Hide() end + end +end + +-------------------------------------------------------------------------------- +-- Hook WorldMapFrame hide to dismiss tooltip +-------------------------------------------------------------------------------- +local function HookMapHide() + if not WorldMapFrame then return end + local prevOnHide = WorldMapFrame:GetScript("OnHide") + WorldMapFrame:SetScript("OnHide", function(a1,a2,a3,a4,a5) + if prevOnHide then prevOnHide(a1,a2,a3,a4,a5) end + if zlrTT then zlrTT:Hide() end + zlrCurrentZone = nil + zlrCurrentArea = nil + end) +end + +-------------------------------------------------------------------------------- +-- Initialize +-------------------------------------------------------------------------------- +function ZLR:Initialize() + local wmCfg = SFramesDB and SFramesDB.WorldMap + if wmCfg and wmCfg.enabled == false then return end + + CreateZLRTooltip() + HookWorldMapUpdate() + HookMapHide() + SuppressLevelRangeTooltip() + + self.initialized = true +end diff --git a/docs/LootDisplay-技术要点.md b/docs/LootDisplay-技术要点.md new file mode 100644 index 0000000..3126970 --- /dev/null +++ b/docs/LootDisplay-技术要点.md @@ -0,0 +1,212 @@ +# LootDisplay 拾取窗口接管 — 技术要点 + +## 最终成功方案 + +**核心原则:不替换原生按钮的交互逻辑,只替换视觉层,重新定位原生按钮。** + +在 Turtle WoW (1.12 魔兽私服) 中,`LootSlot()` 是一个**受保护的 C 端函数**, +只接受来自原生 `LootButton1~4`(由 FrameXML 中 `LootButtonTemplate` 创建的按钮) +的内置 `OnClick` 处理器调用。任何 addon 自建按钮(无论是否使用模板)都**无法**成功 +调用 `LootSlot()`。 + +--- + +## 失败方案及原因 + +### 方案 1:LootButtonTemplate 自定义按钮 + +```lua +local btn = CreateFrame("Button", "NanamiLootBtn1", lootFrame, "LootButtonTemplate") +btn:SetScript("OnClick", function() + LootSlot(this.slot) +end) +``` + +**失败原因**:虽然使用了 `LootButtonTemplate`,但按钮是 addon 动态创建的, +不是 FrameXML 在加载期创建的原生 `LootButton1~4`。Turtle WoW 的 C 端可能检查 +调用来源是否为受信任的原生按钮,导致 `LootSlot()` 静默失败。 + +### 方案 2:纯自定义 Button + 直接调用 LootSlot + +```lua +local btn = CreateFrame("Button", nil, lootFrame) +btn:SetScript("OnClick", function() + LootSlot(this.slot) +end) +``` + +**失败原因**:与方案 1 相同。`LootSlot()` 只信任原生按钮的事件上下文。 +截图可验证 `GameTooltip:SetLootItem()` 正常工作(tooltip 能显示),说明 +拾取会话本身是活跃的,纯粹是 `LootSlot()` 拒绝执行。 + +### 方案 3:完全禁用 LootFrame + 自定义按钮 + +```lua +LootFrame:UnregisterAllEvents() +LootFrame:Hide() +``` + +**失败原因**:在禁用 LootFrame 的基础上使用自定义按钮调 `LootSlot()`, +同样因为 C 端保护而失败。另外隐藏 LootFrame 后 C 端可能也认为拾取会话无效。 + +--- + +## 成功方案:原生按钮重定位 + +### 架构概览 + +``` +┌─ NanamiLootFrame (自定义视觉框架) ──────┐ +│ ┌─ 视觉行 row1 (EnableMouse=false) ──┐ │ +│ │ icon + name + quality bar │ │ ← 玩家看到的 +│ │ ┌─ LootButton1 (alpha=0) ───────┐ │ │ +│ │ │ 原生 OnClick → LootSlot() │ │ │ ← 玩家点击的 +│ │ └───────────────────────────────┘ │ │ +│ └────────────────────────────────────┘ │ +│ ┌─ 视觉行 row2 ... ──┐ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────┘ + +┌─ LootFrame (原生, alpha=0, 不可交互) ───┐ +│ (存在但不可见,维持拾取会话) │ +└─────────────────────────────────────────┘ +``` + +### 关键步骤 + +#### 1. 保存原始 `LootFrame_Update` 引用 + +```lua +origLootFrameUpdate = LootFrame_Update +``` + +**不能**将 `LootFrame_Update` 替换为空函数。这个函数负责为 `LootButton1~4` +设置 `SetID()`、slot 数据、以及关键的内置 `OnClick` 处理器。 + +#### 2. 让原生 LootFrame 保持"活跃但不可见" + +```lua +-- 清除 OnHide 防止 CloseLoot() 被意外调用 +LootFrame:SetScript("OnHide", function() end) + +-- Show hook:每次 Show 后强制 alpha=0 +local origShow = LootFrame.Show +LootFrame.Show = function(self) + origShow(self) + self:SetAlpha(0) + self:EnableMouse(false) +end + +-- Hide hook:我们的框架显示期间阻止隐藏 +local origHide = LootFrame.Hide +LootFrame.Hide = function(self) + if lootFrame and lootFrame:IsShown() then return end + origHide(self) +end +``` + +**为什么不能 Hide/UnregisterAllEvents**: +- `LootFrame:Hide()` 的 XML OnHide 会调用 `CloseLoot()`,立即终止拾取会话 +- C 端可能检查 `LootFrame:IsShown()` 来判断拾取是否合法 +- 原生 `LootButton1~4` 是 `LootFrame` 的子框架,父框架隐藏则子框架不可交互 + +#### 3. ShowLootPage 的双阶段流程 + +**阶段 A — 构建视觉层**:设置自定义行的图标、名称、品质颜色。 +视觉行 `EnableMouse(false)`,不拦截任何鼠标事件。 + +**阶段 B — 设置原生按钮并重定位**: + +```lua +-- 同步页码 +LootFrame.page = page +if not LootFrame:IsShown() then LootFrame:Show() end + +-- 让原生代码完整设置按钮状态(ID、OnClick 等) +origLootFrameUpdate() + +-- 将原生按钮移到我们的视觉行上 +for i = 1, 4 do + local nb = _G["LootButton" .. i] + local row = lootRows[i] + if nb and row and row:IsShown() and row._qualColor then + nb:ClearAllPoints() + nb:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + nb:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + nb:SetFrameStrata("FULLSCREEN_DIALOG") + nb:SetFrameLevel(row:GetFrameLevel() + 10) + nb:SetAlpha(0) -- 不可见 + nb:EnableMouse(true) -- 可点击 + nb:Show() + end +end +``` + +#### 4. Hook 全局 `LootFrame_Update` 保持一致性 + +```lua +LootFrame_Update = function() + origLootFrameUpdate() -- 原生设置 + -- 如果我们的框架在显示,重定位按钮 + if lootFrame and lootFrame:IsShown() then + for i = 1, 4 do + -- 同样的重定位逻辑 + end + end +end +``` + +这确保**任何来源**的 `LootFrame_Update` 调用(包括 `LOOT_SLOT_CLEARED` 事件后 +引擎的自动调用)都会以按钮在正确位置结束,解决了"拾取一个物品后无法继续拾取"的问题。 + +--- + +## 事件流程梳理 + +### 打开拾取 + +``` +玩家右键尸体 + → C 引擎创建拾取会话 + → LOOT_OPENED 事件 + → 原生 LootFrame_OnEvent → LootFrame:Show() [hook: alpha=0] + → LootFrame_Update [hook: 原生设置 + 重定位] + → 我们的 LOOT_OPENED handler → UpdateLootFrame → ShowLootPage + → 设置视觉行 + → origLootFrameUpdate() → 重定位按钮 +``` + +### 拾取物品 + +``` +玩家点击 LootButton1 (alpha=0, 覆盖在视觉行上) + → 原生 LootButton_OnClick → LootSlot(this:GetID()) ← 受信任的调用 + → 物品拾取成功 + → LOOT_SLOT_CLEARED 事件 + → 原生 handler → LootFrame_Update [hook: 原生重设按钮 + 重定位] + → 我们的 handler → UpdateLootFrame → ShowLootPage → 刷新视觉 + 重定位 +``` + +### 关闭拾取 + +``` +玩家走开 / 按 ESC / 点关闭按钮 + → CloseLoot() 或 LOOT_CLOSED 事件 + → CloseLootFrame() → 隐藏自定义框架 (设 _closingLoot 标志) + → 隐藏原生按钮 + → 允许 LootFrame:Hide() +``` + +--- + +## 关键技术教训 + +| 教训 | 说明 | +|------|------| +| `LootSlot()` 是受保护的 | Turtle WoW 中只有原生按钮的内置 OnClick 能成功调用 | +| 不能隐藏 LootFrame | OnHide (XML) 会调用 `CloseLoot()` 终止会话 | +| 不能禁用 LootFrame_Update | 这个函数负责设置按钮的 ID 和交互能力 | +| 视觉与交互分离 | 自定义行负责视觉 (EnableMouse=false),原生按钮负责交互 (alpha=0) | +| Hook 先调原始再改位置 | `LootFrame_Update` hook 先跑原生逻辑,再重定位按钮到自定义行上 | +| OnHide 需要防重入 | `_closingLoot` 标志防止 OnHide → CloseLoot → LOOT_CLOSED → CloseLootFrame 循环 | +| 页码必须同步 | `LootFrame.page` 必须与自定义分页同步,否则原生按钮 ID 计算错误 |