diff --git a/.gitignore b/.gitignore index 07377a9..0f5ed70 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ LOCALIZATION*.md # Dev tooling — Python generators, data processing scripts, scratch files # Not part of the addon, never ship this .tools/ + +# GitHub Copilot — never sync Copilot data to GitHub +.copilot/ +.github/copilot-instructions.md +**/copilot-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a70cdb..3b5123e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,88 @@ All notable changes to MetaHunt will be documented in this file. +## [2.0.0] - 2026-09-14 + +Resurrecting on Octowow. + +### Added + +- **SmartAmmo — low-ammo alerts**: SmartAmmo can now monitor the total matching arrows or bullets in your bags and equipped slot: Enable the option, choose a warning threshold (default: 200), and MetaHunt will ouput a chat and centered-screen alert when you cross it. Sarcasms are always enabled ofc. + +- **New spell datastore**: Metahunt now ships a datastore of all twow 1.18.1 hunter and pet spells. You can browse them with new pages in the Book. + +- **Trainer cost planning**: Added shared Hunter and Pet Trainer spell cost planning for spells available now, at the next training level, and still remaining. + +- **Hunter and Pet spell scanning**: MetaHunt now reads your Hunter and Pet spellbooks to mark every spell rank you already know. When you visit a Hunter or Pet Trainer, it also records which ranks you can learn now and which ones are still unavailable. + +- **Level-up tracking**: Added Hunter, Pet, and Pet Loyalty level-up notifications. When you hit level, you can have a summary in the chat of all new spells available and their costs. + +- **Pet Auto-Teach — Growl**: When you tame a new beast, MetaHunt now offers to teach it Growl at the best rank for the pet's level. Click **Yes** and it opens the Beast Training window, teaches the ability and closes the window automatically. + +- **Animations**: MetaHunt now plays optional animations that add fun and make the interface feel alive. Powered by the great PizzaSauce library of [Pizzahawaii](https://codeberg.org/Pizzahawaii/) ! Thank you man. + +- **zTrack — Find Fish**: Added new spell Find Fish to zTrack. + +- **DDOSers Realm Blocking**: The Addon will auto-disable itself on all Mafia's realms, we know who they are. + +### Changed + +- **Complete revamp of SavedVariables**: All your settings now live in one tidy place instead of being scattered around. A migration happens the first time you log in, and you shouldn't lose any settings. + +- **Polished options**: The option panel had become messy over time and it finally received some attention to look cleaner and coherent with same styling everywhere and better arrangement. + +- **Book navigation**: The book navigation is no longer done with horizontal tabs, but with a left vertical menu just like in the options. This allow more pages and fix the tab overfloading issues on some resolutions and UI scales. + +- **Web database links**: All links in the data that were pointing on old Turtle WoW DB have been replaced to point on the OctoWoW DB. + +- **Addon's code repository change**: The addon repo is now hosted on Codeberg and the "update available" link will point on the new location. + + +### Fixed + +- **NPC Finder — zone accuracy**: Fixed NPCs that could show a wrong zone (e.g. Feathermoon vendors in Feralas were labelled "Duskwood") caused by a zone-ID mix-up. NPCs that stand on a zone border or in a capital city now list **all** the zones they appear on, most precise first — e.g. "Darnassus, Teldrassil" or "Alterac, Hilsbrad". + +- **zCraft — profession ranks**: zCraft now refreshes after learning a profession rank, without requiring a UI reload. + +- **Tooltips — your pet's info showing on other players**: In a party, mousing over or targeting another player could wrongly show YOUR pet's details (mood, loyalty, happiness, XP) on their tooltip. The own-pet detection no longer misfires on other units. + +- **zCraft — Smelting**: Smelting was previously not appearing in zCraft because incorrectly hardcoded as "Mining", this is fixed. + +- **Wrong icons on pet spells**: Fixed the Rank 0 of Bubble Barrier and Charge that had incorrect icons. + +- **Fixed tracking data in Book's Stables**: Fixed an issue with pets' GUID returned by the server, that was not really unique and caused issues with pets' tracking, notably when you had several pets with the same name. Taming info was disapearing, amongst other weirdness. A migration is done on first load to fix your existing data. + +- **FeedOMatic — "Set Key" button error**: Clicking **Set Key** in Options → Feed-o-Matic threw an error and wouldn't let you bind a feed key. It now works again. + +- **FeedOMatic — pet left hungry when bags were full**: When your bags were full, FeedOMatic could grab a random tiny stack (like a single meat your pet won't eat) to free a bag slot, so your pet stayed hungry. It now always picks food your pet can actually eat. + +- **Tooltips — "Not learned yet" hint missing on pet abilities**: When you tamed a new beast, hovering one of its abilities on your pet bar no longer showed the red "Not learned yet, keep going !" reminder, even for abilities you hadn't learned. The hint is back and now correctly shows for any ability rank your hunter hasn't learned yet. + +- **Pet training — new rank of a known ability not registered**: When you trained a higher rank of an ability you already had (e.g. learning Charge Rank 2 after Rank 1), MetaHunt treated it as already known — so the "Pet ability learned and recorded" message never appeared and the pet-bar tooltip kept saying "Not learned yet" for the new rank. Learning a new rank is now recognised on its own, announces correctly, and the tooltip immediately reflects the rank you just learned. + +- **Hunter's Book — fixed "Unique" beasts**: Restored a curated list of the one-of-a-kind beasts (Humar the Pridelord, Darksaber, Shar'lan, Frostsaber Pride Watcher, Mazzranache, Moonfeather, Azurebeak, Old Cliff Jumper, Rotting Agam'ar, Razzashi Serpent, Magmalash Scorpid, Spirit Fox and more). + + + +## [1.5.1] - 2026-04-01 + + +### Added + +- **zAmmo — bag ammo labels**: Quality-colored short-name labels overlaid on ammo items in bags and bank, with optional heat-colored DPS damage indicator. Three new checkboxes General options: bag labels, bank labels, and damage display. Works with both default bags and pfUI bags. + +- **MM Widget — immunity detection**: The MM Widget keybind now detects when the current target is immune to a damage school (Fire / Nature / Arcane) and automatically skips the useless shot, casting Aimed or Steady Shot instead. Immunity is detected in two ways: reactively via combat log "immune" messages, and proactively via hardcoded creature-type rules (e.g. Elementals are always flagged Nature-immune on target). Immune cells show a red border. + +- **MM Widget — rotation toggles**: Three new checkboxes in Options → ExpAmmo → Rotation let you permanently disable individual shots (Multi-Shot, Serpent Sting, Arcane Shot) — unchecked shots are skipped during their proc window. + + +### Fixed + +- **Zone mapping — 22 beasts with raw zone IDs**: Fixed 8 unmapped zone IDs (Blackrock Depths, Molten Core, Wailing Caverns, Maraudon, Sunnyglade, Icepoint Rock, Winter Veil Vale, Gilneas City, Karazhan) that caused 22 beasts to display as "Zone xxx" instead of their proper zone name in the Hunter Book. + +- **MM Widget — IsStateBlocked nil error**: Moved `MTH_EA_IsStateBlocked` definition before its first use, fixing a nil function call on load. + + ## [1.5.0] - 2026-03-31 diff --git a/MetaHunt.lua b/MetaHunt.lua index 3d5045e..54afb42 100644 --- a/MetaHunt.lua +++ b/MetaHunt.lua @@ -4,7 +4,7 @@ -- Modern modular architecture with legacy addon support -- -- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --- TARGET RUNTIME: LUA 5.0 (WoW 1.12 / Turtle-WoW) +-- TARGET RUNTIME: LUA 5.0 (WoW 1.12 / Twow 1.18) -- - Max 32 upvalues per function -- - No # operator (use table.getn) -- - No string.gmatch (use string.gfind) diff --git a/MetaHunt.toc b/MetaHunt.toc index 64dc1a1..09aa0dc 100644 --- a/MetaHunt.toc +++ b/MetaHunt.toc @@ -1,22 +1,18 @@ ## Interface: 11200 -## Version: 1.5.0 +## Version: 2.0.0 ## Title: MetaHunt - |cff00ff00Hunter ## Author: Metasploit and his Copilot ;) -## Notes: Unified addon suite for huntards making old addons compatible with TurtleWoW, and adding an arsenal of never seen Hunter's utilities. -## SavedVariables: MTH_SavedVariables, FOM_Config, FOM_FoodQuality, FOM_AddedFoods, FOM_RemovedFoods, FOM_Cooking, FOM_QuestFood, FOM_LocaleInfo -## SavedVariablesPerCharacter: ZHunterMod_Saved, MTH_CharSavedVariables +## Notes: Unified addon suite for huntards making old addons compatible with Twow, and adding an arsenal of never seen Hunter's utilities. +## SavedVariables: MTH_SavedVariables +## SavedVariablesPerCharacter: MTH_CharSavedVariables data\init.lua Bindings.xml ## Load initialization and core framework -init\env.xml -init\compat.xml -init\libs.xml init\api.xml api\core-feed-tracking.lua -init\skins.xml ## Load database files (DB files must come before data\init.lua) data\ds-beasts.lua @@ -40,6 +36,7 @@ data\ds-pet-spells.lua data\ds-pet-spells-trainer.lua data\ds-hunter-spells.lua data\ds-hunter-spells-dbc.lua +data\ds-trainer-catalog.lua data\ds-racial.lua data\init.lua diff --git a/README.MD b/README.MD index fb7fb29..2900bda 100644 --- a/README.MD +++ b/README.MD @@ -1,26 +1,34 @@ # MetaHunt -MetaHunt is a Turtle WoW Hunter toolkit. -It is a modern modular addon compiling brand new features, and improving a few old hunter add-ons that were broken on Twow. -It provides a bunch of useful tools for all huntards, wheter they are still levelling, lone-wolf, or HL raider. +MetaHunt is a Twow Hunter toolkit. + +It is a modern modular addon compiling brand new features, and improving a few old hunter add-ons that were broken or disfunctional with twow. + +It provides a bunch of useful functionalities for all huntards, wheter they are still levelling, lone-wolf, or HL raider. +You play the best class, now you have the best companion for it (and its not your pet). ### Important notes -- Altough Nampower isn't mandatory for most of the features, it is a must-have for some. -- If you were using old versions of Feed-O-Matic, ICU, zHunterMod and HunterHelper, you dont need those with MetaHunt and you should disable them to avoid conflicts. -- MetaHunt is fully compatible with Quiver and is not a replacement for it. +- Altough Nampower isn't mandatory for most of the features, it is a must-have for some, and certainly the MM Widget. +- If you were using old versions of Feed-O-Matic, ICU, zHunterMod and HunterHelper, you dont need those with MetaHunt and you SHOULD disable them to avoid conflicts. +- MetaHunt is fully compatible with QUIVER <3, and is not a replacement for it. +- The addon purposely disable itself on all Capycraft/Ravencraft Realms, following the DDOS attacks they conducted on other servers. ## Twow Data -MetaHunt ships with large Turtle WOW datastores : +MetaHunt ships with large twow datastores, and notably: - 100% accurate beasts' data, their locations, and the abilities they can learn you - All Pet families, their abilities and their diet - All pet abilities ranks -- All Ammo (Arrows and Bullets) -- All Stable, Hunter and Pet Masters +- All hunter and pet spells +- All Ammo and ranged weapons +- All Hunter/Pet/Stable Masters - All Ammo vendors +Online bestiary is also available here https://duvelcorp.codeberg.page/MetaHunt-Web/ + + ## Smart Ammo - Auto swap your main ammo to junk ammo for shots that doesn't scale on damage, and re-equip you main ammo right away. Don't waste your Doomshots on nothing anymore, save money ! @@ -28,7 +36,8 @@ MetaHunt ships with large Turtle WOW datastores : - Auto equip the right ammo when you swap from a box/xbow to a gun and the other way around. ## MM Widget - Lets Make MM great again. Tracks the new 3-state of Experimental ammo cycle (Fire → Nature → Arcane), the Lock&Load procs, and has a dynamic cell that shows either Aimed Shot availability or the right shot to use following the current Experimental ammo proc if any. + + 1.18.1 weapon of mass-destruction. NAMPOWER MANDATORY. The widget is composed of 5 cells, tracking the 3-state of Experimental ammo cycle (Fire → Nature → Arcane), the Lock & Load procs, and a smart dynamic cell. The smart cell shows either Aimed Shot availability or the right shot to use following the current Experimental ammo proc if any, and it bypasses the proc spell if your current target is immune to the school. You can bind the smart cell to turn the widget into a very efficient one-button rotation. Lets make MM great again ! ## Tooltips @@ -42,45 +51,64 @@ MetaHunt ships with large Turtle WOW datastores : Old zHunterMod addon on steroids. Fully compatible with Twow, and enhanced with new functionalities - zAmmo : Track all your ammo Live and swap them in a click or bind -- zPet : Intelligent collapsible pet Bar regrouping all pet spells and providing an intelligent button that always show you the right spell to use depending on the situation +- zPet : Intelligent collapsible pet Bar regrouping all pet spells and providing a main button that always swap on the right spell to use depending on the situation - zTrack: All your trackings in one collapsible bar -- zAspect: All your trackings in one collapsible bar +- zAspect: All your aspects in one collapsible bar - zRanged : All the ranged weapons currently in your bags to swap them fast -- But also zBar, zCrafts, zMounts, zToys and zCompanions. +- But also zCrafts, zMounts, zToys and zCompanions. + +Additionally, a special _zBar_ can regroup all the zButtons above into a single bar for people that like clean and ordered stuff. ## Feed-O-Matic -Now compatible with Turtle, feed your pet with one key bind, it will always pickup the best food for your pet. +Now compatible with Twow, feed your pet with one key bind, it will always pickup the best food for your pet, according to your preferences. ## Chronometer -The good old Chronometer addon, purged of non-necessary things and reconfigured for a pure hunter use. +The good old Chronometer addon, purged of non-necessary things and reconfigured for a pure hunter usage. ## ICU (I see you) -Old addon used to display Targets' enhanced information when you click on your minimap tracking things. MetaHunt offers many more customization options for it. +This old addon is mostly a PVP weapon used to display targets' enhanced information when you click on your minimap tracked things. MetaHunt offers many more customization options for it. + ## Ammo labels + + You can display some labels on all ammo in your bags/bank to instantly see which of those damn same arrows icons really are. ## Auto-Buy -Define your own simple rules for auto buy ammo and pet food when you open a vendor. +Define your own simple rules for auto buy ammo and pet food when you open a vendor. You can also make a food-auto-buy planning based on all your stabled pet's diets. ## Auto-Quest -Just spam SHIFT-click on the NPC to validate quests Scorpok and Thorium arrows. Additionally you can have an enhanced tooltip that display your actual Scorpok-related items when you mouseover the mobs involved. +Just spam SHIFT-click on the NPC to validate quests Scorpok and Thorium arrows. Additionally you can have an enhanced tooltip that display your actual Scorpok-related items when you mouseover the Blasted Land mobs involved. - ## Beast model Viewer + ## Efficient Scanners -Display the model of all beasts in the world. Always know what you are going to tame exactly before running there ! You can also browse all skin models of the same pet family to find the best-looking pet according to your tastes. +The add-on smartly scan your character and pet tabs, spellbook, beast training, stable and learning masters windows. This is done automatically and fast in the background, and allows to enrich the static datastore with YOUR own data, and to provide meaningfull dynamic information following the context. It also scan your Pet's state constantly, but with minimal ressources. + + ## Animations + +Metahunt is the first add-on that uses the great PizzaSauce library of [Pizzahawaii](https://codeberg.org/Pizzahawaii/). This adds fun and makes the interface feel alive. ## The Great Book of Huntards - The Book is the main GUI of MetaHunt, it allows to display the data constantly collected about you and your pet, and browse the addon's data to find anything a Hunter needs. + The Book is the main GUI of MetaHunt, it allows to browse the addon's data to find anything a Hunter needs. -- Browse the MetaHunt datastores of NPCs (Beast, vendors, masters) and open the map to locate them like in pfQuest -- Model viewer of all beasts in the game. -- Track all info about the pet abilities that you know and don't know yet -- Track all info about your stabled pets -- Keep an history of all your pets after you abandon them or if they run away. +- Search Hunter-related NPCs (Beasts, vendors, masters) and open the map to locate them like in pfQuest. +- Beast model Viewer : Display the model of all beasts in the world. You can also browse all skin models of the same family to find the best-looking pet according to your tastes. +- Instantly find where the beasts having a specific ability are lying and plan ahead your tracking sessions. +- Get a full understanding of all Pet families. +- Display meaningfully the pet abilities that you know and don't know yet +- Get your stable's state everywhere and track all possible information about all your pets +- Keep a detailed history of all the pets you abandonned or those that ran away. +- Browse all twow 1.18.1 hunter and pet spells. Get the status of what you already learned or not, and a detailed cost planning. + + ## Tune it the way you like + +Metahunt option panel is huge. There are hundreds of settings you can configure in order to tweak it like a boss so it fits YOUR preferences. +Its modular architecture also allows you to completely disable any module in order to decrease the addon's memory footprint and background processes. + ### The world is yours. Happy Hunting Fellas ! + diff --git a/api/AntiDaze.lua b/api/AntiDaze.lua index d20ffba..40079be 100644 --- a/api/AntiDaze.lua +++ b/api/AntiDaze.lua @@ -10,10 +10,23 @@ local function AntiDaze_GetSaved() if type(MTH_CharSavedVariables) ~= "table" then MTH_CharSavedVariables = {} end - if type(MTH_CharSavedVariables.antiDaze) ~= "table" then - MTH_CharSavedVariables.antiDaze = {} + local store + if MTH and MTH.GetModuleCharSavedVariables then + store = MTH:GetModuleCharSavedVariables("antidaze") end - return MTH_CharSavedVariables.antiDaze + if type(store) ~= "table" then + if type(MTH_CharSavedVariables.modules) ~= "table" then + MTH_CharSavedVariables.modules = {} + end + if type(MTH_CharSavedVariables.modules.antidaze) ~= "table" then + MTH_CharSavedVariables.modules.antidaze = {} + end + store = MTH_CharSavedVariables.modules.antidaze + end + if type(store.settings) ~= "table" then + store.settings = {} + end + return store.settings end local function AntiDaze_IsEnabled() diff --git a/api/AutoStrip.lua b/api/AutoStrip.lua index 762e448..b153b28 100644 --- a/api/AutoStrip.lua +++ b/api/AutoStrip.lua @@ -33,10 +33,20 @@ function AutoStrip_GetSaved() if type(MTH_CharSavedVariables) ~= "table" then MTH_CharSavedVariables = {} end - if type(MTH_CharSavedVariables.autoStrip) ~= "table" then - MTH_CharSavedVariables.autoStrip = {} + local store + if MTH and MTH.GetModuleCharSavedVariables then + store = MTH:GetModuleCharSavedVariables("autostrip") end - return MTH_CharSavedVariables.autoStrip + if type(store) ~= "table" then + if type(MTH_CharSavedVariables.modules) ~= "table" then + MTH_CharSavedVariables.modules = {} + end + if type(MTH_CharSavedVariables.modules.autostrip) ~= "table" then + MTH_CharSavedVariables.modules.autostrip = {} + end + store = MTH_CharSavedVariables.modules.autostrip + end + return store end local AUTO_STRIP_ORDER = {16, 17, 18, 5, 7, 1, 3, 10, 8, 6, 9} diff --git a/api/PizzaSauce.lua b/api/PizzaSauce.lua new file mode 100644 index 0000000..c07de99 --- /dev/null +++ b/api/PizzaSauce.lua @@ -0,0 +1,1347 @@ +-- PizzaSauce: Smooth, declarative animation library for WoW 1.12 + +local _G = getfenv(0) + +local lib = {} +lib.version = "0.1.1" +_G["PizzaSauce"] = lib + +lib._types = {} +lib._easing = {} +lib._active = {} +lib._pending = {} +lib._pendingN = 0 +lib._count = 0 +lib._finBuf = {} + +lib._frame = CreateFrame("Frame", nil, UIParent) +lib._frame:Hide() + +local lastTime = GetTime() + +local function activate(anim) + if anim._active then return end + anim._active = true + lib._count = lib._count + 1 + lib._active[anim] = true + if lib._count == 1 then + lastTime = GetTime() + lib._frame:Show() + end +end + +local function deactivate(anim) + if not anim._active then return end + anim._active = false + lib._count = lib._count - 1 + lib._active[anim] = nil + if lib._count <= 0 then + lib._count = 0 + if lib._pendingN == 0 then lib._frame:Hide() end + end +end + +lib._frame:SetScript("OnUpdate", function() + local now = GetTime() + local dt = now - lastTime + lastTime = now + if dt <= 0 then return end + + -- Process pending auto-play animations + local pendingN = lib._pendingN + if pendingN > 0 then + lib._pendingN = 0 + for i = 1, pendingN do + local anim = lib._pending[i] + lib._pending[i] = nil + if anim._autoplay then anim:Play() end + end + end + + -- Hide driver if nothing to do + if lib._count == 0 then + lib._frame:Hide() + return + end + + -- Tick all active, collect finished into scratch buffer. + -- Can't finish inline because _finish() callbacks may create new animations, + -- which would modify _active while we're iterating it. + local finN = 0 + for anim in pairs(lib._active) do + if anim:_tick(dt, now) then + finN = finN + 1 + lib._finBuf[finN] = anim + end + end + + for i = 1, finN do + lib._finBuf[i]:_finish() + lib._finBuf[i] = nil + end +end) + +lib._activate = activate +lib._deactivate = deactivate + +function lib._schedulePending(anim) + anim._autoplay = true + lib._pendingN = lib._pendingN + 1 + lib._pending[lib._pendingN] = anim + lastTime = GetTime() + lib._frame:Show() +end + +function lib:RegisterType(name, handlers) + lib._types[name] = handlers +end + +function lib:RegisterEasing(name, fn) + lib._easing[name] = fn +end + +function lib:RegisterHelper(name, fn) + if lib[name] then + DEFAULT_CHAT_FRAME:AddMessage( + "PizzaSauce: RegisterHelper('" .. name .. "') would overwrite an existing method") + return + end + lib[name] = fn +end +local E = lib._easing + +local pi = math.pi +local sin = math.sin +local cos = math.cos +local pow = math.pow +local sqrt = math.sqrt + +E["linear"] = function(t) return t end +E["swing"] = function(t) return 0.5 - cos(t * pi) / 2 end + +-- Quad (power of 2) +E["inQuad"] = function(t) return t * t end +E["outQuad"] = function(t) return t * (2 - t) end +E["inOutQuad"] = function(t) + if t < 0.5 then return 2 * t * t end + return -1 + (4 - 2 * t) * t +end + +-- Cubic (power of 3) +E["inCubic"] = function(t) return t * t * t end +E["outCubic"] = function(t) local u = t - 1; return u * u * u + 1 end +E["inOutCubic"] = function(t) + if t < 0.5 then return 4 * t * t * t end + local u = 2 * t - 2; return (u * u * u + 2) / 2 +end + +-- Quart (power of 4) +E["inQuart"] = function(t) return t * t * t * t end +E["outQuart"] = function(t) local u = t - 1; return 1 - u * u * u * u end +E["inOutQuart"] = function(t) + if t < 0.5 then return 8 * t * t * t * t end + local u = t - 1; return 1 - 8 * u * u * u * u +end + +-- Quint (power of 5) +E["inQuint"] = function(t) return t * t * t * t * t end +E["outQuint"] = function(t) local u = t - 1; return 1 + u * u * u * u * u end +E["inOutQuint"] = function(t) + if t < 0.5 then return 16 * t * t * t * t * t end + local u = t - 1; return 1 + 16 * u * u * u * u * u +end + +-- Sine +E["inSine"] = function(t) return 1 - cos(t * pi / 2) end +E["outSine"] = function(t) return sin(t * pi / 2) end +E["inOutSine"] = function(t) return 0.5 - cos(t * pi) / 2 end + +-- Expo +E["inExpo"] = function(t) + if t == 0 then return 0 end + return pow(2, 10 * (t - 1)) +end +E["outExpo"] = function(t) + if t == 1 then return 1 end + return 1 - pow(2, -10 * t) +end +E["inOutExpo"] = function(t) + if t == 0 or t == 1 then return t end + if t < 0.5 then return pow(2, 20 * t - 10) / 2 end + return (2 - pow(2, -20 * t + 10)) / 2 +end + +-- Circ +E["inCirc"] = function(t) return 1 - sqrt(1 - t * t) end +E["outCirc"] = function(t) local u = t - 1; return sqrt(1 - u * u) end +E["inOutCirc"] = function(t) + if t < 0.5 then return (1 - sqrt(1 - 4 * t * t)) / 2 end + local u = 2 * t - 2; return (sqrt(1 - u * u) + 1) / 2 +end + +-- Elastic +E["inElastic"] = function(t) + if t == 0 or t == 1 then return t end + return -pow(2, 10 * t - 10) * sin((t * 10 - 10.75) * (2 * pi) / 3) +end +E["outElastic"] = function(t) + if t == 0 or t == 1 then return t end + return pow(2, -10 * t) * sin((t * 10 - 0.75) * (2 * pi) / 3) + 1 +end +E["inOutElastic"] = function(t) + if t == 0 or t == 1 then return t end + if t < 0.5 then + return -pow(2, 20 * t - 10) * sin((20 * t - 11.125) * (2 * pi) / 4.5) / 2 + end + return pow(2, -20 * t + 10) * sin((20 * t - 11.125) * (2 * pi) / 4.5) / 2 + 1 +end + +-- Back +E["inBack"] = function(t) + local s = 1.70158 + return t * t * ((s + 1) * t - s) +end +E["outBack"] = function(t) + local s = 1.70158 + local u = t - 1 + return u * u * ((s + 1) * u + s) + 1 +end +E["inOutBack"] = function(t) + local s = 1.70158 * 1.525 + if t < 0.5 then + return (2 * t) * (2 * t) * ((s + 1) * (2 * t) - s) / 2 + end + local u = 2 * t - 2 + return (u * u * ((s + 1) * u + s) + 2) / 2 +end + +-- Bounce +E["outBounce"] = function(t) + if t < 1 / 2.75 then + return 7.5625 * t * t + elseif t < 2 / 2.75 then + t = t - 1.5 / 2.75 + return 7.5625 * t * t + 0.75 + elseif t < 2.5 / 2.75 then + t = t - 2.25 / 2.75 + return 7.5625 * t * t + 0.9375 + else + t = t - 2.625 / 2.75 + return 7.5625 * t * t + 0.984375 + end +end +E["inBounce"] = function(t) return 1 - E["outBounce"](1 - t) end +E["inOutBounce"] = function(t) + if t < 0.5 then return (1 - E["outBounce"](1 - 2 * t)) / 2 end + return (1 + E["outBounce"](2 * t - 1)) / 2 +end +local function lerp(a, b, t) return a + (b - a) * t end + +local function rotCorner(a) + local r = math.rad(a) + return 0.5 + math.cos(r) / 1.4142, 0.5 + math.sin(r) / 1.4142 +end + +local function applyRotation(texture, angle) + local a = -angle + local ULx, ULy = rotCorner(a + 225) + local LLx, LLy = rotCorner(a + 135) + local URx, URy = rotCorner(a - 45) + local LRx, LRy = rotCorner(a + 45) + texture:SetTexCoord(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy) +end + +lib:RegisterType("alpha", { + init = function(target, from) + return from or target:GetAlpha() + end, + apply = function(target, from, to, t) + target:SetAlpha(lerp(from, to, t)) + end, +}) + +lib:RegisterType("scale", { + init = function(target, from, to, tween) + tween._baseW = target:GetWidth() + tween._baseH = target:GetHeight() + return from or 1.0 + end, + apply = function(target, from, to, t, tween) + local s = lerp(from, to, t) + target:SetWidth(tween._baseW * s) + target:SetHeight(tween._baseH * s) + end, +}) + +lib:RegisterType("size", { + init = function(target, from) + return from or { target:GetWidth(), target:GetHeight() } + end, + apply = function(target, from, to, t) + target:SetWidth(lerp(from[1], to[1], t)) + target:SetHeight(lerp(from[2], to[2], t)) + end, +}) + +lib:RegisterType("position", { + init = function(target, from, to, tween) + local point, rel, relPoint, x, y = target:GetPoint(1) + tween._point = point or "CENTER" + tween._rel = rel + tween._relPoint = relPoint or "CENTER" + return from or { x or 0, y or 0 } + end, + apply = function(target, from, to, t, tween) + target:ClearAllPoints() + target:SetPoint(tween._point, tween._rel, tween._relPoint, + lerp(from[1], to[1], t), lerp(from[2], to[2], t)) + end, +}) + +lib:RegisterType("color", { + init = function(target, from) + return from or { 1, 1, 1, 1 } + end, + apply = function(target, from, to, t) + target:SetVertexColor( + lerp(from[1], to[1], t), + lerp(from[2], to[2], t), + lerp(from[3], to[3], t), + lerp(from[4] or 1, to[4] or 1, t)) + end, +}) + +lib:RegisterType("rotation", { + init = function(target, from) + return from or 0 + end, + apply = function(target, from, to, t) + applyRotation(target, lerp(from, to, t)) + end, +}) + +lib:RegisterType("custom", { + init = function(target, from, to, tween) + if from ~= nil then return from end + if tween._getter then return tween._getter(target) end + return 0 + end, + apply = function(target, from, to, t, tween) + if tween._setter then + tween._setter(target, lerp(from, to, t)) + end + end, +}) +local function resolveEasing(easing) + if type(easing) == "function" then return easing end + return lib._easing[easing] or lib._easing["inOutQuad"] +end + +local Tween = {} + +function Tween:Play() + self._elapsed = 0 + self._started = false + self._done = false + lib._activate(self) +end + +function Tween:Cancel() + if not self._active then return end + lib._deactivate(self) + self._done = true + if self._onCancel then self:_onCancel() end +end + +function Tween:IsPlaying() + return self._active == true +end + +function Tween:_start() + local handler = lib._types[self._type] + if handler and handler.init then + self._from = handler.init(self._target, self._from, self._to, self) + end + self._started = true + if self._onStart then self:_onStart() end +end + +function Tween:_tick(dt) + self._elapsed = self._elapsed + dt + + if self._elapsed < self._delay then return false end + + if not self._started then self:_start() end + + local t = self._duration > 0 + and math.min((self._elapsed - self._delay) / self._duration, 1) + or 1 + + local handler = lib._types[self._type] + if handler and handler.apply and self._target then + handler.apply(self._target, self._from, self._to, resolveEasing(self._easing)(t), self) + end + + if self._onUpdate then self:_onUpdate(t) end + + return t >= 1 +end + +function Tween:_finish() + lib._deactivate(self) + self._done = true + + -- Apply one final time at exactly t=1 -- the last _tick may have landed + -- slightly before 1.0 depending on frame timing. + local handler = lib._types[self._type] + if handler and handler.apply and self._target then + handler.apply(self._target, self._from, self._to, 1, self) + end + if handler and handler.cleanup then + handler.cleanup(self._target, self) + end + + if self._onFinish then self:_onFinish() end +end + +function Tween:SetFrom(val) self._from = val end +function Tween:SetTo(val) self._to = val end +function Tween:SetDuration(val) self._duration = val end +function Tween:SetDelay(val) self._delay = val end +function Tween:SetTarget(val) self._target = val end + +function Tween:_reset() + self._elapsed = 0 + self._started = false + self._done = false +end + +local TweenMT = { __index = Tween } + +local KNOWN_OPTS = { + type = true, from = true, to = true, duration = true, easing = true, + delay = true, onStart = true, onUpdate = true, onFinish = true, + onCancel = true, getter = true, setter = true, defer = true, +} + +function lib:Tween(target, opts) + local tween = setmetatable({ + _target = target, + _type = opts.type or "alpha", + _from = opts.from, + _to = opts.to, + _duration = opts.duration or 0.3, + _easing = opts.easing or "inOutQuad", + _delay = opts.delay or 0, + _onStart = opts.onStart, + _onUpdate = opts.onUpdate, + _onFinish = opts.onFinish, + _onCancel = opts.onCancel, + _getter = opts.getter, + _setter = opts.setter, + }, TweenMT) + + -- Forward extra opts to the tween for custom type handlers. + -- Internal fields use underscore prefix, so user params (vx, radius, etc.) + -- are accessible as tween.vx, tween.radius without collision. + for k, v in pairs(opts) do + if not KNOWN_OPTS[k] then + tween[k] = v + end + end + + if not opts.defer then + lib._schedulePending(tween) + end + + return tween +end +local Sequence = {} + +function Sequence:Play() + self._index = 1 + self._loopCount = 0 + self._elapsed = 0 + self._done = false + self:_resetChildren() + lib._activate(self) +end + +function Sequence:Cancel() + if not self._active then return end + local child = self._children[self._index] + if child and child._started and child.Cancel then child:Cancel() end + lib._deactivate(self) + self._done = true + if self._onCancel then self:_onCancel() end +end + +function Sequence:IsPlaying() + return self._active == true +end + +function Sequence:_tick(dt) + self._elapsed = self._elapsed + dt + if self._elapsed < self._delay then return false end + + local child = self._children[self._index] + if not child then return true end + + if not child._started then child:_start() end + + if child:_tick(dt) then + child:_finish() + self._index = self._index + 1 + + if self._index > table.getn(self._children) then + return self:_handleLoop() + end + end + + return false +end + +function Sequence:_finish() + lib._deactivate(self) + self._done = true + if self._onFinish then self:_onFinish() end +end + +function Sequence:_start() + self._started = true +end + +function Sequence:_reset() + self._index = 1 + self._loopCount = 0 + self._elapsed = 0 + self._done = false + self._started = false + self:_resetChildren() +end + +function Sequence:_resetChildren() + for i = 1, table.getn(self._children) do + self._children[i]:_reset() + end +end + +function Sequence:_handleLoop() + self._loopCount = self._loopCount + 1 + if self._loop ~= 0 and self._loopCount >= self._loop then + return true + end + + if self._yoyo then + local c = self._children + local n = table.getn(c) + for i = 1, math.floor(n / 2) do + c[i], c[n - i + 1] = c[n - i + 1], c[i] + end + end + + self._index = 1 + self:_resetChildren() + return false +end + +local SequenceMT = { __index = Sequence } + +function lib:Sequence(children, opts) + opts = opts or {} + + for i = 1, table.getn(children) do + children[i]._autoplay = false + end + + local seq = setmetatable({ + _children = children, + _index = 1, + _loop = opts.loop or 1, + _yoyo = opts.yoyo or false, + _loopCount = 0, + _delay = opts.delay or 0, + _elapsed = 0, + _onFinish = opts.onFinish, + _onCancel = opts.onCancel, + _done = false, + }, SequenceMT) + + if not opts.defer then + lib._schedulePending(seq) + end + + return seq +end +local Group = {} + +function Group:Play() + self._loopCount = 0 + self._elapsed = 0 + self._done = false + self:_resetChildren() + lib._activate(self) +end + +function Group:Cancel() + if not self._active then return end + for i = 1, table.getn(self._children) do + local child = self._children[i] + if child._active or child._started then + if child.Cancel then child:Cancel() end + end + end + lib._deactivate(self) + self._done = true + if self._onCancel then self:_onCancel() end +end + +function Group:IsPlaying() + return self._active == true +end + +function Group:_tick(dt) + self._elapsed = self._elapsed + dt + if self._elapsed < self._delay then return false end + + local allDone = true + + for i = 1, table.getn(self._children) do + local child = self._children[i] + if not child._done then + if not child._started then child:_start() end + if child:_tick(dt) then + child:_finish() + else + allDone = false + end + end + end + + if allDone then + return self:_handleLoop() + end + return false +end + +function Group:_handleLoop() + self._loopCount = self._loopCount + 1 + if self._loop ~= 0 and self._loopCount >= self._loop then + return true + end + + if self._yoyo then + local c = self._children + local n = table.getn(c) + for i = 1, math.floor(n / 2) do + c[i], c[n - i + 1] = c[n - i + 1], c[i] + end + end + + self:_resetChildren() + return false +end + +function Group:_finish() + lib._deactivate(self) + self._done = true + if self._onFinish then self:_onFinish() end +end + +function Group:_start() + self._started = true +end + +function Group:_reset() + self._loopCount = 0 + self._elapsed = 0 + self._done = false + self._started = false + self:_resetChildren() +end + +function Group:_resetChildren() + for i = 1, table.getn(self._children) do + self._children[i]:_reset() + end +end + +local GroupMT = { __index = Group } + +function lib:Group(children, opts) + opts = opts or {} + + for i = 1, table.getn(children) do + children[i]._autoplay = false + end + + local grp = setmetatable({ + _children = children, + _loop = opts.loop or 1, + _yoyo = opts.yoyo or false, + _loopCount = 0, + _delay = opts.delay or 0, + _elapsed = 0, + _onFinish = opts.onFinish, + _onCancel = opts.onCancel, + _done = false, + }, GroupMT) + + if not opts.defer then + lib._schedulePending(grp) + end + + return grp +end +-- Directional offsets for slide animations +local directions = { + LEFT = function(d) return { -d, 0 } end, + RIGHT = function(d) return { d, 0 } end, + UP = function(d) return { 0, d } end, + DOWN = function(d) return { 0,-d } end, +} + +local function dirOffset(dir, dist) + local fn = directions[string.upper(dir or "LEFT")] + return fn and fn(dist) or { 0, 0 } +end + +local function getAnchorPos(frame) + local _, _, _, x, y = frame:GetPoint(1) + return { x or 0, y or 0 } +end + +local EMPTY = {} +local function opts(o) + if type(o) == "table" then return o end + return EMPTY +end + +-- ============================================================ +-- Alpha +-- ============================================================ + +function lib:FadeTo(target, toAlpha, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "alpha", to = toAlpha, + duration = duration, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:FadeIn(target, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "alpha", from = 0, to = 1, + duration = duration, easing = easing, + onStart = function() + target:SetAlpha(0) + target:Show() + if o.onStart then o.onStart() end + end, + delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:FadeOut(target, duration, easing, o) + o = opts(o) + local onFinish = o.onFinish + return lib:FadeTo(target, 0, duration, easing, { + delay = o.delay, onCancel = o.onCancel, defer = o.defer, + onFinish = function() target:Hide(); if onFinish then onFinish() end end, + }) +end + +function lib:Flash(target, count, duration, o) + o = opts(o) + count = count or 3 + duration = duration or 0.6 + local half = duration / count / 2 + local steps = {} + for i = 1, count do + table.insert(steps, lib:Tween(target, { type = "alpha", to = 0, duration = half, easing = "inQuad", defer = true })) + table.insert(steps, lib:Tween(target, { type = "alpha", to = 1, duration = half, easing = "outQuad", defer = true })) + end + return lib:Sequence(steps, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:Breathe(target, minAlpha, maxAlpha, duration, o) + o = opts(o) + duration = duration or 1.6 + local half = duration / 2 + return lib:Sequence({ + lib:Tween(target, { type = "alpha", from = minAlpha, to = maxAlpha, duration = half, easing = "inOutQuad", defer = true }), + lib:Tween(target, { type = "alpha", from = maxAlpha, to = minAlpha, duration = half, easing = "inOutQuad", defer = true }), + }, { loop = 0, delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +-- ============================================================ +-- Scale +-- ============================================================ + +function lib:ScaleIn(target, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "scale", from = 0, to = 1, + duration = duration or 0.3, easing = easing or "outBack", + onStart = function() + target:Show() + if o.onStart then o.onStart() end + end, + delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:ScaleOut(target, duration, easing, o) + o = opts(o) + local onFinish = o.onFinish + return lib:Tween(target, { + type = "scale", from = 1, to = 0, + duration = duration or 0.3, easing = easing or "inBack", + onStart = o.onStart, + onFinish = function() target:Hide(); if onFinish then onFinish() end end, + onCancel = o.onCancel, delay = o.delay, defer = o.defer, + }) +end + +function lib:Pulse(target, scale, duration, o) + o = opts(o) + scale = scale or 1.3 + local half = (duration or 0.4) / 2 + local baseW, baseH = target:GetWidth(), target:GetHeight() + local function set(f, s) + f:SetWidth(baseW * s) + f:SetHeight(baseH * s) + end + return lib:Sequence({ + lib:Tween(target, { type = "custom", from = 1.0, to = scale, duration = half, easing = "outQuad", setter = set, defer = true }), + lib:Tween(target, { type = "custom", from = scale, to = 1.0, duration = half, easing = "inQuad", setter = set, defer = true }), + }, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:Rubber(target, scale, duration, o) + o = opts(o) + return lib:Tween(target, { + type = "scale", from = scale or 1.3, to = 1.0, + duration = duration or 0.5, easing = "outElastic", + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:Flip(target, axis, duration, o) + o = opts(o) + local quarter = (duration or 0.8) / 4 + local horizontal = axis ~= "y" + local base = horizontal and target:GetWidth() or target:GetHeight() + local set = horizontal + and function(f, v) f:SetWidth(v) end + or function(f, v) f:SetHeight(v) end + + -- Find child textures to mirror via SetTexCoord + local textures = {} + if target.GetTexture then + table.insert(textures, target) + else + local children = { target:GetRegions() } + for i = 1, table.getn(children) do + if children[i].GetTexture then table.insert(textures, children[i]) end + end + end + + local function mirror() + for i = 1, table.getn(textures) do + if horizontal then + textures[i]:SetTexCoord(1, 0, 0, 1) + else + textures[i]:SetTexCoord(0, 1, 1, 0) + end + end + end + + local function unmirror() + for i = 1, table.getn(textures) do + textures[i]:SetTexCoord(0, 1, 0, 1) + end + end + + local shrink = function(f, v) set(f, v) end + return lib:Sequence({ + -- 0°→90°: shrink to invisible + lib:Tween(target, { type = "custom", from = base, to = 0.01, duration = quarter, easing = "inQuad", setter = shrink, defer = true }), + -- 90°→180°: mirror and expand (showing flipped content) + lib:Tween(target, { type = "custom", from = 0.01, to = base, duration = quarter, easing = "outQuad", setter = shrink, defer = true, + onStart = function() mirror() end }), + -- 180°→270°: shrink again + lib:Tween(target, { type = "custom", from = base, to = 0.01, duration = quarter, easing = "inQuad", setter = shrink, defer = true }), + -- 270°→360°: unmirror and expand back to original + lib:Tween(target, { type = "custom", from = 0.01, to = base, duration = quarter, easing = "outQuad", setter = shrink, defer = true, + onStart = function() unmirror() end }), + }, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +-- ============================================================ +-- Position +-- ============================================================ + +function lib:Move(target, from, to, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "position", from = from, to = to, + duration = duration or 0.3, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:MoveTo(target, to, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "position", to = to, + duration = duration, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:SlideIn(target, direction, distance, duration, easing, o) + o = opts(o) + local dest = getAnchorPos(target) + local offset = dirOffset(direction, distance or 100) + local from = { dest[1] - offset[1], dest[2] - offset[2] } + return lib:Group({ + lib:Tween(target, { + type = "position", from = from, to = dest, duration = duration or 0.3, easing = easing or "outCubic", + onStart = function() + target:SetAlpha(0) + target:Show() + if o.onStart then o.onStart() end + end, + defer = true, + }), + lib:Tween(target, { + type = "alpha", + from = 0, + to = 1, + duration = (duration or 0.3) * 0.6, + easing = "outQuad", + defer = true, + }), + }, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:SlideOut(target, direction, distance, duration, easing, o) + o = opts(o) + local from = getAnchorPos(target) + local offset = dirOffset(direction, distance or 100) + local dest = { from[1] + offset[1], from[2] + offset[2] } + local onFinish = o.onFinish + return lib:Group({ + lib:Tween(target, { type = "position", from = from, to = dest, duration = duration or 0.3, easing = easing or "inCubic", defer = true }), + lib:Tween(target, { type = "alpha", to = 0, duration = duration or 0.3, easing = "inQuad", defer = true }), + }, { + delay = o.delay, onCancel = o.onCancel, defer = o.defer, + onFinish = function() target:Hide(); if onFinish then onFinish() end end, + }) +end + +function lib:Bounce(target, height, count, duration, o) + o = opts(o) + height = height or 30 + count = count or 3 + duration = duration or 0.6 + local pos = getAnchorPos(target) + local steps = {} + for i = 1, count do + local h = height * (1 - (i - 1) / count) -- decay + table.insert(steps, lib:Tween(target, { + type = "position", from = pos, to = { pos[1], pos[2] + h }, + duration = duration / count / 2, easing = "outQuad", defer = true, + })) + table.insert(steps, lib:Tween(target, { + type = "position", from = { pos[1], pos[2] + h }, to = pos, + duration = duration / count / 2, easing = "inQuad", defer = true, + })) + end + return lib:Sequence(steps, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:Shake(target, intensity, duration, o) + o = opts(o) + intensity = intensity or 5 + duration = duration or 0.3 + local pos = getAnchorPos(target) + local steps = {} + local stepDur = 0.03 + local numSteps = math.floor(duration / stepDur) + for i = 1, numSteps do + local decay = 1 - (i - 1) / numSteps + local dx = (math.random() * intensity * 2 - intensity) * decay + local dy = (math.random() * intensity * 2 - intensity) * decay + table.insert(steps, lib:Tween(target, { + type = "position", to = { pos[1] + dx, pos[2] + dy }, + duration = stepDur, easing = "linear", defer = true, + })) + end + -- Snap back to original position + table.insert(steps, lib:Tween(target, { + type = "position", to = pos, + duration = stepDur, easing = "linear", defer = true, + })) + return lib:Sequence(steps, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:FlyTo(frame, targetFrame, duration, easing, o) + o = opts(o) + local toX = targetFrame:GetLeft() + targetFrame:GetWidth() / 2 - UIParent:GetWidth() / 2 + local toY = targetFrame:GetBottom() + targetFrame:GetHeight() / 2 - UIParent:GetHeight() / 2 + return lib:Tween(frame, { + type = "position", to = { toX, toY }, + duration = duration or 0.5, easing = easing or "inOutQuad", + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +-- ============================================================ +-- Rotation +-- ============================================================ + +function lib:Spin(texture, degreesPerSecond, o) + o = opts(o) + local dps = degreesPerSecond or 90 + return lib:Sequence({ + lib:Tween(texture, { + type = "rotation", + from = 0, + to = dps > 0 and 360 or -360, + duration = 360 / math.abs(dps), + easing = "linear", + defer = true, + }), + }, { loop = 0, delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +-- ============================================================ +-- Color +-- ============================================================ + +function lib:Color(target, from, to, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "color", from = from, to = to, + duration = duration or 0.3, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:ColorFlash(texture, r, g, b, duration, o) + o = opts(o) + duration = duration or 0.4 + local half = duration / 2 + return lib:Sequence({ + lib:Tween(texture, { type = "color", to = { r, g, b, 1 }, duration = half, easing = "outQuad", defer = true }), + lib:Tween(texture, { type = "color", from = { r, g, b, 1 }, to = { 1, 1, 1, 1 }, duration = half, easing = "inQuad", defer = true }), + }, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +-- ============================================================ +-- Size +-- ============================================================ + +function lib:Size(target, from, to, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "size", from = from, to = to, + duration = duration or 0.3, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +function lib:Morph(target, to, duration, easing, o) + o = opts(o) + return lib:Tween(target, { + type = "size", to = to, + duration = duration or 0.3, easing = easing, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +-- ============================================================ +-- Value +-- ============================================================ + +function lib:Progress(statusbar, toValue, duration, easing, o) + o = opts(o) + return lib:Tween(statusbar, { + type = "custom", + to = toValue, + duration = duration or 0.4, + easing = easing or "outCubic", + getter = function(bar) return bar:GetValue() end, + setter = function(bar, val) bar:SetValue(val) end, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +-- ============================================================ +-- Text +-- ============================================================ + +function lib:Typewriter(fontstring, text, duration, o) + o = opts(o) + local len = string.len(text) + return lib:Tween(fontstring, { + type = "custom", + from = 0, to = len, + duration = duration or len * 0.05, + easing = "linear", + setter = function(fs, val) + fs:SetText(string.sub(text, 1, math.floor(val))) + end, + delay = o.delay, onStart = o.onStart, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer, + }) +end + +-- ============================================================ +-- Clipping (Reveal / Conceal) +-- ============================================================ + +-- Progressively reveal a texture from a direction by re-anchoring +-- to one edge, animating width/height, and shifting tex coords. +local clipAnchors = { + LEFT = { "TOPLEFT", "TOPLEFT" }, + RIGHT = { "TOPRIGHT", "TOPRIGHT" }, + UP = { "TOPLEFT", "TOPLEFT" }, + DOWN = { "BOTTOMLEFT", "BOTTOMLEFT" }, +} + +local function clipTween(target, direction, duration, easing, from, to, o) + o = opts(o) + direction = string.upper(direction or "LEFT") + duration = duration or 0.3 + + local parent = target:GetParent() + local baseW = target._clipBaseW or parent:GetWidth() + local baseH = target._clipBaseH or parent:GetHeight() + target._clipBaseW = baseW + target._clipBaseH = baseH + + local horizontal = (direction == "LEFT" or direction == "RIGHT") + local reversed = (direction == "RIGHT" or direction == "UP") + local anchor = clipAnchors[direction] + + target:ClearAllPoints() + target:SetPoint(anchor[1], parent, anchor[2], 0, 0) + + return lib:Tween(target, { + type = "custom", from = from, to = to, + duration = duration, easing = easing or "outQuad", + setter = function(tex, t) + if horizontal then + tex:SetWidth(math.max(baseW * t, 0.01)) + tex:SetHeight(baseH) + if reversed then + tex:SetTexCoord(1 - t, 1, 0, 1) + else + tex:SetTexCoord(0, t, 0, 1) + end + else + tex:SetWidth(baseW) + tex:SetHeight(math.max(baseH * t, 0.01)) + if reversed then + tex:SetTexCoord(0, 1, 1 - t, 1) + else + tex:SetTexCoord(0, 1, 0, t) + end + end + end, + onFinish = function() + target:SetTexCoord(0, 1, 0, 1) + target:ClearAllPoints() + target:SetAllPoints(parent) + if o.onFinish then o.onFinish() end + end, + onCancel = o.onCancel, delay = o.delay, defer = o.defer, + }) +end + +function lib:Reveal(target, direction, duration, easing, o) + target:Show() + return clipTween(target, direction, duration, easing, 0, 1, o) +end + +function lib:Conceal(target, direction, duration, easing, o) + o = opts(o) + local onFinish = o.onFinish + return clipTween(target, direction, duration, easing, 1, 0, { + delay = o.delay, onCancel = o.onCancel, defer = o.defer, + onFinish = function() target:Hide(); if onFinish then onFinish() end end, + }) +end + +-- WipeIn/WipeOut: texture slides in from a direction while being clipped. +-- Unlike Reveal/Conceal where the image is stationary and a window opens, +-- here the image physically moves into view (like sliding out from under a cover). +-- WipeIn/WipeOut: the image slides into/out of view while being clipped. +-- The texture stays full-size (no stretching) -- we clip by shrinking the +-- visible region and offsetting tex coords so the revealed portion matches +-- the correct part of the image. +-- +-- For "LEFT" (image slides in from the left edge): +-- anchor at TOPLEFT, width = baseW * t, texCoord left = 1-t +-- → at t=0 nothing visible; at t=1 full image +local wipeAnchors = { + LEFT = "TOPLEFT", + RIGHT = "TOPRIGHT", + UP = "TOPLEFT", + DOWN = "BOTTOMLEFT", +} + +local function wipeTween(target, direction, duration, easing, from, to, o) + o = opts(o) + direction = string.upper(direction or "LEFT") + duration = duration or 0.3 + + local parent = target:GetParent() + local baseW = target._clipBaseW or parent:GetWidth() + local baseH = target._clipBaseH or parent:GetHeight() + target._clipBaseW = baseW + target._clipBaseH = baseH + + local horizontal = (direction == "LEFT" or direction == "RIGHT") + local anchor = wipeAnchors[direction] + + target:ClearAllPoints() + target:SetPoint(anchor, parent, anchor, 0, 0) + + return lib:Tween(target, { + type = "custom", from = from, to = to, + duration = duration, easing = easing or "outQuad", + setter = function(tex, t) + if horizontal then + tex:SetWidth(math.max(baseW * t, 0.01)) + tex:SetHeight(baseH) + if direction == "LEFT" then + tex:SetTexCoord(1 - t, 1, 0, 1) + else + tex:SetTexCoord(0, t, 0, 1) + end + else + tex:SetWidth(baseW) + tex:SetHeight(math.max(baseH * t, 0.01)) + if direction == "DOWN" then + tex:SetTexCoord(0, 1, 1 - t, 1) + else + tex:SetTexCoord(0, 1, 0, t) + end + end + end, + onFinish = function() + target:SetTexCoord(0, 1, 0, 1) + target:ClearAllPoints() + target:SetAllPoints(parent) + if o.onFinish then o.onFinish() end + end, + onCancel = o.onCancel, delay = o.delay, defer = o.defer, + }) +end + +local wipeInFlip = { LEFT="RIGHT", RIGHT="LEFT", UP="DOWN", DOWN="UP" } + +function lib:WipeIn(target, direction, duration, easing, o) + target:Show() + local dir = wipeInFlip[string.upper(direction or "LEFT")] or direction + return wipeTween(target, dir, duration, easing, 0, 1, o) +end + +function lib:WipeOut(target, direction, duration, easing, o) + o = opts(o) + local onFinish = o.onFinish + return wipeTween(target, direction, duration, easing, 1, 0, { + delay = o.delay, onCancel = o.onCancel, defer = o.defer, + onFinish = function() target:Hide(); if onFinish then onFinish() end end, + }) +end + +-- ============================================================ +-- Meta / Utility +-- ============================================================ + +function lib:Stagger(targets, animFn, delay, o) + o = opts(o) + delay = delay or 0.08 + local children = {} + for i = 1, table.getn(targets) do + local anim = animFn(targets[i], i) + anim._autoplay = false + if i > 1 then + table.insert(children, lib:Sequence({ lib:Delay(delay * (i - 1)), anim }, { defer = true })) + else + table.insert(children, anim) + end + end + return lib:Group(children, { delay = o.delay, onFinish = o.onFinish, onCancel = o.onCancel, defer = o.defer }) +end + +function lib:Delay(duration) + -- A no-op tween that just waits. Uses a dummy target-less approach. + local dummy = lib._frame + return lib:Tween(dummy, { + type = "alpha", + from = dummy:GetAlpha(), + to = dummy:GetAlpha(), + duration = duration or 1, + defer = true, + }) +end + +function lib:Run(fn, o) + o = opts(o) + return lib:Tween(nil, { + type = "custom", from = 0, to = 0, duration = 0, + onStart = fn, delay = o.delay, defer = o.defer, + }) +end + +function lib:Stop(target) + for anim in pairs(lib._active) do + if anim._target == target then + anim:Cancel() + elseif anim._children then + lib:_stopInChildren(anim, anim, target) + end + end + -- Also cancel pending (not yet started) animations for this target + for i = 1, lib._pendingN do + local anim = lib._pending[i] + if anim then + if anim._target == target then + anim._autoplay = false + elseif anim._children then + if lib:_hasTargetInChildren(anim, target) then + anim._autoplay = false + end + end + end + end +end + +function lib:_hasTargetInChildren(parent, target) + for i = 1, table.getn(parent._children) do + local child = parent._children[i] + if child._target == target then return true end + if child._children and lib:_hasTargetInChildren(child, target) then return true end + end + return false +end + +function lib:_stopInChildren(root, parent, target) + for i = 1, table.getn(parent._children) do + local child = parent._children[i] + if child._target == target then + root:Cancel() + return + elseif child._children then + lib:_stopInChildren(root, child, target) + if not root._active then return end + end + end +end diff --git a/api/bag-ammo-labels.lua b/api/bag-ammo-labels.lua new file mode 100644 index 0000000..eb7b53a --- /dev/null +++ b/api/bag-ammo-labels.lua @@ -0,0 +1,463 @@ +-- ============================================================ +-- Bag Ammo Labels — standalone overlay for bag & bank ammo slots +-- Uses globals from zButtonAmmo.lua: +-- zButtonAmmo_ShortArrowLabels, zButtonAmmo_ShortBulletLabels, +-- zButtonAmmo_GetQualityColor, zButtonAmmo_GetColorGradient, +-- zButtonAmmo_GetKnownAmmoTypes +-- Uses DPS data from MTH_DS_AmmoItems[id].dps +-- ============================================================ + +-- Saved variable cache (per-character, stored in "general" module) +local cache = {} -- bagAmmoLabels, bankAmmoLabels, bagAmmoDamage + +local function GetStore() + return MTH and MTH.GetModuleCharSavedVariables and MTH:GetModuleCharSavedVariables("general") +end + +local function GetCached(key, default) + if cache[key] == nil then + local store = GetStore() + if type(store) == "table" and store[key] ~= nil then + cache[key] = store[key] and true or false + else + cache[key] = default or false + end + end + return cache[key] +end + +local function SetCached(key, enabled) + cache[key] = enabled and true or false + local store = GetStore() + if type(store) == "table" then + store[key] = cache[key] + end +end + +-- Public getters/setters +function MTH_BagAmmoLabels_GetEnabled() + return GetCached("bagAmmoLabels") +end +function MTH_BagAmmoLabels_SetEnabled(enabled) + SetCached("bagAmmoLabels", enabled) + MTH_BagAmmoLabels_RefreshAll() +end + +function MTH_BankAmmoLabels_GetEnabled() + return GetCached("bankAmmoLabels") +end +function MTH_BankAmmoLabels_SetEnabled(enabled) + SetCached("bankAmmoLabels", enabled) + MTH_BagAmmoLabels_RefreshAllBank() +end + +function MTH_BagAmmoDamage_GetEnabled() + return GetCached("bagAmmoDamage") +end +function MTH_BagAmmoDamage_SetEnabled(enabled) + SetCached("bagAmmoDamage", enabled) + MTH_BagAmmoLabels_RefreshAll() + MTH_BagAmmoLabels_RefreshAllBank() +end + +-- Heat color: red (7.5 DPS) → yellow → green (20.5 DPS); below 7.5 = grey +local DPS_MIN = 7.5 +local DPS_MAX = 20.5 + +local function GetDpsHeatColor(dps) + if not dps then return 1, 1, 1 end + if dps < DPS_MIN then return 0.6, 0.6, 0.6 end + local perc = (dps - DPS_MIN) / (DPS_MAX - DPS_MIN) + return zButtonAmmo_GetColorGradient(perc) +end + +-- Look up DPS from MTH_DS_AmmoItems by numeric item ID +local function GetAmmoDps(numericId) + if not MTH_DS_AmmoItems then return nil end + local entry = MTH_DS_AmmoItems[numericId] + return entry and entry.dps +end + +-- Detect whether pfUI bags are active +local function HasPfUI() + return pfUI and pfUI.bags and pfUI.bag and true or false +end + +-- Is this bagID a bank bag? +local BANK_BAGS = { [-1] = true, [5] = true, [6] = true, [7] = true, [8] = true, [9] = true, [10] = true, [11] = true } + +local function IsBankBag(bagID) + return BANK_BAGS[bagID] or false +end + +-- Apply or clear labels on a single bag-slot button +local function UpdateButton(button, bagID, slot, showName, showDmg) + if not button then return end + + -- Lazy-create name label (TOPLEFT) + if not button._mthAmmoLabel then + button._mthAmmoLabel = button:CreateFontString(nil, "OVERLAY") + button._mthAmmoLabel:SetPoint("TOPLEFT", button, "TOPLEFT", 1, -1) + button._mthAmmoLabel:SetFont("Fonts\\ARIALN.ttf", 9, "OUTLINE") + button._mthAmmoLabel:Hide() + end + -- Lazy-create damage label (CENTER, large + bold) + if not button._mthAmmoDmgLabel then + button._mthAmmoDmgLabel = button:CreateFontString(nil, "OVERLAY") + button._mthAmmoDmgLabel:SetPoint("CENTER", button, "CENTER", 0, 0) + button._mthAmmoDmgLabel:SetFont("Fonts\\ARIALN.ttf", 10, "THICKOUTLINE") + button._mthAmmoDmgLabel:Hide() + end + + local nameLabel = button._mthAmmoLabel + local dmgLabel = button._mthAmmoDmgLabel + + if not showName and not showDmg then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local link = GetContainerItemLink(bagID, slot) + if not link then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local _, _, id = string.find(link, "Hitem:(%d+)") + if not id then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local numericId = tonumber(id) + local itemName, _, itemQuality, _, _, _, _, _, itemSlot = GetItemInfo(id) + if not itemName then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local isAmmo = (itemSlot == "INVTYPE_AMMO") + if not isAmmo then + local knownAmmo = zButtonAmmo_GetKnownAmmoTypes and zButtonAmmo_GetKnownAmmoTypes() + isAmmo = knownAmmo and knownAmmo[itemName] + end + if not isAmmo then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + -- Name label + if showName then + local shortText = zButtonAmmo_ShortArrowLabels[itemName] + or zButtonAmmo_ShortBulletLabels[itemName] + if shortText then + local cr, cg, cb = zButtonAmmo_GetQualityColor(itemQuality) + nameLabel:SetTextColor(cr, cg, cb, 1) + nameLabel:SetText(shortText) + nameLabel:Show() + else + nameLabel:Hide() + end + else + nameLabel:Hide() + end + + -- Damage label + if showDmg then + local dps = GetAmmoDps(numericId) + if dps then + local cr, cg, cb = GetDpsHeatColor(dps) + dmgLabel:SetTextColor(cr, cg, cb, 1) + dmgLabel:SetText(dps) + dmgLabel:Show() + else + dmgLabel:Hide() + end + else + dmgLabel:Hide() + end +end + +-- Clear both labels on a button +local function ClearButton(button) + if not button then return end + if button._mthAmmoLabel then button._mthAmmoLabel:Hide() end + if button._mthAmmoDmgLabel then button._mthAmmoDmgLabel:Hide() end +end + +-- Forward declaration +local RefreshAllVisibleFrames + +-- Refresh labels on a single bag (works for any bagID) +local function RefreshBag(bagID, showName, showDmg) + local numSlots = GetContainerNumSlots(bagID) + if numSlots == 0 then return end + + -- pfUI path + if HasPfUI() then + local pfBag = pfUI.bags[bagID] + if not pfBag or not pfBag.slots then return end + for slot = 1, numSlots do + local slotData = pfBag.slots[slot] + local button = slotData and slotData.frame + if button then + if showName or showDmg then + UpdateButton(button, bagID, slot, showName, showDmg) + else + ClearButton(button) + end + end + end + return + end + + -- Default UI path + + -- Main bank (bagID -1): buttons are BankFrameItem1..28 + if bagID == -1 then + for slot = 1, numSlots do + local button = getglobal("BankFrameItem" .. slot) + if button then + if showName or showDmg then + UpdateButton(button, -1, slot, showName, showDmg) + else + ClearButton(button) + end + end + end + return + end + + -- All other bags (0-4 and 5-11): search all ContainerFrames + for ci = 1, 13 do + local frame = getglobal("ContainerFrame" .. ci) + if frame and frame:IsVisible() and frame:GetID() == bagID then + local frameName = frame:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(frameName .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + if showName or showDmg then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + else + ClearButton(button) + end + end + end + -- Clear stale labels on any buttons beyond numSlots + for slot = numSlots + 1, 24 do + local button = getglobal(frameName .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + return + end + end +end + +-- Public: refresh all inventory bags (0-4) +function MTH_BagAmmoLabels_RefreshAll() + RefreshAllVisibleFrames() +end + +-- Public: refresh all bank bags (-1, 5-11) +function MTH_BagAmmoLabels_RefreshAllBank() + RefreshAllVisibleFrames() +end + +-- Refresh all visible default UI ContainerFrames (frame-centric: no bagID lookup) +RefreshAllVisibleFrames = function() + local bagShowName = MTH_BagAmmoLabels_GetEnabled() + local bankShowName = MTH_BankAmmoLabels_GetEnabled() + local showDmg = MTH_BagAmmoDamage_GetEnabled() + + if HasPfUI() then + -- pfUI: refresh via pfUI bag structure + for bagID = 0, 4 do + RefreshBag(bagID, bagShowName, showDmg) + end + local bankBags = { -1, 5, 6, 7, 8, 9, 10, 11 } + for _, bagID in ipairs(bankBags) do + RefreshBag(bagID, bankShowName, showDmg) + end + return + end + + -- Default UI: iterate all visible ContainerFrames + for ci = 1, 13 do + local cf = getglobal("ContainerFrame" .. ci) + if cf and cf:IsVisible() then + local bagID = cf:GetID() + local showName = IsBankBag(bagID) and bankShowName or bagShowName + local numSlots = GetContainerNumSlots(bagID) + local fname = cf:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(fname .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + if showName or showDmg then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + else + ClearButton(button) + end + end + end + for slot = numSlots + 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + end + + -- Bank bag -1: BankFrameItem buttons + if bankShowName or showDmg then + local numSlots = GetContainerNumSlots(-1) + for slot = 1, numSlots do + local button = getglobal("BankFrameItem" .. slot) + if button then + UpdateButton(button, -1, slot, bankShowName, showDmg) + end + end + end +end + +-- Event-driven refresh: coalesce with a short delay +do + local frame = CreateFrame("Frame", "MTH_BagAmmoLabelsFrame", UIParent) + local needsRefresh = false + local elapsed = 0 + + frame:RegisterEvent("BAG_UPDATE") + frame:RegisterEvent("PLAYERBANKSLOTS_CHANGED") + frame:RegisterEvent("PLAYERBANKBAGSLOTS_CHANGED") + frame:RegisterEvent("BANKFRAME_OPENED") + frame:RegisterEvent("PLAYER_ENTERING_WORLD") + + frame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + -- After login/reload, delay 1s for item cache to populate + elapsed = -0.95 + needsRefresh = true + frame:Show() + return + end + + if event == "BANKFRAME_OPENED" then + if MTH_BankAmmoLabels_GetEnabled() or MTH_BagAmmoDamage_GetEnabled() then + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end + return + end + + if event == "PLAYERBANKSLOTS_CHANGED" or event == "PLAYERBANKBAGSLOTS_CHANGED" then + if MTH_BankAmmoLabels_GetEnabled() or MTH_BagAmmoDamage_GetEnabled() then + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end + return + end + + -- BAG_UPDATE + local bagID = arg1 + if not bagID then return end + if IsBankBag(bagID) then + if not MTH_BankAmmoLabels_GetEnabled() and not MTH_BagAmmoDamage_GetEnabled() then return end + else + if not MTH_BagAmmoLabels_GetEnabled() and not MTH_BagAmmoDamage_GetEnabled() then return end + end + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end) + + frame:SetScript("OnUpdate", function() + elapsed = elapsed + (arg1 or 0.016) + if elapsed < 0.05 then return end + needsRefresh = false + frame:Hide() + RefreshAllVisibleFrames() + end) + frame:Hide() + + -- Hook ContainerFrame OnShow: trigger a refresh when a bag becomes visible + if not HasPfUI() then + for ci = 1, 13 do + local cf = getglobal("ContainerFrame" .. ci) + if cf then + local origOnShow = cf:GetScript("OnShow") + cf:SetScript("OnShow", function() + if origOnShow then origOnShow() end + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end) + end + end + end + + -- Post-hook ContainerFrame_Update for default UI (synchronous update) + if type(ContainerFrame_Update) == "function" then + local origCFU = ContainerFrame_Update + ContainerFrame_Update = function(cf) + origCFU(cf) + if cf and cf:IsVisible() then + local bagID = cf:GetID() + local showDmg = MTH_BagAmmoDamage_GetEnabled() + local showName + if IsBankBag(bagID) then + showName = MTH_BankAmmoLabels_GetEnabled() + else + showName = MTH_BagAmmoLabels_GetEnabled() + end + if showName or showDmg then + local numSlots = GetContainerNumSlots(bagID) + local fname = cf:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(fname .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + end + end + for slot = numSlots + 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + else + -- Frame hidden: clear all labels + if cf then + local fname = cf:GetName() + if fname then + for slot = 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + end + end + end + end +end diff --git a/api/beastlore-scan.lua b/api/beastlore-scan.lua deleted file mode 100644 index 13e2240..0000000 --- a/api/beastlore-scan.lua +++ /dev/null @@ -1,8 +0,0 @@ ------------------------------------------------------- --- MetaHunt: Beast Lore Scanner (removed) ------------------------------------------------------- --- This file previously contained a feature that recorded --- unknown beasts to SavedVariables when scanned with --- Beast Lore. The feature has been removed; the static --- beast database (ds-beasts) is now the sole data source. ------------------------------------------------------- diff --git a/api/config.lua b/api/config.lua index ecdb00b..772d006 100644 --- a/api/config.lua +++ b/api/config.lua @@ -38,7 +38,10 @@ local function MTH_ConfigEnsureStore(module_name) root.modules[module_name] = legacy end - root[module_name] = root.modules[module_name] + -- NOTE: we intentionally do NOT mirror root[module_name] = root.modules[module_name]. + -- That old alias made WoW serialize a full DUPLICATE of every module store at the + -- account root on disk. The canonical store is root.modules[module_name] only. + root[module_name] = nil MTH_SavedVariables = root return root.modules[module_name] end diff --git a/api/constants.lua b/api/constants.lua index 9cf8621..24b74e8 100644 --- a/api/constants.lua +++ b/api/constants.lua @@ -1,5 +1,5 @@ MTH_CONST = MTH_CONST or {} -MTH_CONST.version = MTH_CONST.version or "1.5.0" +MTH_CONST.version = MTH_CONST.version or "2.0.0" MTH_CONST.WEAPON_TYPES = { BOWS = "Bows", diff --git a/api/core-feed-tracking.lua b/api/core-feed-tracking.lua index 565831e..95871df 100644 --- a/api/core-feed-tracking.lua +++ b/api/core-feed-tracking.lua @@ -1385,6 +1385,13 @@ function MTH_FEED_FinalizeAttempt(attemptId, payload) if petKey ~= "" then MTH_FEED_Runtime.sessionFeedsByPetId[petKey] = (tonumber(MTH_FEED_Runtime.sessionFeedsByPetId[petKey]) or 0) + 1 end + if type(MTH_FX_Flash) == "function" and type(MTH_FX_IsEnabled) == "function" + and MTH_FX_IsEnabled("pet.feed") then + local target = getglobal("zButtonPet") + if target then + MTH_FX_Flash(target, 0.35, 1.00, 0.35, 0.5) + end + end else petRow.totals.rejected = petRow.totals.rejected + 1 if reason == "no-buff" then diff --git a/api/core-framework.lua b/api/core-framework.lua index 9f90965..305d972 100644 --- a/api/core-framework.lua +++ b/api/core-framework.lua @@ -1,5 +1,5 @@ MTH = MTH or { - version = "1.5.0", + version = "2.0.0", name = "MetaHunt", modules = {}, config = {}, @@ -7,13 +7,29 @@ MTH = MTH or { } local MTH_CHAT_PREFIX = "|cFFFFFFFF[|r|cFFD14A4AMeta|r|cFFABD473Hunt|r|cFFFFFFFF]|r " -local MTH_NON_HUNTER_BLOCK_MESSAGE = "You are not a hunter and you are not allowed here. Disable the add-on for this character." +local MTH_NON_HUNTER_BLOCK_MESSAGE = "You are not a hunter. Disable the add-on for this character." +local MTH_REALM_BLOCK_MESSAGE = "Zero support for Ravencraft/Capybara mafia's realms. Consider joining Octowow." +local MTH_BLOCKED_REALMS = { + ["Medivh"] = true, + ["\230\176\184\230\173\140\232\141\146\233\135\142"] = true, + ["\211\192\184\232\187\196\210\176"] = true, + ["\229\159\186\232\181\171\231\186\179\230\150\175"] = true, + ["\187\249\186\213\196\201\203\185"] = true, + ["\231\190\164\230\152\159\231\155\134\229\156\176"] = true, + ["\200\186\208\199\197\232\181\216"] = true, +} + local MTH_MESSAGE_DEFAULTS = { initModulesLoaded = false, initSarcasticWelcome = true, petHungry = false, beastTrainingScan = true, spellbookScan = false, + trainerScan = true, + hunterLevelUp = true, + hunterLevelQuiet = false, + petLevelUp = true, + petLoyaltyUp = true, petRanAway = true, mapMarkers = true, stableScan = true, @@ -189,6 +205,62 @@ function MTH:ApplyClassGate(_source) return blocked end +function MTH:GetCurrentRealmName() + local realm = nil + if type(GetRealmName) == "function" then + realm = GetRealmName() + end + if (realm == nil or realm == "") and type(GetCVar) == "function" then + realm = GetCVar("realmName") + end + return realm +end + +function MTH:IsRealmGateBlocked() + return self and self._realmGateBlocked and true or false +end + +function MTH:CheckRealmGate() + -- TEMP TESTING: allow Ravencraft client access while HunterBook layout is reviewed. + if true then + return false + end + + -- Realm name is only reliable once logged in; an empty result never blocks. + local realm = self:GetCurrentRealmName() + if type(realm) == "string" and realm ~= "" then + self._currentRealm = realm + if MTH_BLOCKED_REALMS[realm] then + self._realmGateBlocked = true + end + end + return self:IsRealmGateBlocked() +end + +function MTH:AnnounceRealmGateBlocked() + if self._realmGateAnnounced then + return + end + local sink = MTH_TryOutputClassGateMessage(MTH_REALM_BLOCK_MESSAGE) + if sink == "chat" or sink == "uierrors" or sink == "print" then + self._realmGateAnnounced = true + end +end + +function MTH:ApplyRealmGate(_source) + if self:IsHardBlocked() then + return true + end + if self:CheckRealmGate() then + self:AnnounceRealmGateBlocked() + if self.ShutdownForNonHunter then + self:ShutdownForNonHunter(_source or "realm-gate") + end + return true + end + return false +end + MTH:ApplyClassGate("startup") if not MTH._classGateLifecycleFrame then @@ -202,6 +274,11 @@ if not MTH._classGateLifecycleFrame then MTH._classGateAnnounced = nil MTH._classGateAnnouncePending = nil end + if MTH and MTH.ApplyRealmGate and MTH:ApplyRealmGate(event) then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end if MTH and MTH.ApplyClassGate and MTH:ApplyClassGate(event) and MTH._classGateAnnouncedChat then this:UnregisterAllEvents() this:SetScript("OnEvent", nil) @@ -1120,8 +1197,6 @@ function MTH:InitSavedVariables() if not MTH_SavedVariables then MTH_SavedVariables = { modules = {}, - feedomatic = {}, - zhunter = {}, } end @@ -1131,12 +1206,6 @@ function MTH:InitSavedVariables() if not MTH_SavedVariables.moduleStates then MTH_SavedVariables.moduleStates = {} end - if not MTH_SavedVariables.modules.feedomatic then - MTH_SavedVariables.modules.feedomatic = MTH_SavedVariables.feedomatic or {} - end - if not MTH_SavedVariables.modules.zhunter then - MTH_SavedVariables.modules.zhunter = MTH_SavedVariables.zhunter or {} - end if type(MTH_SavedVariables.messages) ~= "table" then MTH_SavedVariables.messages = {} end @@ -1148,9 +1217,6 @@ function MTH:InitSavedVariables() end end - MTH_SavedVariables.feedomatic = MTH_SavedVariables.modules.feedomatic - MTH_SavedVariables.zhunter = MTH_SavedVariables.modules.zhunter - if not MTH_CharSavedVariables then MTH_CharSavedVariables = {} end @@ -1204,6 +1270,24 @@ function MTH:InitSavedVariables() end end end + + -- NOTE: the legacy/scattered SavedVariables consolidation (MTH_SV_EnsureSchema) + -- is intentionally NOT called here. InitSavedVariables also runs at file-load time + -- (before WoW has loaded the SV file from disk), when the roots are still empty + -- defaults. Running the migration there operated on throwaway tables and made it + -- re-announce every session. The migration is now driven from an ADDON_LOADED + -- handler in api/savedvariables.lua, which fires once the real saved data exists. + + -- Account-root module aliases must NEVER persist: the canonical stores live under + -- .modules.. feedomatic/zhunter used to be mirrored here (config.lua alias + + -- InitSavedVariables seeds), which made WoW serialize a full DUPLICATE on disk. + -- Strip them unconditionally on every load — even after the one-shot migration gate + -- has closed — so any stray copy re-created by older code, or already sitting in an + -- on-disk file from a previous version, is removed. + if type(MTH_SavedVariables) == "table" then + MTH_SavedVariables.feedomatic = nil + MTH_SavedVariables.zhunter = nil + end end function MTH:GetModuleSavedVariables(name) diff --git a/api/core-level-tracking.lua b/api/core-level-tracking.lua new file mode 100644 index 0000000..d556c5d --- /dev/null +++ b/api/core-level-tracking.lua @@ -0,0 +1,386 @@ +-- MetaHunt level tracker. All training facts come from MTH_DS_TrainerCatalog +-- through trainer-spell-planner.lua; this module only detects progression and +-- presents the resulting facts through chat and animations. + +local MTH_LT_Frame = nil +local MTH_LT_PendingPetCheck = false + +local MTH_LT_HunterLines = { + [1] = "You can now track beasts. Revolutionary: look for the things you intend to shoot.", + [2] = "The wilderness remains deeply unimpressed.", + [3] = "Still mostly leather, arrows, and misplaced confidence.", + [4] = "You have actual trainer lessons now. Try not to spend all your coin on repairs.", + [5] = "Your pet is doing its share. Please consider doing yours.", + [6] = "A hunter's mark: because shouting at the target was not precise enough.", + [7] = "Every pull is a plan until it becomes cardio.", + [8] = "Concussive Shot. Distance is now a tactical concept, not an accident.", + [9] = "Your arrows have begun their long argument with gravity.", + [10] = "You have a hawk aspect. The bird remains unavailable for comment.", + [11] = "A fine age for discovering that pets also require paperwork.", + [12] = "Mend Pet unlocked. Your pet has noticed this was overdue.", + [13] = "The wilderness has upgraded its complaints department.", + [14] = "Eagle Eye. Finally, a way to inspect trouble before walking into it.", + [15] = "You have survived another level through skill, luck, or a pet-shaped shield.", + [16] = "Traps and sharp things. Subtlety continues to be optional.", + [17] = "Your pet is still carrying the emotional weight of this partnership.", + [18] = "Multi-Shot. One target was apparently an unacceptable limitation.", + [19] = "Soon you may run faster. The enemies will take this personally.", + [20] = "Congratulations: escape now has a speed setting.", + [21] = "Fast enough to arrive at the next questionable decision sooner.", + [22] = "Your trainer has new bills and somehow they are all addressed to you.", + [23] = "The pet remains employed despite the working conditions.", + [24] = "Beast Lore. At last: research before violence. Briefly.", + [25] = "Halfway to better excuses, nowhere near fewer mistakes.", + [26] = "Rapid Fire. Your bow has entered its impatient phase.", + [27] = "Things are going well enough to become dangerous.", + [28] = "Your arrows now contain a measurable amount of intent.", + [29] = "Prepare for the hunter's most respected tactical maneuver.", + [30] = "Feign Death. Why win a fight when you can become administrative debris?", + [31] = "You are alive, which validates absolutely nothing.", + [32] = "Flare. Stealth has been downgraded to a suggestion.", + [33] = "The next trap is definitely part of the plan.", + [34] = "Explosive Trap. Negotiation has left the chat.", + [35] = "Your pet thinks this is all normal. That is worrying.", + [36] = "More damage, more maintenance, same heroic disregard for repair bills.", + [37] = "Keep going. The next disaster is probably already pathing toward you.", + [38] = "Your toolkit is becoming impressively hard to explain to civilized people.", + [39] = "One level until armor finally admits you have been taking hits.", + [40] = "Mail training. Leather got you this far; enemies kept putting holes in it.", + [41] = "You are now visibly better protected from consequences.", + [42] = "More Multi-Shot: because collateral damage needed a sequel.", + [43] = "Your pet has grown used to the sound of avoidable danger.", + [44] = "The trainer is delighted. Your coin pouch is less enthusiastic.", + [45] = "You are a seasoned hunter now, which means the mistakes are expensive.", + [46] = "Nature resistance: proof that the outdoors have been escalating.", + [47] = "Still alive. Still pointing the dangerous end away from yourself. Usually.", + [48] = "Your damage has matured into a fully funded public nuisance.", + [49] = "One more level until the trainer starts charging executive prices.", + [50] = "Your arrows have tenure now.", + [51] = "The wilderness would like a word. It has a queue.", + [52] = "Aimed Shot hits harder, which is your preferred form of diplomacy.", + [53] = "Your pet remains your most reliable party member. Make of that what you will.", + [54] = "Explosive Trap again. The floor continues to be a valid target.", + [55] = "Almost there. Try not to die to something with a tutorial tooltip.", + [56] = "Aspect of the Viper. Mana problems now have a bird-shaped solution.", + [57] = "The finish line is visible. So are several elite mobs. Choose carefully.", + [58] = "The final trainer bills are arriving with alarming confidence.", + [59] = "One last level. Your pet has begun drafting a victory speech.", + [60] = "You made it. The wildlife will remember this unfavorably.", +} + +local MTH_LT_Priority = { + ["Feign Death"] = 100, + ["Mail"] = 95, + ["Aspect of the Cheetah"] = 90, + ["Rapid Fire"] = 88, + ["Aspect of the Pack"] = 86, + ["Explosive Trap"] = 84, + ["Freezing Trap"] = 82, + ["Flare"] = 80, + ["Eagle Eye"] = 78, + ["Multi-Shot"] = 76, + ["Aimed Shot"] = 74, + ["Aspect of the Viper"] = 72, + ["Trueshot Aura"] = 70, + ["Volley"] = 68, + ["Track Hidden"] = 66, +} + +local MTH_LT_PetLines = { + "It is now statistically better than your last pull.", + "The food budget has noticed.", + "Please do not celebrate by pulling three extra mobs.", + "It remains committed to solving problems with teeth.", + "The wilderness has filed no objection.", + "Your dependable colleague is carrying this team again.", +} + +local MTH_LT_LoyaltyLines = { + [2] = "It tolerates your leadership as long as the food arrives on time.", + [3] = "Your authority has been acknowledged, reluctantly.", + [4] = "It has decided your plans are survivable often enough.", + [5] = "Please do not reward this trust with an elite pull.", + [6] = "Against all evidence, it trusts you completely.", +} + +local function MTH_LT_Store() + if not MTH_CharSavedVariables then MTH_CharSavedVariables = {} end + if type(MTH_CharSavedVariables.levelTracker) ~= "table" then + MTH_CharSavedVariables.levelTracker = { petLine = 0, loyaltyLine = 0 } + end + return MTH_CharSavedVariables.levelTracker +end + +local function MTH_LT_MessageEnabled(key, defaultValue) + return MTH and type(MTH.IsMessageEnabled) == "function" and MTH:IsMessageEnabled(key, defaultValue) +end + +local function MTH_LT_FormatCost(copper) + local total = tonumber(copper) or 0 + local gold = math.floor(total / 10000) + local silver = math.floor((total - gold * 10000) / 100) + local bronze = total - gold * 10000 - silver * 100 + local parts = {} + if gold > 0 then table.insert(parts, "|cffffd100" .. gold .. "g|r") end + if silver > 0 or gold > 0 then table.insert(parts, "|cffc7c7cf" .. silver .. "s|r") end + table.insert(parts, "|cffeda55f" .. bronze .. "c|r") + return table.concat(parts, " ") +end + +local function MTH_LT_NextLine(store, key, lines) + store[key] = (tonumber(store[key]) or 0) + 1 + if store[key] > table.getn(lines) then store[key] = 1 end + return lines[store[key]] +end + +local function MTH_LT_GetHeadline(plan) + local best = nil + local bestPriority = -1 + for _, entry in ipairs(plan.rows or {}) do + local priority = MTH_LT_Priority[tostring(entry.row and entry.row.name or "")] or 0 + if priority > bestPriority then best, bestPriority = entry, priority end + end + return best +end + +local function MTH_LT_FormatRows(plan) + local labels = {} + for index, entry in ipairs(plan.rows or {}) do + local row = entry.row or {} + local label = tostring(row.name or "Unknown") + if row.rank and row.rank ~= "" then label = label .. " " .. tostring(row.rank) end + table.insert(labels, label) + end + return table.concat(labels, ", ") +end + +local function MTH_LT_GrowlRankForLevel(level) + local rank = math.floor((tonumber(level) or 1) / 10) + 1 + if rank < 1 then rank = 1 end + if rank > 7 then rank = 7 end + return rank +end + +local function MTH_LT_BuildPetGrowlPlan(level, preview, honored) + if type(MTH_TrainerPlan_BuildLevel) ~= "function" then return nil end + local plan = MTH_TrainerPlan_BuildLevel("pet", level, { honored = honored and true or false, includeKnown = preview }) + local growlPlan = { + rows = {}, + unlearned = { count = 0, cost = 0 }, + } + for _, entry in ipairs(plan.rows or {}) do + if tostring(entry.row and entry.row.name or "") == "Growl" then + table.insert(growlPlan.rows, entry) + growlPlan.unlearned.count = growlPlan.unlearned.count + 1 + growlPlan.unlearned.cost = growlPlan.unlearned.cost + (tonumber(entry.price) or 0) + end + end + return growlPlan +end + +local function MTH_LT_HandlePetGrowlLevel(petName, level) + if level < 10 or level > 60 or math.mod(level, 10) ~= 0 then return end + local rank = MTH_LT_GrowlRankForLevel(level) + local known = type(MTH_HT_GetSpellInfo) == "function" and MTH_HT_GetSpellInfo("Growl") or nil + if known and (tonumber(known.rankNum) or 0) >= rank then + if type(MTH_Growl_OfferForTamedPet) == "function" then + MTH_Growl_OfferForTamedPet(petName, level) + end + elseif MTH_LT_MessageEnabled("petLevelUp", true) then + MTH:Print(petName .. " can now learn Growl Rank " .. tostring(rank) .. ". Visit a Pet Trainer to learn it.") + end +end + +local function MTH_LT_PrintSpellList(plan) + local line = "" + local isFirstLine = true + for _, entry in ipairs(plan.rows or {}) do + local row = entry.row or {} + local label = tostring(row.name or "Unknown") + if row.rank and row.rank ~= "" then label = label .. " " .. tostring(row.rank) end + if line == "" then + line = label + elseif string.len(line) + string.len(label) + 2 > 92 then + MTH:Print((isFirstLine and "New spells available: " or "And also: ") .. line) + isFirstLine = false + line = label + else + line = line .. ", " .. label + end + end + if line ~= "" then MTH:Print((isFirstLine and "New spells available: " or "And also: ") .. line) end +end + +local function MTH_LT_AnnounceTraining(kind, level, eventKind, preview) + if type(MTH_TrainerPlan_BuildLevel) ~= "function" then return end + local base = MTH_TrainerPlan_BuildLevel(kind, level, { honored = false, includeKnown = preview }) + local honored = MTH_TrainerPlan_BuildLevel(kind, level, { honored = true, includeKnown = preview }) + if base.unlearned.count <= 0 then + if kind == "hunter" and MTH_LT_MessageEnabled("hunterLevelUp", true) then + MTH:Print("Congratz! You hit level " .. tostring(level) .. ".") + end + if preview then MTH:Print("Level test: no " .. kind .. " trainer spells in the 1.18.1 catalog at level " .. tostring(level) .. ".") end + return + end + local headline = MTH_LT_GetHeadline(base) + local headlineText = headline and headline.row and tostring(headline.row.name or "") or "New training" + if headline and headline.row and headline.row.rank and headline.row.rank ~= "" then headlineText = headlineText .. " " .. headline.row.rank end + local isLevelCap = kind == "hunter" and level == 60 + if isLevelCap then headlineText = "The hunt is yours" end + local isPetGrowlMilestone = kind == "pet" and level >= 10 and level <= 60 and math.mod(level, 10) == 0 + if isPetGrowlMilestone then headlineText = "Growl Rank " .. tostring(MTH_LT_GrowlRankForLevel(level)) end + if kind == "hunter" and not isLevelCap then headlineText = nil end + if MTH_LT_MessageEnabled(eventKind == "hunter" and "hunterLevelUp" or "petLevelUp", true) then + if kind == "hunter" then + MTH:Print("Congratz! You hit level " .. tostring(level) .. ".") + MTH:Print(MTH_LT_HunterLines[level] or "New training is available.") + MTH:Print("The spells you can learn will cost you " .. MTH_LT_FormatCost(base.unlearned.cost) + .. ", but only " .. MTH_LT_FormatCost(honored.unlearned.cost) .. " if you are Honored with the master.") + MTH_LT_PrintSpellList(base) + else + local displayPlan = isPetGrowlMilestone and MTH_LT_BuildPetGrowlPlan(level, preview) or base + local displayHonoredPlan = isPetGrowlMilestone and MTH_LT_BuildPetGrowlPlan(level, preview, true) or honored + if displayPlan and displayPlan.unlearned.count > 0 then + MTH:Print("It can learn " .. MTH_LT_FormatRows(displayPlan)) + MTH:Print("Trainer cost: Base " .. MTH_LT_FormatCost(displayPlan.unlearned.cost) .. " | Honored " .. MTH_LT_FormatCost(displayHonoredPlan.unlearned.cost)) + end + end + end + local isMilestone = kind == "hunter" and (level == 10 or level == 20 or level == 30 or level == 40 or level == 50 or level == 60) + local flag = isMilestone and "level.huntermilestone" or (kind == "hunter" and "level.hunter" or "level.pet") + if type(MTH_FX_IsEnabled) == "function" and MTH_FX_IsEnabled(flag) and type(MTH_Celebrate) == "function" then + local subtitle = kind == "hunter" and (MTH_LT_HunterLines[level] or "New training is available.") or "New pet training is available. The trainer wants coin." + if isMilestone and type(MTH_CelebrateBlackHole) == "function" then + MTH_CelebrateBlackHole(isLevelCap and headlineText or "Level " .. tostring(level), subtitle) + else + local title = headlineText and ("Level " .. tostring(level) .. " - " .. headlineText) or "Level " .. tostring(level) + MTH_Celebrate(title, subtitle, "fanfare", 5) + end + end +end + +local function MTH_LT_HandleHunterLevel(level) + local store = MTH_LT_Store() + local previous = tonumber(store.hunterLevel) + store.hunterLevel = level + if previous and level > previous then + MTH_LT_AnnounceTraining("hunter", level, "hunter") + end +end + +local function MTH_LT_GetPetState() + if type(UnitExists) ~= "function" or not UnitExists("pet") then return nil end + local level = type(UnitLevel) == "function" and tonumber(UnitLevel("pet")) or nil + local name = type(UnitName) == "function" and UnitName("pet") or "Your pet" + if not level or level <= 0 then return nil end + -- Loyalty is a localized name via GetPetLoyalty(); reuse the pet service which + -- parses it (plus UI fallbacks) into a numeric 1-6 level. + local loyalty = nil + if type(MTH_GetCurrentPetInfo) == "function" then + local info = MTH_GetCurrentPetInfo() + if type(info) == "table" then loyalty = tonumber(info.loyaltyLevel) or nil end + end + return { key = tostring(name or "pet"), name = tostring(name or "Your pet"), level = level, loyalty = loyalty } +end + +local function MTH_LT_HandlePetState() + MTH_LT_PendingPetCheck = false + local state = MTH_LT_GetPetState() + if not state then return end + local store = MTH_LT_Store() + local pet = store.pet + if type(pet) ~= "table" or pet.key ~= state.key then + store.pet = state + return + end + if state.level > (tonumber(pet.level) or 0) then + local line = MTH_LT_NextLine(store, "petLine", MTH_LT_PetLines) + if MTH_LT_MessageEnabled("petLevelUp", true) then MTH:Print(state.name .. " reached level " .. state.level .. ". " .. line) end + MTH_LT_AnnounceTraining("pet", state.level, "pet") + MTH_LT_HandlePetGrowlLevel(state.name, state.level) + end + if state.loyalty and state.loyalty > 1 and state.loyalty > (tonumber(pet.loyalty) or 0) then + local line = MTH_LT_LoyaltyLines[state.loyalty] or "The food arrangement appears to be working." + if MTH_LT_MessageEnabled("petLoyaltyUp", true) then MTH:Print(state.name .. " reached Loyalty Level " .. state.loyalty .. ". " .. line) end + if type(MTH_FX_IsEnabled) == "function" and MTH_FX_IsEnabled("level.loyalty") then + local title = state.name .. " - Loyalty " .. state.loyalty + if state.loyalty == 6 and type(MTH_CelebrateBlackHole) == "function" then + MTH_CelebrateBlackHole(title, line) + elseif type(MTH_Celebrate) == "function" then + MTH_Celebrate(title, line, "fanfare") + end + end + end + store.pet = state +end + +local function MTH_LT_RequestPetCheck() + if MTH_LT_PendingPetCheck or not MTH_LT_Frame then return end + MTH_LT_PendingPetCheck = true + MTH_LT_Frame.elapsed = 0 + MTH_LT_Frame:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + (arg1 or 0) + if this.elapsed >= 0.35 then + this:SetScript("OnUpdate", nil) + MTH_LT_HandlePetState() + end + end) +end + +function MTH_LevelTracker_Test(kind, level) + if kind == "hunter" or kind == "pet" then + local targetLevel = tonumber(level) or ((kind == "hunter") and (tonumber(UnitLevel("player")) or 1) or 1) + MTH:Print("Level test: previewing " .. kind .. " trainer spells at level " .. tostring(targetLevel) .. ".") + MTH_LT_AnnounceTraining(kind, targetLevel, kind, true) + return true + end + if kind == "loyalty" then + local state = MTH_LT_GetPetState() + local loyalty = tonumber(level) or (state and state.loyalty) or 1 + if loyalty <= 1 then + MTH:Print("Pet Loyalty starts at level 1 when you tame a pet. Test levels 2 through 6.") + return false + end + local petName = state and state.name or "Your pet" + local line = MTH_LT_LoyaltyLines[loyalty] or "The food arrangement appears to be working." + MTH:Print(petName .. " reached Loyalty Level " .. tostring(loyalty) .. ". " .. line) + local title = petName .. " - Loyalty " .. tostring(loyalty) + if loyalty == 6 and type(MTH_CelebrateBlackHole) == "function" then + MTH_CelebrateBlackHole(title, line) + elseif type(MTH_Celebrate) == "function" then + MTH_Celebrate(title, line, "fanfare") + end + return true + end + return false +end + +function MTH_LevelTracker_Initialize() + if MTH_LT_Frame then return end + MTH_LT_Frame = CreateFrame("Frame", "MTH_LevelTracker") + MTH_LT_Frame:RegisterEvent("PLAYER_ENTERING_WORLD") + MTH_LT_Frame:RegisterEvent("PLAYER_LEVEL_UP") + MTH_LT_Frame:RegisterEvent("UNIT_LEVEL") + MTH_LT_Frame:RegisterEvent("UNIT_PET") + MTH_LT_Frame:RegisterEvent("PET_BAR_UPDATE") + MTH_LT_Frame:RegisterEvent("UNIT_HAPPINESS") + MTH_LT_Frame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + local level = type(UnitLevel) == "function" and tonumber(UnitLevel("player")) or nil + if level then MTH_LT_Store().hunterLevel = level end + MTH_LT_RequestPetCheck() + elseif event == "PLAYER_LEVEL_UP" then + MTH_LT_HandleHunterLevel(tonumber(arg1) or tonumber(UnitLevel("player")) or 1) + elseif event == "UNIT_LEVEL" and arg1 == "pet" then + MTH_LT_RequestPetCheck() + elseif event == "UNIT_PET" and arg1 == "player" then + MTH_LT_RequestPetCheck() + elseif event == "PET_BAR_UPDATE" then + MTH_LT_RequestPetCheck() + elseif event == "UNIT_HAPPINESS" then + MTH_LT_RequestPetCheck() + end + end) +end + +MTH_LevelTracker_Initialize() \ No newline at end of file diff --git a/api/core-scan-beasttraining.lua b/api/core-scan-beasttraining.lua index acda272..6fb0a53 100644 --- a/api/core-scan-beasttraining.lua +++ b/api/core-scan-beasttraining.lua @@ -168,7 +168,10 @@ function MTH_PT_HasBeastTrainingRows() end local function MTH_PT_DebugLog(line) - return + if line == nil then return end + if MTH_DebugFrame and type(MTH_DebugFrame.AddInfo) == "function" then + MTH_DebugFrame:AddInfo("[BeastTraining] " .. tostring(line)) + end end local function MTH_PT_IsScanMessageEnabled() @@ -335,6 +338,39 @@ local function MTH_PT_IsHunterTokenKnown(token) return false end +-- Rank-strict variant of MTH_PT_IsHunterTokenKnown: only the EXACT token counts, with +-- no fall back to the base ability. Knowing Charge (Rank 1) must NOT make Charge (Rank 2) +-- look already-learned, otherwise learning a new rank is never recorded or announced. +local function MTH_PT_IsExactTokenKnown(token) + if token == nil or token == "" then + return false + end + + local store = MTH_PT_GetStore() + if store.hunterKnownMap and store.hunterKnownMap[token] == true then + return true + end + if store.spellMap and store.spellMap[token] and store.spellMap[token].isKnown == true then + return true + end + return false +end + +-- Authoritative, rank-strict check for whether the HUNTER has learned a pet ability at a +-- given rank. Consults both hunterKnownMap and spellMap, so callers (e.g. the tooltip hint) +-- stay correct even when a full Beast Training rescan has not yet rebuilt spellMap rows. +function MTH_IsPetAbilityKnownByHunter(abilityName, rankNumber) + local ability = MTH_PT_NormalizeName(abilityName) + if ability == "" then + return false + end + local token = MTH_PT_MakeTokenFromAbilityRank(ability, rankNumber) + if token == "" then + return false + end + return MTH_PT_IsExactTokenKnown(token) +end + local function MTH_PT_ProcessLearnSystemMessage(rawMessage) local message = tostring(rawMessage or "") if message == "" then return false end @@ -348,6 +384,10 @@ local function MTH_PT_ProcessLearnSystemMessage(rawMessage) _, _, abilityName, rankText = string.find(message, "^[Yy]ou have learned a new spell:%s*(.-)%s*[Rr]ank%s*(%d+)%.?$") end if not abilityName or not rankText then + -- Only trace messages that look like a learn line, to avoid flooding the log. + if MTH_PT_DebugLog and string.find(message, "[Ll]earned") then + MTH_PT_DebugLog("learn-msg parse FAILED: '" .. tostring(message) .. "'") + end return false end @@ -368,7 +408,7 @@ local function MTH_PT_ProcessLearnSystemMessage(rawMessage) local token = MTH_PT_MakeTokenFromAbilityRank(abilityName, rankNumber) if token == "" then return false end - local alreadyKnown = MTH_PT_IsHunterTokenKnown(token) + local alreadyKnown = MTH_PT_IsExactTokenKnown(token) local changed = false if not alreadyKnown then changed = MTH_PT_MarkHunterKnownToken(token) @@ -379,6 +419,12 @@ local function MTH_PT_ProcessLearnSystemMessage(rawMessage) petRecorded = MTH_PETS_RecordCurrentPetLearnedAbility(abilityName, rankNumber, "beasttraining:learn-msg") and true or false end + if MTH_PT_DebugLog then + MTH_PT_DebugLog("learn-msg matched: ability='" .. tostring(abilityName) .. "' rank=" .. tostring(rankNumber) + .. " token='" .. tostring(token) .. "' alreadyKnown=" .. tostring(alreadyKnown) + .. " changed=" .. tostring(changed) .. " petRecorded=" .. tostring(petRecorded)) + end + if changed or petRecorded then if MTH_PT_DebugLog then MTH_PT_DebugLog("learn-msg captured: source='you have learned a new spell' ability='" .. tostring(abilityName) .. "' rank=" .. tostring(rankNumber) .. " token='" .. tostring(token) .. "' changed=" .. tostring(changed) .. " petRecorded=" .. tostring(petRecorded)) @@ -399,6 +445,9 @@ local function MTH_PT_ProcessLearnSystemMessage(rawMessage) if changed then MTH_PT_ApplyScan("learn-msg") end + if changed and type(MTH_CelebratePetAbility) == "function" then + MTH_CelebratePetAbility(label) + end if type(MTH_PSP_RequestScan) == "function" then MTH_PSP_RequestScan("train-learn-msg") end @@ -656,6 +705,24 @@ MTH_PT_IsPetBookVisible = function() return false end +-- Cheap content signature of the trainer's craft rows, so repeated reads of the +-- same list (multiple CRAFT_UPDATE / open events) don't each pay for a full, +-- datastore-wide re-scan (that pile-up was freezing the client). +local MTH_PT_LastScanSignature = nil +local function MTH_PT_ComputeCraftSignature() + local getNumCrafts = MTH_PT_GetGlobal("GetNumCrafts") + local getCraftInfo = MTH_PT_GetGlobal("GetCraftInfo") + if type(getNumCrafts) ~= "function" or type(getCraftInfo) ~= "function" then return nil end + local total = tonumber(getNumCrafts()) or 0 + if total <= 0 then return nil end + local parts = { tostring(total) } + for i = 1, total do + local name, sub, ctype = getCraftInfo(i) + table.insert(parts, tostring(name or "") .. "~" .. tostring(sub or "") .. "~" .. tostring(ctype or "")) + end + return table.concat(parts, "|") +end + local function MTH_PT_RunDoubleScan(triggerBase) local triggerText = tostring(triggerBase or "") local feedbackOnOpen = ( @@ -665,6 +732,10 @@ local function MTH_PT_RunDoubleScan(triggerBase) or triggerText == "beast-training-rows-open" or triggerText == "petbook-visible-poll" ) + -- Stay quiet while Growl auto-teach is driving the trainer window. + if feedbackOnOpen and type(MTH_Growl_IsTeaching) == "function" and MTH_Growl_IsTeaching() then + feedbackOnOpen = false + end if not MTH_PT_ShouldScanNow() then if MTH_PT_DebugLog then MTH_PT_DebugLog("scan skipped: no beast-training rows, trigger=" .. tostring(triggerBase or "")) @@ -675,9 +746,15 @@ local function MTH_PT_RunDoubleScan(triggerBase) return false end + local signature = MTH_PT_ComputeCraftSignature() + if signature and signature == MTH_PT_LastScanSignature then + if MTH_PT_DebugLog then MTH_PT_DebugLog("scan skipped (unchanged signature): trigger=" .. triggerText) end + return true + end local t0 = time() local ok, added, baselines, ranks = MTH_PT_ApplyScan(triggerText .. ":single") local t1 = time() + if ok and signature then MTH_PT_LastScanSignature = signature end if MTH_PT_DebugLog then MTH_PT_DebugLog("scan complete: trigger=" .. tostring(triggerBase or "") .. " ok=" .. tostring(ok and true or false) .. " dt=" .. tostring((t1 or 0) - (t0 or 0)) .. "s") @@ -832,6 +909,10 @@ function MTH_PT_InitService() MTH_PT_RunDoubleScanThrottled("beast-training-rows-present", 8) end + if (not hasRows) and MTH_PT_TrainingRowsWerePresent then + -- Trainer closed: allow one fresh scan the next time it opens. + MTH_PT_LastScanSignature = nil + end MTH_PT_TrainingRowsWerePresent = hasRows MTH_PT_LastTrainingRowCount = rowCount diff --git a/api/core-scan-stablemaster.lua b/api/core-scan-stablemaster.lua index 196981e..766710e 100644 --- a/api/core-scan-stablemaster.lua +++ b/api/core-scan-stablemaster.lua @@ -2,7 +2,7 @@ if not MTH then error("MetaHunt core framework missing: api/core-framework.lua must load before api/core-scan-stablemaster.lua") end -local MTH_PETS_SCHEMA_VERSION = 2 +local MTH_PETS_SCHEMA_VERSION = 3 local MTH_PETS_CORE_HOOK_BOUNDARY_KEY = "core-pet-rename-hook" local MTH_ST_FULL_DEBUG_TRACE = false MTH_PETS_TRACE_CONSISTENCY = false @@ -914,6 +914,21 @@ local function MTH_PETS_ParseCreatureIdFromGuid(guid) return nil end +-- Canonical pet key: the pet GUID with its low 16 bits (last 4 hex chars) zeroed. +-- On this core the low 16 bits change every session while the high portion stays +-- stable per physical pet, so the full GUID cannot be used as a durable identity +-- key. The canonical key is that durable identity, derived purely from the GUID. +function MTH_PETS_CanonicalKey(guid) + if type(guid) ~= "string" or guid == "" then + return nil + end + local body = string.gsub(guid, "^0[xX]", "") + if string.len(body) < 5 then + return nil + end + return "0x" .. string.sub(body, 1, string.len(body) - 4) .. "0000" +end + local function MTH_PETS_ParseBeastLevelBounds(levelField) local levelText = tostring(levelField or "") if levelText == "" then @@ -1252,10 +1267,11 @@ local function MTH_PETS_ResolveConfirmedTameBeastId(source, snapshot) end local function MTH_PETS_MakeSignature(name, family, level) + -- Level intentionally excluded: it mutates on level-up, so baking it into the + -- identity signature made the same pet look like a new one after each level. local cleanName = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(name)) local cleanFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(family)) - local numericLevel = tonumber(level) or 0 - return cleanName .. "|" .. cleanFamily .. "|" .. tostring(numericLevel) + return cleanName .. "|" .. cleanFamily end local function MTH_PETS_RowHasAnyTameMetadata(row) @@ -1546,11 +1562,14 @@ local function MTH_PETS_RepairActiveRowsByGuid(pets) local groupedByGuid = {} for petId, row in pairs(pets.petStore.activeById) do if type(row) == "table" and type(row.guid) == "string" and row.guid ~= "" then - local guid = row.guid - if type(groupedByGuid[guid]) ~= "table" then - groupedByGuid[guid] = {} + -- Group by the CANONICAL key, not the full guid: the full guid is volatile + -- across sessions, so grouping on it never catches cross-session duplicates + -- of the same physical pet (the root cause of the duplicate-row bug). + local key = MTH_PETS_CanonicalKey(row.guid) or row.guid + if type(groupedByGuid[key]) ~= "table" then + groupedByGuid[key] = {} end - table.insert(groupedByGuid[guid], tostring(petId)) + table.insert(groupedByGuid[key], tostring(petId)) end end @@ -1627,6 +1646,9 @@ local function MTH_PETS_EnsurePetStoreSchema(pets) row.tameVerified = true end end + -- Re-sign level-free every load so the signature index can never retain a + -- level-baked key from older data. + row.signature = MTH_PETS_MakeSignature(row.name, row.family) if type(row.signature) == "string" and row.signature ~= "" then if type(petStore.signatureIndex[row.signature]) ~= "table" then petStore.signatureIndex[row.signature] = {} @@ -1634,7 +1656,12 @@ local function MTH_PETS_EnsurePetStoreSchema(pets) table.insert(petStore.signatureIndex[row.signature], resolvedPetId) end if type(row.guid) == "string" and row.guid ~= "" then - petStore.guidIndex[row.guid] = resolvedPetId + petStore.guidIndex[MTH_PETS_CanonicalKey(row.guid) or row.guid] = resolvedPetId + end + -- beastId is mislabeled (per-pet number, not creature id); expose petNumber + -- additively without breaking existing readers. + if row.beastId ~= nil and row.petNumber == nil then + row.petNumber = row.beastId end if row.loyaltyLevel == nil and type(row.loyalty) == "string" then row.loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(row.loyalty) @@ -1800,8 +1827,16 @@ local function MTH_PETS_ApplyCurrentPetFromActiveRow(cp, petId, row) end end +-- Forward declaration: the one-shot orphan cleanup is defined later (it reuses +-- MTH_PETS_MoveToHistory, declared further down), but MTH_PETS_EnsureSchema needs +-- to reference it here. By call time (a load-time event) it is assigned. +local MTH_PETS_ArchiveOrphanActiveRows + local function MTH_PETS_EnsureSchema(pets) local now = time() + -- Capture BEFORE the stamp block rewrites schemaVersion, so we know whether the + -- one-time v3 orphan cleanup still needs to run for this character. + local needsOrphanCleanup = (tonumber(pets.schemaVersion) or 0) < 3 if pets.schemaVersion ~= MTH_PETS_SCHEMA_VERSION then pets.schemaVersion = MTH_PETS_SCHEMA_VERSION pets.schemaMigratedAt = now @@ -1810,7 +1845,7 @@ local function MTH_PETS_EnsureSchema(pets) end pets.schemaMigration.version = MTH_PETS_SCHEMA_VERSION pets.schemaMigration.at = now - pets.schemaMigration.note = "beta canonical store" + pets.schemaMigration.note = "canonical identity keys + orphan cleanup" end if pets.updatedAt == nil then pets.updatedAt = 0 @@ -1839,6 +1874,11 @@ local function MTH_PETS_EnsureSchema(pets) end MTH_PETS_EnsureCurrentPetSchema(pets) MTH_PETS_EnsurePetStoreSchema(pets) + -- After dedup + index rebuild (done inside EnsurePetStoreSchema), sweep legacy + -- orphan rows (active but neither the current pet nor stabled) into history once. + if needsOrphanCleanup and type(MTH_PETS_ArchiveOrphanActiveRows) == "function" then + MTH_PETS_ArchiveOrphanActiveRows(pets, "migration-recovered") + end end local function MTH_PETS_MarkStableVisited(pets, source) @@ -2067,19 +2107,22 @@ local function MTH_PETS_SelectPetIdBySnapshot(store, snapshot, previousPetId) if previousPetId and store.activeById[previousPetId] then local previousRow = store.activeById[previousPetId] - if previousRow.guid and snapshot.guid and previousRow.guid == snapshot.guid then + if previousRow.guid and snapshot.guid + and MTH_PETS_CanonicalKey(previousRow.guid) == MTH_PETS_CanonicalKey(snapshot.guid) then return previousPetId end end - if snapshot.guid and store.guidIndex[snapshot.guid] and store.activeById[store.guidIndex[snapshot.guid]] then - return store.guidIndex[snapshot.guid] + local snapshotKey = snapshot.guid and MTH_PETS_CanonicalKey(snapshot.guid) or nil + if snapshotKey and store.guidIndex[snapshotKey] and store.activeById[store.guidIndex[snapshotKey]] then + return store.guidIndex[snapshotKey] end - if snapshot.guid and snapshot.guid ~= "" then + if snapshotKey then for petId, row in pairs(store.activeById or {}) do - if type(row) == "table" and row.guid == snapshot.guid then - store.guidIndex[snapshot.guid] = tostring(petId) + if type(row) == "table" and type(row.guid) == "string" + and MTH_PETS_CanonicalKey(row.guid) == snapshotKey then + store.guidIndex[snapshotKey] = tostring(petId) return tostring(petId) end end @@ -2240,16 +2283,18 @@ local function MTH_PETS_UpsertActivePetFromSnapshot(pets, snapshot, source, opti petStore.stableSlotIndex[row.stableSlot] = nil end row.stableSlot = nil - if row.guid and row.guid ~= snapshot.guid and petStore.guidIndex[row.guid] == petId then - petStore.guidIndex[row.guid] = nil + local oldGuidKey = row.guid and MTH_PETS_CanonicalKey(row.guid) or nil + local newGuidKey = snapshot.guid and MTH_PETS_CanonicalKey(snapshot.guid) or nil + if oldGuidKey and oldGuidKey ~= newGuidKey and petStore.guidIndex[oldGuidKey] == petId then + petStore.guidIndex[oldGuidKey] = nil end local context = MTH_PETS_CaptureContext() MTH_PETS_ApplySnapshotToRow(row, snapshot, source, context) row.firstSeen = row.firstSeen or time() - if snapshot.guid and snapshot.guid ~= "" then - petStore.guidIndex[snapshot.guid] = petId + if newGuidKey then + petStore.guidIndex[newGuidKey] = petId end if snapshot.signature and snapshot.signature ~= "" then MTH_PETS_AddSignatureIndex(petStore, snapshot.signature, petId) @@ -2573,8 +2618,9 @@ local function MTH_PETS_MoveToHistory(pets, petId, source, context, reason) if row.signature then MTH_PETS_RemoveSignatureIndex(petStore, row.signature, resolvedPetId) end - if row.guid and petStore.guidIndex[row.guid] == resolvedPetId then - petStore.guidIndex[row.guid] = nil + local guidKey = row.guid and MTH_PETS_CanonicalKey(row.guid) or nil + if guidKey and petStore.guidIndex[guidKey] == resolvedPetId then + petStore.guidIndex[guidKey] = nil end if row.stableSlot and petStore.stableSlotIndex[row.stableSlot] == resolvedPetId then petStore.stableSlotIndex[row.stableSlot] = nil @@ -2597,6 +2643,56 @@ local function MTH_PETS_MoveToHistory(pets, petId, source, context, reason) return true end +-- One-shot legacy cleanup (v3 migration): move "orphan" active rows into history. +-- An orphan is an active row that is neither the current pet nor associated with a +-- stable slot in any way. In normal play a pet is always either summoned or stabled, +-- so such rows are abandoned pets that older code failed to archive. They are moved +-- (not deleted): all tame history is preserved. Assigned to the forward-declared +-- local so MTH_PETS_EnsureSchema (defined earlier) can call it. +function MTH_PETS_ArchiveOrphanActiveRows(pets, reason) + local petStore = type(pets) == "table" and pets.petStore or nil + if type(petStore) ~= "table" or type(petStore.activeById) ~= "table" then + return 0 + end + local currentId = tostring(pets.currentPetId or petStore.activeCurrentId or "") + local orphanIds = {} + for petId, row in pairs(petStore.activeById) do + if type(row) == "table" then + local id = tostring(petId) + local isCurrent = (currentId ~= "" and id == currentId) + local inSlotIndex = false + if type(petStore.stableSlotIndex) == "table" then + for _, mappedId in pairs(petStore.stableSlotIndex) do + if tostring(mappedId or "") == id then + inSlotIndex = true + break + end + end + end + local hasStableSlot = tonumber(row.stableSlot) and tonumber(row.stableSlot) > 0 + local hasStableInfo = type(row.stableInfo) == "table" + if not isCurrent and not inSlotIndex and not hasStableSlot and not hasStableInfo then + table.insert(orphanIds, id) + end + end + end + local archived = 0 + for i = 1, table.getn(orphanIds) do + local id = orphanIds[i] + local row = petStore.activeById[id] + if type(row) == "table" then + row.migrationNote = "Auto-archived by 2.0 pet migration (orphaned active row)." + if MTH_PETS_MoveToHistory(pets, id, "migration-v3", nil, reason or "migration-recovered") then + archived = archived + 1 + end + end + end + if archived > 0 and MTH and MTH.DebugPrint then + MTH:DebugPrint("Pet migration: archived " .. tostring(archived) .. " orphan pet row(s) to history.") + end + return archived +end + function MTH_PETS_RecordPetAbandon(source, explicitName) local pets = MTH_PETS_GetRootStore() if type(pets) ~= "table" then @@ -2695,6 +2791,9 @@ function MTH_PETS_RecordPetRunaway(source, rawMessage) if MTH:IsMessageEnabled("petRanAway", true) then MTH:Print("YOUR PET " .. tostring(displayName) .. " HAS RUNAWAY FOREVER :-( Try to feed it better next time.") end + if type(MTH_CelebrateRunaway) == "function" then + MTH_CelebrateRunaway(displayName) + end end return true @@ -3005,6 +3104,9 @@ function MTH_PETS_RefreshCurrentPet() at = now, context = eventContext, }) + if eventType == "pet-tamed" and type(MTH_Growl_OfferForTamedPet) == "function" then + MTH_Growl_OfferForTamedPet(snapshot.name, snapshot.level) + end end end @@ -3634,6 +3736,9 @@ local function MTH_PETS_HandleUnitPetTransition(source) context = MTH_PETS_CaptureContext(), }) end + if eventType == "pet-tamed" and type(MTH_Growl_OfferForTamedPet) == "function" then + MTH_Growl_OfferForTamedPet(snapshot.name, snapshot.level) + end end if not hadPet and hasPendingTame then MTH_PETS_LogTame("Acquire completed; clearing pending tame attempt") @@ -3952,6 +4057,43 @@ function MTH_CommandPetsReset() return true end +-- Dev-only: force the v3 pet-store migration to run again (dedup by canonical key, +-- level-free re-sign, index rebuild, orphan cleanup). Resets the pet schema flag +-- and re-runs EnsureSchema. Does NOT touch MTH.version, so nothing is broadcast. +-- Non-destructive: history is preserved. Invoke via /mth dev petmigrate or /run. +function MTH_CommandPetsMigrate() + local pets = MTH_PETS_GetRootStore() + if type(pets) ~= "table" then + MTH:Print("Pet migration failed: datastore unavailable.") + return false + end + local store = MTH_PETS_GetStoreTables(pets) + local function countTable(t) + local n = 0 + if type(t) == "table" then for _ in pairs(t) do n = n + 1 end end + return n + end + local beforeActive = countTable(store and store.activeById) + local beforeHistory = countTable(store and store.historyById) + + -- Force the one-shot path to re-run regardless of current stamp. + pets.schemaVersion = 2 + MTH_PETS_EnsureSchema(pets) + + store = MTH_PETS_GetStoreTables(pets) + local afterActive = countTable(store and store.activeById) + local afterHistory = countTable(store and store.historyById) + pets.updatedAt = time() + if type(MTH_CharSavedVariables) == "table" then + MTH_CharSavedVariables.MTH_Pets = pets + MTH_CharSavedVariables.petStore = pets.petStore + end + MTH:Print("Pet migration (v3) complete: active " .. tostring(beforeActive) .. "->" .. tostring(afterActive) + .. ", history " .. tostring(beforeHistory) .. "->" .. tostring(afterHistory) + .. ", schemaVersion=" .. tostring(pets.schemaVersion)) + return true +end + function MTH_CommandPetsDump() local pets = MTH_PETS_GetRootStore() MTH_PETS_RefreshCurrentPet() diff --git a/api/core-scan-trainer.lua b/api/core-scan-trainer.lua new file mode 100644 index 0000000..68b8aab --- /dev/null +++ b/api/core-scan-trainer.lua @@ -0,0 +1,251 @@ +-- MetaHunt: one-time live trainer catalog collector. +-- Developer workflow: /mth trainerscan, then open a Hunter or Pet Trainer. + +local MTH_TS_Frame +local MTH_TS_Armed = false +local MTH_TS_AutoArmed = false + +local function MTH_TS_Log(text) + if MTH_DebugFrame and type(MTH_DebugFrame.AddInfo) == "function" then + MTH_DebugFrame:AddInfo("[TrainerScan] " .. tostring(text)) + end +end + +local function MTH_TS_Call(name, ...) + local fn = getglobal and getglobal(name) or (_G and _G[name]) + if type(fn) ~= "function" then return nil, "missing" end + local ok, a, b, c, d, e, f, g = pcall(fn, unpack(arg)) + if not ok then return nil, "error=" .. tostring(a) end + return { a, b, c, d, e, f, g }, nil +end + +local function MTH_TS_Values(values) + if not values then return "" end + local parts = {} + local i = 1 + while i <= table.getn(values) do + if values[i] ~= nil then + table.insert(parts, tostring(values[i])) + end + i = i + 1 + end + return table.concat(parts, " | ") +end + +local function MTH_TS_TargetName() + if type(UnitName) == "function" then + local name = UnitName("target") + if name and name ~= "" then return name end + end + local title = getglobal and getglobal("TrainerFrameTitleText") or nil + if title and title.GetText then return title:GetText() or "?" end + return "?" +end + +local function MTH_TS_GetLiveStatusStore() + if type(MTH_CharSavedVariables) ~= "table" then MTH_CharSavedVariables = {} end + if type(MTH_CharSavedVariables.trainerLiveStatus) ~= "table" then + MTH_CharSavedVariables.trainerLiveStatus = {} + end + return MTH_CharSavedVariables.trainerLiveStatus +end + +local function MTH_TS_GetCatalogKind(name, rank) + local catalog = MTH_DS_TrainerCatalog + if type(catalog) ~= "table" or type(catalog.byNameRank) ~= "table" then return nil end + local keySuffix = "\031" .. tostring(name or "") .. "\031" .. tostring(rank or "") + if catalog.byNameRank["hunter" .. keySuffix] then return "hunter" end + if catalog.byNameRank["pet" .. keySuffix] then return "pet" end + return nil +end + +function MTH_GetTrainerLiveStatus(kind, name, rank) + local store = type(MTH_CharSavedVariables) == "table" and MTH_CharSavedVariables.trainerLiveStatus or nil + if type(store) ~= "table" then return nil end + return store[tostring(kind or "") .. "\031" .. tostring(name or "") .. "\031" .. tostring(rank or "")] +end + +local function MTH_TS_CollectLiveStatuses() + local countValues = MTH_TS_Call("GetNumTrainerServices") + local total = countValues and tonumber(countValues[1]) or 0 + if total <= 0 then return false end + + local trainerName = MTH_TS_TargetName() + local store = MTH_TS_GetLiveStatusStore() + local observed = 0 + for index = 1, total do + local info = MTH_TS_Call("GetTrainerServiceInfo", index) + local name = info and info[1] or nil + local rank = info and info[2] or "" + local status = info and info[3] or nil + local kind = MTH_TS_GetCatalogKind(name, rank) + if kind and (status == "available" or status == "unavailable" or status == "used") then + local key = kind .. "\031" .. tostring(name) .. "\031" .. tostring(rank) + store[key] = { + status = status, + observedAt = time(), + trainer = trainerName, + } + observed = observed + 1 + end + end + + if observed > 0 then + MTH_TS_Log("Live trainer overlay: " .. tostring(observed) .. " observed catalog row(s) from " .. tostring(trainerName) .. ".") + if MTH and MTH.Print and (not MTH.IsMessageEnabled or MTH:IsMessageEnabled("trainerScan", true)) then + MTH:Print("Trainer scan: " .. tostring(observed) .. " Hunter/Pet spell status" .. (observed == 1 and "" or "es") .. " observed.") + end + return true + end + return false +end + +local function MTH_TS_Collect() + local countValues, countError = MTH_TS_Call("GetNumTrainerServices") + local total = countValues and tonumber(countValues[1]) or 0 + if not total or total <= 0 then + MTH_TS_Log("No trainer services yet. GetNumTrainerServices=" .. tostring(MTH_TS_Values(countValues)) + .. " " .. tostring(countError or "")) + return false + end + + local trainerName = MTH_TS_TargetName() + local capture = { + trainer = trainerName, + capturedAt = time(), + services = {}, + } + MTH_TRAINER_SCAN_LAST = capture + if type(MTH_CharSavedVariables) == "table" then + if type(MTH_CharSavedVariables.trainerCatalogScans) ~= "table" then + MTH_CharSavedVariables.trainerCatalogScans = {} + end + MTH_CharSavedVariables.trainerCatalogScans[trainerName] = capture + end + MTH_TS_Log("=== " .. tostring(capture.trainer) .. " | " .. tostring(total) .. " trainer services ===") + + local index = 1 + while index <= total do + local info, infoError = MTH_TS_Call("GetTrainerServiceInfo", index) + local icon, iconError = MTH_TS_Call("GetTrainerServiceIcon", index) + local cost, costError = MTH_TS_Call("GetTrainerServiceCost", index) + local level, levelError = MTH_TS_Call("GetTrainerServiceLevelReq", index) + local skill, skillError = MTH_TS_Call("GetTrainerServiceSkillReq", index) + local description, descriptionError = MTH_TS_Call("GetTrainerServiceDescription", index) + local reqCountValues = MTH_TS_Call("GetTrainerServiceNumAbilityReq", index) + local reqCount = reqCountValues and tonumber(reqCountValues[1]) or 0 + local requirements = {} + local reqIndex = 1 + while reqIndex <= reqCount do + local requirement, reqError = MTH_TS_Call("GetTrainerServiceAbilityReq", index, reqIndex) + table.insert(requirements, { + name = requirement and requirement[1] or nil, + flag = requirement and requirement[2] or nil, + error = reqError, + }) + reqIndex = reqIndex + 1 + end + + local row = { + index = index, + name = info and info[1] or nil, + rank = info and info[2] or nil, + status = info and info[3] or nil, + expanded = info and info[4] or nil, + icon = icon and icon[1] or nil, + cost = cost and cost[1] or nil, + requiredLevel = level and level[1] or nil, + skillRequirement = skill, + description = description and description[1] or nil, + abilityRequirements = requirements, + } + table.insert(capture.services, row) + + MTH_TS_Log(string.format("%02d name=%s | rank=%s | status=%s | expanded=%s | level=%s | cost=%s | icon=%s", + index, tostring(row.name), tostring(row.rank), tostring(row.status), tostring(row.expanded), + tostring(row.requiredLevel), tostring(row.cost), tostring(row.icon))) + if row.description and row.description ~= "" then + MTH_TS_Log(" description=" .. tostring(row.description)) + end + if table.getn(requirements) > 0 then + local requirementText = {} + local requirementIndex = 1 + while requirementIndex <= table.getn(requirements) do + local requirement = requirements[requirementIndex] + table.insert(requirementText, tostring(requirement.name) + .. " [flag=" .. tostring(requirement.flag) .. "]") + requirementIndex = requirementIndex + 1 + end + MTH_TS_Log(" abilityRequirements=" .. table.concat(requirementText, ", ")) + end + if skill and table.getn(skill) > 0 then + MTH_TS_Log(" skillRequirement=" .. MTH_TS_Values(skill)) + end + if infoError or iconError or costError or levelError or skillError or descriptionError then + MTH_TS_Log(" API=" .. tostring(infoError or "ok") .. ", " .. tostring(iconError or "ok") + .. ", " .. tostring(costError or "ok") .. ", " .. tostring(levelError or "ok") + .. ", " .. tostring(skillError or "ok") .. ", " .. tostring(descriptionError or "ok")) + end + index = index + 1 + end + + MTH_TS_Log("=== Capture complete. " .. tostring(total) + .. " rows retained in MTH_TRAINER_SCAN_LAST and trainerCatalogScans. ===") + return true +end + +local function MTH_TS_EnsureFrame() + if not MTH_TS_Frame then + MTH_TS_Frame = CreateFrame("Frame", "MTH_TrainerCatalogScanner") + MTH_TS_Frame:SetScript("OnEvent", function() + if event == "TRAINER_SHOW" then + MTH_TS_AutoArmed = true + this.elapsed = 0 + this:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + (arg1 or 0) + if this.elapsed < 0.25 then return end + this:SetScript("OnUpdate", nil) + MTH_TS_CollectLiveStatuses() + end) + return + end + if event ~= "TRAINER_LIST_UPDATE" or not (MTH_TS_Armed or MTH_TS_AutoArmed) then return end + this.elapsed = 0 + this:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + (arg1 or 0) + if this.elapsed < 0.25 then return end + this:SetScript("OnUpdate", nil) + local manualArmed = MTH_TS_Armed + MTH_TS_Armed = false + MTH_TS_AutoArmed = false + MTH_TS_CollectLiveStatuses() + if manualArmed and not MTH_TS_Collect() and MTH and MTH.Print then + MTH:Print("Trainer list was empty. Open the trainer again and run /mth trainerscan.") + end + end) + end) + MTH_TS_Frame:RegisterEvent("TRAINER_SHOW") + MTH_TS_Frame:RegisterEvent("TRAINER_LIST_UPDATE") + end +end + +function MTH_CommandTrainerScan() + if MTH_DebugFrame and type(MTH_DebugFrame.Show) == "function" then MTH_DebugFrame:Show() end + local currentRows = MTH_TS_Call("GetNumTrainerServices") + if currentRows and (tonumber(currentRows[1]) or 0) > 0 then + MTH_TS_Collect() + if MTH and MTH.Print then + MTH:Print("Trainer catalog captured from the open trainer window.") + end + return + end + + MTH_TS_EnsureFrame() + + MTH_TS_Armed = true + if MTH and MTH.Print then + MTH:Print("Trainer catalog scan armed. Open a Hunter Trainer or Pet Trainer; results will appear in the debug window.") + end +end + +MTH_TS_EnsureFrame() \ No newline at end of file diff --git a/api/core.lua b/api/core.lua index 4308a8e..b13f085 100644 --- a/api/core.lua +++ b/api/core.lua @@ -325,6 +325,57 @@ function SlashCmdList.MTH(msg, editbox) MTH_CommandBook() elseif lowerMsg == "peers" or lowerMsg == "who" then MTH_CommandPeers() + elseif lowerCmd == "trainscan" or lowerCmd == "petscan" then + if type(MTH_CommandTrainScan) == "function" then + MTH_CommandTrainScan() + else + MTH:Print("Train scan is not available.") + end + elseif lowerCmd == "trainerscan" then + if type(MTH_CommandTrainerScan) == "function" then + MTH_CommandTrainerScan() + else + MTH:Print("Trainer catalog scanner is not available.") + end + elseif lowerCmd == "spellscan" then + if type(MTH_HT_RescanSpellbook) ~= "function" then + MTH:Print("Hunter spellbook scanner is not available.") + return + end + MTH_HT_RescanSpellbook() + local mail = type(MTH_HT_GetSpellInfo) == "function" and MTH_HT_GetSpellInfo("Mail") or nil + if mail then + MTH:Print("Hunter spellbook scan: Mail found (rank=" .. tostring(mail.rank or "") .. ", slot=" .. tostring(mail.slot or "?") .. ").") + else + MTH:Print("Hunter spellbook scan: Mail was not returned by the client spellbook API.") + end + elseif lowerCmd == "leveltest" then + local _, _, kind, levelText = string.find(lowerArg or "", "^(%S+)%s*(%d*)") + if type(MTH_LevelTracker_Test) ~= "function" then + MTH:Print("Level tracker is not available.") + elseif not MTH_LevelTracker_Test(kind, tonumber(levelText)) then + MTH:Print("Use /mth leveltest hunter [level], pet [level], or loyalty [level]") + end + elseif lowerCmd == "fx" or lowerCmd == "fireworks" or lowerCmd == "celebrate" then + if type(MTH_CelebratePreview) == "function" then + local style = lowerArg or "" + if style == "" then style = "firework" end + if style == "help" or style == "list" then + MTH:Print("Celebrations: firework, blackhole, shockwave, shatter, all") + elseif not MTH_CelebratePreview(style) then + MTH:Print("Unknown celebration: " .. tostring(style)) + MTH:Print("Use /mth fx help for: firework, blackhole, shockwave, shatter, all") + end + else + -- Self-diagnosing: report which piece failed to load so we can tell + -- a stale UI from a genuine load error. + MTH:Print("Celebration effect is not available. Diagnostics:") + MTH:Print(" PizzaSauce global: " .. type(PizzaSauce)) + MTH:Print(" MTH_Celebrate: " .. type(MTH_Celebrate)) + MTH:Print(" MTH_FX_IsEnabled: " .. type(MTH_FX_IsEnabled)) + MTH:Print(" MTH_SetupAnimationsOptions: " .. type(MTH_SetupAnimationsOptions)) + MTH:Print(" -> If these are 'nil', do /reload (your UI is running code from before the feature was added).") + end elseif lowerMsg == "zoneinfo" then -- Dumps all available zone data for the current zone, for adding to ds-zones / ds-minimap-sizes pcall(SetMapToCurrentZone) @@ -422,6 +473,29 @@ function SlashCmdList.MTH(msg, editbox) MTH:Print("|cffaaffaaArmed.|r Open any vendor, trainer, or stable master to capture their ID.") end end + elseif lowerCmd == "dev" then + if lowerArg == "petmigrate" then + if type(MTH_CommandPetsMigrate) == "function" then + MTH_CommandPetsMigrate() + else + MTH:Print("Pet migration command unavailable.") + end + else + MTH:Print("Dev commands: /mth dev petmigrate") + end + elseif lowerCmd == "growl" then + if type(MTH_Growl_SetEnabled) ~= "function" or type(MTH_Growl_IsEnabled) ~= "function" then + MTH:Print("Growl auto-teach is not available.") + elseif lowerArg == "on" or lowerArg == "enable" then + MTH_Growl_SetEnabled(true) + MTH:Print("Growl auto-teach: |cff00ff00ON|r (offers Growl to newly tamed pets).") + elseif lowerArg == "off" or lowerArg == "disable" then + MTH_Growl_SetEnabled(false) + MTH:Print("Growl auto-teach: |cffff0000OFF|r.") + else + local state = MTH_Growl_IsEnabled() and "|cff00ff00ON|r" or "|cffff0000OFF|r" + MTH:Print("Growl auto-teach is " .. state .. ". Usage: /mth growl on | off") + end elseif lowerCmd == "food" then local sub = lowerArg local function MTH_FoodItemLabel(itemId) diff --git a/api/fx-celebrate.lua b/api/fx-celebrate.lua new file mode 100644 index 0000000..d76b859 --- /dev/null +++ b/api/fx-celebrate.lua @@ -0,0 +1,818 @@ +-- MetaHunt: center-screen celebration effect (message + firework burst). +-- Uses the embedded PizzaSauce animation library. Degrades to a silent no-op +-- when PizzaSauce or the WoW frame API are unavailable (e.g. offline tests). + +-- Capture our own PizzaSauce instance now, before another addon that ships its +-- own copy can overwrite the global. This is the pattern the library requires. +local Sauce = PizzaSauce + +-- Localization helper: resolve a key through MetaHunt's loc system, falling back +-- to the inline English default. Every user-facing string is keyed so the enUS +-- strings can be harvested later; the inline default is the working English. +local function MTH_FX_L(key, default) + if MTH and type(MTH.GetLocalization) == "function" then + return MTH:GetLocalization(key, default) + end + return default or key +end + +-- --------------------------------------------------------------------------- +-- Animation settings (shared by the effect code and the options "Animations" tab) +-- --------------------------------------------------------------------------- +-- Sections shown, in order, in the options panel. `titleKey` localizes `title`. +MTH_FX_ANIM_SECTIONS = { + { id = "pet", title = "Pet", titleKey = "ANIM_SECTION_PET" }, + { id = "level", title = "Leveling", titleKey = "ANIM_SECTION_LEVEL" }, + { id = "combat", title = "Combat", titleKey = "ANIM_SECTION_COMBAT" }, + { id = "book", title = "Book", titleKey = "ANIM_SECTION_BOOK" }, + { id = "ui", title = "Interface", titleKey = "ANIM_SECTION_UI" }, +} + +-- Every individual animation the addon can play. Each has a stable `key`, the +-- section it belongs to, a checkbox label, an optional tooltip, and a default. +-- labelKey/tooltipKey localize label/tooltip (resolved at render time). +-- Adding a new animation = one entry here + one MTH_FX_IsEnabled("key") guard. +MTH_FX_ANIM_DEFS = { + { key = "level.hunter", section = "level", default = true, + label = "Celebrate Hunter training levels", + tooltip = "Show a banner and burst when a level unlocks Hunter trainer spells." }, + { key = "level.huntermilestone", section = "level", default = true, + label = "Celebrate Hunter milestones", + tooltip = "Use a large celebration at levels 10, 20, 30, 40, 50, and 60." }, + { key = "level.pet", section = "level", default = true, + label = "Celebrate pet training levels", + tooltip = "Show a banner when your current pet gains a level with Pet Trainer spells." }, + { key = "level.loyalty", section = "level", default = true, + label = "Celebrate pet loyalty gains", + tooltip = "Show a golden banner when your current pet gains a loyalty level." }, + { key = "pet.celebrate", section = "pet", default = true, + label = "Celebrate new pet abilities", labelKey = "ANIM_PET_CELEBRATE", + tooltip = "Play a firework and a banner in the middle of the screen when your pet learns a new ability or a new rank.", + tooltipKey = "ANIM_PET_CELEBRATE_TIP" }, + { key = "pet.runaway", section = "pet", default = true, + label = "Warn when a pet runs away", labelKey = "ANIM_PET_RUNAWAY", + tooltip = "Flash a red, shaking banner in the middle of the screen when one of your pets runs away for good.", + tooltipKey = "ANIM_PET_RUNAWAY_TIP" }, + { key = "pet.happiness", section = "pet", default = true, + label = "Flash zPet on happiness change", labelKey = "ANIM_PET_HAPPINESS", + tooltip = "Briefly flash the pet happiness area green when your pet cheers up, or red when it gets unhappy.", + tooltipKey = "ANIM_PET_HAPPINESS_TIP" }, + { key = "pet.loyaltyup", section = "pet", default = true, + label = "Glow zPet on loyalty gain", labelKey = "ANIM_PET_LOYALTYUP", + tooltip = "Give a short golden glow when your pet gains a loyalty level.", + tooltipKey = "ANIM_PET_LOYALTYUP_TIP" }, + { key = "pet.feed", section = "pet", default = true, + label = "Flash zPet when feeding your pet", labelKey = "ANIM_PET_FEED", + tooltip = "Give a short green flash when Feed-O-Matic feeds your pet.", + tooltipKey = "ANIM_PET_FEED_TIP" }, + { key = "combat.ammoswap", section = "combat", default = true, + label = "Flash zAmmo on Smart Ammo swap", labelKey = "ANIM_COMBAT_AMMOSWAP", + tooltip = "Briefly highlight the zAmmo button when Smart Ammo swaps your ammo.", + tooltipKey = "ANIM_COMBAT_AMMOSWAP_TIP" }, + { key = "combat.ammoout", section = "combat", default = true, + label = "Warn when current ammo runs out", + tooltip = "Show a red warning banner when Smart Ammo equips fallback ammunition." }, + { key = "combat.lowammo", section = "combat", default = true, + label = "Warn when total ammo is low", + tooltip = "Show a warning banner when your total arrows or bullets reach the Smart Ammo threshold." }, + { key = "combat.ammozero", section = "combat", default = true, + label = "Warn when all ammo is gone", + tooltip = "Show a sarcastic warning banner when you have no arrows or bullets left." }, + { key = "combat.rangedswap", section = "combat", default = true, + label = "Flash zRanged on weapon swap", labelKey = "ANIM_COMBAT_RANGEDSWAP", + tooltip = "Briefly highlight the zRanged button when you hot-swap a ranged weapon.", + tooltipKey = "ANIM_COMBAT_RANGEDSWAP_TIP" }, + { key = "combat.mmprocs", section = "combat", default = true, + label = "Pulse MM Widget procs", labelKey = "ANIM_COMBAT_MMPROCS", + tooltip = "Pulse an MM Widget cell when a Lock and Load or Experimental ammo proc lights up.", + tooltipKey = "ANIM_COMBAT_MMPROCS_TIP" }, + { key = "book.open", section = "book", default = true, + label = "Fade in the Hunter's Book", labelKey = "ANIM_BOOK_OPEN", + tooltip = "Fade the Hunter's Book window in when you open it.", + tooltipKey = "ANIM_BOOK_OPEN_TIP" }, + { key = "book.tabs", section = "book", default = true, + label = "Fade Hunter's Book results on tab changes", labelKey = "ANIM_BOOK_TABS", + tooltip = "Fade the results list back in when you switch tabs inside the Hunter's Book.", + tooltipKey = "ANIM_BOOK_TABS_TIP" }, + { key = "book.model", section = "book", default = true, + label = "Fade Hunter's Book beast models", labelKey = "ANIM_BOOK_MODEL", + tooltip = "Fade the beast model viewer in when it loads a new model.", + tooltipKey = "ANIM_BOOK_MODEL_TIP" }, + { key = "book.search", section = "book", default = true, + label = "Fade Hunter's Book search results", labelKey = "ANIM_BOOK_SEARCH", + tooltip = "Fade the results list when your search filter changes.", + tooltipKey = "ANIM_BOOK_SEARCH_TIP" }, + { key = "ui.options", section = "ui", default = true, + label = "Slide MetaHunt Options open", labelKey = "ANIM_UI_OPTIONS", + tooltip = "Slide and fade the MetaHunt options window in when you open it.", + tooltipKey = "ANIM_UI_OPTIONS_TIP" }, + { key = "ui.optiontabs", section = "ui", default = true, + label = "Fade MetaHunt Options tab changes", labelKey = "ANIM_UI_OPTIONTABS", + tooltip = "Fade the settings panel when you switch tabs inside the options window.", + tooltipKey = "ANIM_UI_OPTIONTABS_TIP" }, + { key = "ui.optionsclose", section = "ui", default = true, + label = "Fade MetaHunt windows closed", labelKey = "ANIM_UI_OPTIONSCLOSE", + tooltip = "Fade the options and Hunter's Book windows out when you close them.", + tooltipKey = "ANIM_UI_OPTIONSCLOSE_TIP" }, + { key = "ui.minimap", section = "ui", default = true, + label = "Pulse MetaHunt minimap button on click", labelKey = "ANIM_UI_MINIMAP", + tooltip = "Give the minimap button a quick pulse when you click it.", + tooltipKey = "ANIM_UI_MINIMAP_TIP" }, + { key = "ui.minimapping", section = "ui", default = true, + label = "Ping minimap when locating a target", labelKey = "ANIM_UI_MINIMAPPING", + tooltip = "Send a short ping ripple over the minimap when you locate a tracked NPC.", + tooltipKey = "ANIM_UI_MINIMAPPING_TIP" }, + { key = "ui.zbuttons", section = "ui", default = true, + label = "Fade zButton bars open", labelKey = "ANIM_UI_ZBUTTONS", + tooltip = "Fade the child buttons in when a collapsible zButton bar (pet, tracking, aspects...) expands.", + tooltipKey = "ANIM_UI_ZBUTTONS_TIP" }, + { key = "ui.zbar", section = "ui", default = true, + label = "Fade the zBar in", labelKey = "ANIM_UI_ZBAR", + tooltip = "Fade the unified zBar buttons in when the bar becomes active.", + tooltipKey = "ANIM_UI_ZBAR_TIP" }, + { key = "ui.stable", section = "ui", default = true, + label = "Flash Hunter's Book stable slots", labelKey = "ANIM_UI_STABLE", + tooltip = "Flash a stable slot in the Hunter's Book when a pet is deposited or withdrawn.", + tooltipKey = "ANIM_UI_STABLE_TIP" }, + { key = "ui.mappins", section = "ui", default = true, + label = "Fade World Map markers in", labelKey = "ANIM_UI_MAPPINS", + tooltip = "Fade the world map markers in when you locate a beast, vendor or master.", + tooltipKey = "ANIM_UI_MAPPINS_TIP" }, +} + +local function MTH_FX_Store() + if MTH and type(MTH.GetModuleSavedVariables) == "function" then + return MTH:GetModuleSavedVariables("animations") + end + return nil +end + +local function MTH_FX_DefaultFor(key) + local i = 1 + while i <= table.getn(MTH_FX_ANIM_DEFS) do + if MTH_FX_ANIM_DEFS[i].key == key then + return MTH_FX_ANIM_DEFS[i].default and true or false + end + i = i + 1 + end + return true +end + +function MTH_FX_AnimationsEnabled() + local store = MTH_FX_Store() + if not store or store.enabled == nil then + return true + end + return store.enabled and true or false +end + +function MTH_FX_SetAnimationsEnabled(value) + local store = MTH_FX_Store() + if not store then return end + store.enabled = value and true or false +end + +function MTH_FX_GetFlag(key) + local store = MTH_FX_Store() + if store and store.flags and store.flags[key] ~= nil then + return store.flags[key] and true or false + end + return MTH_FX_DefaultFor(key) +end + +function MTH_FX_SetFlag(key, value) + local store = MTH_FX_Store() + if not store then return end + store.flags = store.flags or {} + store.flags[key] = value and true or false +end + +-- Master gate every animation call site checks: false when animations are +-- globally disabled, or when this specific animation is turned off. +function MTH_FX_IsEnabled(key) + if not MTH_FX_AnimationsEnabled() then + return false + end + if key == nil then + return true + end + return MTH_FX_GetFlag(key) +end + +-- Register a one-shot "ballistic" tween type: a spark that flies out from the +-- center along a velocity vector while gravity pulls it back down. +if Sauce and type(Sauce.RegisterType) == "function" and not Sauce.MTH_HasBallistic then + local ok, err = pcall(function() + Sauce.MTH_HasBallistic = true + Sauce:RegisterType("mth_ballistic", { + init = function(target, from) + return from or { 0, 0 } + end, + apply = function(target, from, to, t, tween) + local dur = tween.dur or 1 + local elapsed = t * dur + local vx = tween.vx or 0 + local vy = tween.vy or 0 + local gravity = tween.gravity or 0 + local x = from[1] + vx * elapsed + local y = from[2] + vy * elapsed - 0.5 * gravity * elapsed * elapsed + local anchor = tween.anchor or UIParent + target:ClearAllPoints() + target:SetPoint("CENTER", anchor, "CENTER", x, y) + end, + }) + end) +end + +-- Register a decaying "shake" tween type: oscillates the frame around its own +-- current anchor point, with the amplitude fading to zero over the tween so it +-- always lands back exactly where it started. Used for negative events. +if Sauce and type(Sauce.RegisterType) == "function" and not Sauce.MTH_HasShake then + local ok, err = pcall(function() + Sauce.MTH_HasShake = true + Sauce:RegisterType("mth_shake", { + init = function(target) + local p, rel, rp, x, y = target:GetPoint(1) + return { p or "CENTER", rel, rp or p or "CENTER", x or 0, y or 0 } + end, + apply = function(target, from, to, t, tween) + local mag = tween.magnitude or 8 + local freq = tween.freq or 34 + local decay = 1 - t + local offset = math.sin(t * freq) * mag * decay + target:ClearAllPoints() + target:SetPoint(from[1], from[2], from[3], (from[4] or 0) + offset, from[5] or 0) + end, + }) + end) +end + +if Sauce and type(Sauce.RegisterType) == "function" and not Sauce.MTH_HasSpiral then + local ok, err = pcall(function() + Sauce.MTH_HasSpiral = true + Sauce:RegisterType("mth_spiral", { + init = function(target, from) + return from or 0 + end, + apply = function(target, from, to, t, tween) + local radius = (tween.radius or 0) * (1 - t) + local angle = (tween.startAngle or 0) + (tween.rotations or 1) * 2 * math.pi * t + target:ClearAllPoints() + target:SetPoint("CENTER", tween.anchor or UIParent, "CENTER", + (tween.cx or 0) + math.cos(angle) * radius, + (tween.cy or 0) + math.sin(angle) * radius) + end, + }) + end) +end + +-- Reusable flash: overlays a short additive colour glow on any frame/button and +-- fades it out. Safe on any frame that supports CreateTexture; silent no-op +-- otherwise. The overlay texture is cached on the frame for reuse. +function MTH_FX_Flash(frame, r, g, b, duration) + if not Sauce or not frame then + return + end + if type(frame.CreateTexture) ~= "function" then + return + end + local tex = frame.mthFxFlash + if not tex then + tex = frame:CreateTexture(nil, "OVERLAY") + tex:SetTexture("Interface\\Buttons\\CheckButtonHilight") + tex:SetBlendMode("ADD") + tex:SetAllPoints(frame) + frame.mthFxFlash = tex + end + tex:SetVertexColor(r or 1, g or 1, b or 1) + tex:SetAlpha(0) + tex:Show() + local dur = duration or 0.45 + Sauce:Stop(tex) + Sauce:Sequence({ + Sauce:FadeTo(tex, 0.85, dur * 0.35), + Sauce:FadeTo(tex, 0, dur * 0.65, "inQuad", { + onFinish = function() tex:Hide() end, + }), + }) +end + + +local MTH_FX_NUM_SPARKS = 22 +local MTH_FX_NUM_CONFETTI = 48 +local MTH_FX_NUM_BLACKHOLE = 40 +local MTH_FX_Container +local MTH_FX_MsgFrame +local MTH_FX_TitleFS +local MTH_FX_SubFS +local MTH_FX_Sparks = {} +local MTH_FX_Confetti = {} +local MTH_FX_BlackHole = {} + +-- Palette for the firework sparks (r, g, b), warm celebratory tones. +local MTH_FX_Colors = { + { 1.00, 0.82, 0.00 }, + { 1.00, 0.45, 0.10 }, + { 1.00, 0.20, 0.30 }, + { 0.35, 0.70, 1.00 }, + { 0.55, 1.00, 0.45 }, + { 0.85, 0.50, 1.00 }, +} + +local function MTH_FX_EnsureFrames() + if type(CreateFrame) ~= "function" then + return false + end + if MTH_FX_Container then + return true + end + + MTH_FX_Container = CreateFrame("Frame", "MTH_FX_Container", UIParent) + MTH_FX_Container:SetWidth(1) + MTH_FX_Container:SetHeight(1) + MTH_FX_Container:SetPoint("CENTER", UIParent, "CENTER", 0, 40) + MTH_FX_Container:SetFrameStrata("FULLSCREEN_DIALOG") + MTH_FX_Container:Hide() + + MTH_FX_MsgFrame = CreateFrame("Frame", "MTH_FX_Message", UIParent) + MTH_FX_MsgFrame:SetWidth(512) + MTH_FX_MsgFrame:SetHeight(90) + MTH_FX_MsgFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 80) + MTH_FX_MsgFrame:SetFrameStrata("FULLSCREEN_DIALOG") + + MTH_FX_TitleFS = MTH_FX_MsgFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge") + MTH_FX_TitleFS:SetPoint("CENTER", MTH_FX_MsgFrame, "CENTER", 0, 14) + MTH_FX_TitleFS:SetTextColor(1, 0.82, 0) + + MTH_FX_SubFS = MTH_FX_MsgFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + MTH_FX_SubFS:SetPoint("TOP", MTH_FX_TitleFS, "BOTTOM", 0, -8) + MTH_FX_SubFS:SetTextColor(1, 1, 1) + + MTH_FX_MsgFrame:Hide() + + local i = 1 + while i <= MTH_FX_NUM_SPARKS do + local spark = MTH_FX_Container:CreateTexture(nil, "OVERLAY") + spark:SetTexture("Interface\\Cooldown\\star4") + spark:SetWidth(22) + spark:SetHeight(22) + spark:SetBlendMode("ADD") + spark:SetPoint("CENTER", MTH_FX_Container, "CENTER", 0, 0) + spark:Hide() + MTH_FX_Sparks[i] = spark + i = i + 1 + end + + i = 1 + while i <= MTH_FX_NUM_CONFETTI do + local bit = MTH_FX_Container:CreateTexture(nil, "OVERLAY") + bit:SetTexture("Interface\\Buttons\\WHITE8X8") + bit:SetWidth(5) + bit:SetHeight(8) + bit:SetPoint("CENTER", MTH_FX_Container, "CENTER", 0, 0) + bit:Hide() + MTH_FX_Confetti[i] = bit + i = i + 1 + end + + i = 1 + while i <= MTH_FX_NUM_BLACKHOLE do + local particle = MTH_FX_Container:CreateTexture(nil, "OVERLAY") + particle:SetTexture("Interface\\Cooldown\\star4") + particle:SetWidth(10) + particle:SetHeight(10) + particle:SetBlendMode("ADD") + particle:SetPoint("CENTER", MTH_FX_Container, "CENTER", 0, 0) + particle:Hide() + MTH_FX_BlackHole[i] = particle + i = i + 1 + end + + return true +end + +-- Public entry point: play a full-screen celebration. `style` defaults to the +-- original firework burst and may be firework, victory, starfall, fanfare, or comet. +function MTH_Celebrate(title, subtitle, style, displaySeconds) + if not Sauce then + return + end + if not MTH_FX_EnsureFrames() then + return + end + style = string.lower(tostring(style or "firework")) + local fadeDelay = tonumber(displaySeconds) or 5 + if fadeDelay < 0.5 then fadeDelay = 0.5 end + + if style == "starfall" then + MTH_FX_TitleFS:SetTextColor(0.45, 0.80, 1.00) + else + MTH_FX_TitleFS:SetTextColor(1, 0.82, 0) + end + MTH_FX_TitleFS:SetText(title or "") + MTH_FX_SubFS:SetText(subtitle or "") + + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + if style == "victory" or style == "fanfare" then + Sauce:Sequence({ + Sauce:Group({ + Sauce:SlideIn(MTH_FX_MsgFrame, "UP", 80, 0.36, "outBack", { defer = true }), + Sauce:Pulse(MTH_FX_MsgFrame, style == "victory" and 1.32 or 1.18, 0.52, { defer = true }), + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.6, "inQuad", { delay = fadeDelay }), + }) + else + Sauce:Sequence({ + Sauce:Group({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.25), + Sauce:Pulse(MTH_FX_MsgFrame, 1.22, 0.5), + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.6, "inQuad", { delay = fadeDelay }), + }) + end + + MTH_FX_Container:Show() + local colorCount = table.getn(MTH_FX_Colors) + local colorIdx = 0 + local i = 1 + while i <= MTH_FX_NUM_SPARKS do + local spark = MTH_FX_Sparks[i] + spark:SetTexture("Interface\\Cooldown\\star4") + spark:SetTexCoord(0, 1, 0, 1) + local startX, startY = 0, 0 + local delay, gravity = 0, 300 + local angle = (i / MTH_FX_NUM_SPARKS) * 2 * math.pi + (math.random() - 0.5) * 0.4 + local speed = 150 + math.random() * 130 + local vx = math.cos(angle) * speed + local vy = math.sin(angle) * speed + 70 + local dur = 0.9 + math.random() * 0.4 + + if style == "victory" then + startX = ((i - math.floor(i / 2) * 2) == 0) and -130 or 130 + speed = 100 + math.random() * 100 + vx = math.cos(angle) * speed + vy = math.sin(angle) * speed + 105 + elseif style == "starfall" then + startX = -230 + math.random() * 460 + startY = 200 + math.random() * 100 + vx = -35 + math.random() * 70 + vy = -10 + math.random() * 25 + gravity = 235 + dur = 1.1 + math.random() * 0.5 + elseif style == "fanfare" then + delay = ((i - 1) - math.floor((i - 1) / 3) * 3) * 0.18 + speed = 110 + math.random() * 90 + vx = math.cos(angle) * speed + vy = math.sin(angle) * speed + 90 + elseif style == "comet" then + if i == 1 then + startY = -180 + vx, vy, gravity, dur = 0, 430, 0, 0.52 + else + delay = 0.5 + startY = 45 + speed = 130 + math.random() * 120 + vx = math.cos(angle) * speed + vy = math.sin(angle) * speed + 70 + end + end + + colorIdx = colorIdx + 1 + if colorIdx > colorCount then colorIdx = 1 end + local color = MTH_FX_Colors[colorIdx] + spark:SetVertexColor(color[1], color[2], color[3]) + spark:SetAlpha(delay > 0 and 0 or 1) + spark:Show() + + Sauce:Stop(spark) + Sauce:Group({ + Sauce:Tween(spark, { + type = "mth_ballistic", + from = { startX, startY }, + duration = dur, + easing = "linear", + delay = delay, + anchor = MTH_FX_Container, + dur = dur, + vx = vx, + vy = vy, + gravity = gravity, + }), + Sauce:FadeTo(spark, 1, 0.05, "outQuad", { delay = delay }), + Sauce:FadeTo(spark, 0, 0.45, "inQuad", { + delay = delay + dur - 0.45, + onFinish = function() + spark:Hide() + end, + }), + }) + i = i + 1 + end +end + +local function MTH_FX_ShowPreviewBanner(title, subtitle, r, g, b) + MTH_FX_TitleFS:SetTextColor(r, g, b) + MTH_FX_TitleFS:SetText(title) + MTH_FX_SubFS:SetText(subtitle) + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + Sauce:Sequence({ + Sauce:Group({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.18), + Sauce:Pulse(MTH_FX_MsgFrame, 1.18, 0.45), + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.55, "inQuad", { delay = 5 }), + }) +end + +function MTH_CelebrateConfetti(title, subtitle) + if not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_ShowPreviewBanner(title or "Celebration!", subtitle or "Confetti", 1, 0.82, 0.15) + MTH_FX_Container:Show() + local count = table.getn(MTH_FX_Colors) + local i = 1 + while i <= MTH_FX_NUM_CONFETTI do + local bit = MTH_FX_Confetti[i] + local color = MTH_FX_Colors[((i - 1) - math.floor((i - 1) / count) * count) + 1] + local size = 3 + math.random() * 5 + local angle = math.random() * 2 * math.pi + local speed = 90 + math.random() * 210 + local dur = 1.25 + math.random() * 0.7 + bit:SetWidth(size) + bit:SetHeight(size * 1.6) + bit:SetVertexColor(color[1], color[2], color[3]) + bit:SetAlpha(1) + bit:Show() + Sauce:Stop(bit) + Sauce:Group({ + Sauce:Tween(bit, { + type = "mth_ballistic", from = { 0, 0 }, duration = dur, + easing = "linear", anchor = MTH_FX_Container, dur = dur, + vx = math.cos(angle) * speed, vy = math.sin(angle) * speed + 100, gravity = 255, + }), + Sauce:FadeTo(bit, 0, 0.35, "inQuad", { + delay = dur - 0.35, onFinish = function() bit:Hide() end, + }), + }) + i = i + 1 + end +end + +function MTH_CelebrateBlackHole(title, subtitle) + if not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_ShowPreviewBanner(title or "Gravity Well", subtitle or "Black Hole", 0.68, 0.45, 1.00) + MTH_FX_Container:Show() + local colors = { { 0.45, 0.25, 1 }, { 0.30, 0.50, 1 }, { 0.80, 0.55, 1 } } + local i = 1 + while i <= MTH_FX_NUM_BLACKHOLE do + local particle = MTH_FX_BlackHole[i] + local color = colors[((i - 1) - math.floor((i - 1) / 3) * 3) + 1] + local startAngle = math.random() * 2 * math.pi + local radius = 95 + math.random() * 145 + local duration = 1.5 + math.random() * 1.0 + local size = 7 + math.random() * 8 + particle:SetWidth(size) + particle:SetHeight(size) + particle:SetVertexColor(color[1], color[2], color[3]) + particle:SetAlpha(1) + particle:Show() + Sauce:Stop(particle) + Sauce:Group({ + Sauce:Tween(particle, { + type = "mth_spiral", from = 0, to = 1, duration = duration, + easing = "inQuad", anchor = MTH_FX_Container, cx = 0, cy = 0, + radius = radius, startAngle = startAngle, rotations = 1.5 + math.random() * 2.0, + }), + Sauce:Size(particle, { size, size }, { 2, 2 }, duration, "inQuad"), + Sauce:FadeTo(particle, 0, duration * 0.3, "inQuad", { + delay = duration * 0.7, onFinish = function() particle:Hide() end, + }), + }) + i = i + 1 + end +end + +function MTH_CelebrateShockwave(title, subtitle) + if not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_ShowPreviewBanner(title or "Impact!", subtitle or "Shockwave", 1, 0.62, 0.15) + MTH_FX_Container:Show() + local dotsPerWave = 16 + local i = 1 + while i <= MTH_FX_NUM_CONFETTI do + local dot = MTH_FX_Confetti[i] + local wave = math.floor((i - 1) / dotsPerWave) + 1 + local pos = (i - 1) - math.floor((i - 1) / dotsPerWave) * dotsPerWave + local angle = pos / dotsPerWave * 2 * math.pi + local duration = 1.1 + wave * 0.18 + local radius = 115 + wave * 52 + local delay = (wave - 1) * 0.18 + local intensity = 1 - (wave - 1) * 0.20 + dot:SetWidth(5) + dot:SetHeight(5) + dot:SetVertexColor(1 * intensity, 0.65 * intensity, 0.18 * intensity) + dot:SetAlpha(0) + dot:Show() + Sauce:Stop(dot) + Sauce:Group({ + Sauce:Tween(dot, { + type = "mth_ballistic", from = { 0, 0 }, duration = duration, + easing = "outQuad", delay = delay, anchor = MTH_FX_Container, dur = duration, + vx = math.cos(angle) * radius / duration, vy = math.sin(angle) * radius / duration, gravity = 0, + }), + Sauce:FadeTo(dot, 1, 0.04, "outQuad", { delay = delay }), + Sauce:FadeTo(dot, 0, 0.4, "inQuad", { + delay = delay + duration - 0.4, onFinish = function() dot:Hide() end, + }), + }) + i = i + 1 + end +end + +function MTH_CelebrateShatter(title, subtitle) + if not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_ShowPreviewBanner(title or "Shattered!", subtitle or "Shatter", 0.70, 0.85, 1.00) + MTH_FX_Container:Show() + local grid = 4 + local iconSize = 80 + local fragmentSize = iconSize / grid + local i = 1 + while i <= MTH_FX_NUM_SPARKS do + local spark = MTH_FX_Sparks[i] + if i <= grid * grid then + local row = math.floor((i - 1) / grid) + local col = (i - 1) - row * grid + local x = (col - grid / 2 + 0.5) * fragmentSize + local y = ((grid - 1 - row) - grid / 2 + 0.5) * fragmentSize + local duration = 1.1 + math.random() * 0.45 + spark:SetTexture("Interface\\Icons\\Ability_Hunter_SniperShot") + spark:SetTexCoord(col / grid, (col + 1) / grid, row / grid, (row + 1) / grid) + spark:SetWidth(fragmentSize) + spark:SetHeight(fragmentSize) + spark:SetVertexColor(1, 1, 1) + spark:SetAlpha(1) + spark:Show() + Sauce:Stop(spark) + Sauce:Group({ + Sauce:Tween(spark, { + type = "mth_ballistic", from = { x, y }, duration = duration, + easing = "linear", anchor = MTH_FX_Container, dur = duration, + vx = x * 4 + (math.random() - 0.5) * 90, + vy = y * 4 + 95 + math.random() * 80, gravity = 265, + }), + Sauce:FadeTo(spark, 0, 0.35, "inQuad", { + delay = duration - 0.35, onFinish = function() spark:Hide() end, + }), + }) + end + i = i + 1 + end +end + +function MTH_CelebratePreview(style) + style = string.lower(tostring(style or "firework")) + if style == "all" then + if not Sauce then return end + local styles = { "firework", "blackhole", "shockwave", "shatter" } + local steps = {} + local i = 1 + while i <= table.getn(styles) do + local previewStyle = styles[i] + table.insert(steps, Sauce:Run(function() + MTH_CelebratePreview(previewStyle) + end, { defer = true })) + table.insert(steps, Sauce:Delay(5.7)) + i = i + 1 + end + Sauce:Sequence(steps) + return true + end + + if style == "blackhole" then + MTH_CelebrateBlackHole("Celebration Preview", "Black Hole") + return true + elseif style == "shockwave" then + MTH_CelebrateShockwave("Celebration Preview", "Shockwave") + return true + elseif style == "shatter" then + MTH_CelebrateShatter("Celebration Preview", "Shatter") + return true + end + + local labels = { + firework = "Firework Burst", + } + if not labels[style] then return false end + MTH_Celebrate("Celebration Preview", labels[style], style) + return true +end + +-- Convenience wrapper used when the hunter's pet learns a new ability/rank. +function MTH_CelebratePetAbility(label) + if not MTH_FX_IsEnabled("pet.celebrate") then + return + end + MTH_Celebrate(MTH_FX_L("ANIM_PET_LEARNED_TITLE", "New Pet Ability!"), label) +end + +-- Dramatic negative banner: a red, shaking message shown when a pet runs away +-- for good. Gated on pet.runaway. Reuses the shared message frame. +function MTH_CelebrateRunaway(petName) + if not MTH_FX_IsEnabled("pet.runaway") then + return + end + if not Sauce then + return + end + if not MTH_FX_EnsureFrames() then + return + end + + MTH_FX_TitleFS:SetTextColor(1, 0.15, 0.15) + MTH_FX_TitleFS:SetText(MTH_FX_L("ANIM_PET_RUNAWAY_TITLE", "Your pet ran away!")) + MTH_FX_SubFS:SetText(petName or "") + + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + Sauce:Sequence({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.18), + Sauce:Tween(MTH_FX_MsgFrame, { + type = "mth_shake", + duration = 0.55, + easing = "linear", + magnitude = 11, + freq = 42, + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.7, "inQuad", { delay = 5 }), + }) +end + +function MTH_CelebrateAmmoFallback(previousAmmo, newAmmo) + if not MTH_FX_IsEnabled("combat.ammoout") or not Sauce or not MTH_FX_EnsureFrames() then return end + + MTH_FX_TitleFS:SetTextColor(1, 0.15, 0.15) + MTH_FX_TitleFS:SetText("Out of ammo!") + MTH_FX_SubFS:SetText("Equipped " .. tostring(newAmmo or "fallback ammunition")) + + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + Sauce:Sequence({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.18), + Sauce:Tween(MTH_FX_MsgFrame, { + type = "mth_shake", + duration = 0.55, + easing = "linear", + magnitude = 11, + freq = 42, + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.7, "inQuad", { delay = 5 }), + }) +end + +function MTH_CelebrateLowAmmo(ammoType, total) + if not MTH_FX_IsEnabled("combat.lowammo") or not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_TitleFS:SetTextColor(1, 0.55, 0.10) + MTH_FX_TitleFS:SetText("Running low on " .. tostring(ammoType or "ammo") .. "!") + MTH_FX_SubFS:SetText(tostring(total or 0) .. " left before you're reduced to harsh language.") + + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + Sauce:Sequence({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.18), + Sauce:Tween(MTH_FX_MsgFrame, { + type = "mth_shake", + duration = 0.55, + easing = "linear", + magnitude = 11, + freq = 42, + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.7, "inQuad", { delay = 5 }), + }) +end + +function MTH_CelebrateAmmoZero(ammoType) + if not MTH_FX_IsEnabled("combat.ammozero") or not Sauce or not MTH_FX_EnsureFrames() then return end + MTH_FX_TitleFS:SetTextColor(1, 0.15, 0.15) + MTH_FX_TitleFS:SetText("Out of " .. tostring(ammoType or "ammo") .. "!") + MTH_FX_SubFS:SetText("Your ranged weapon is now a very expensive club. Well done.") + + Sauce:Stop(MTH_FX_MsgFrame) + MTH_FX_MsgFrame:SetAlpha(0) + MTH_FX_MsgFrame:Show() + Sauce:Sequence({ + Sauce:FadeTo(MTH_FX_MsgFrame, 1, 0.18), + Sauce:Tween(MTH_FX_MsgFrame, { + type = "mth_shake", + duration = 0.55, + easing = "linear", + magnitude = 11, + freq = 42, + }), + Sauce:FadeOut(MTH_FX_MsgFrame, 0.7, "inQuad", { delay = 5 }), + }) +end diff --git a/api/hunterbook-model-viewer.lua b/api/hunterbook-model-viewer.lua index ef0ace4..644f6d9 100644 --- a/api/hunterbook-model-viewer.lua +++ b/api/hunterbook-model-viewer.lua @@ -6,6 +6,18 @@ -- Uses MTH_DS_BeastSkins[familyName] generated by .tools/generators/gen_beast_skins.py -- Skin rendering: PlayerModel:SetUnit(CreatureDisplayInfo.ID) +local MTH_MV_Sauce = PizzaSauce + +-- Fade a model frame in when a new skin is displayed, unless disabled. +local function MTH_MV_AnimateModel(model) + if not MTH_MV_Sauce or not model then return end + if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("book.model") then return end + if type(model.SetAlpha) ~= "function" then return end + if not model:IsShown() then return end + model:SetAlpha(0) + MTH_MV_Sauce:FadeTo(model, 1, 0.30, "outQuad") +end + local MTH_MV = { inited = false, family = nil, @@ -51,6 +63,8 @@ local function MTH_MV_DisplayCurrent() MTH_MV_ApplyModel(MTH_MV.previewModel, skin.id) MTH_MV_ApplyModel(MTH_MV.popupModel, skin.id) + MTH_MV_AnimateModel(MTH_MV.previewModel) + MTH_MV_AnimateModel(MTH_MV.popupModel) local indexStr = tostring(MTH_MV.index) .. " / " .. tostring(n) if MTH_MV.popupSkinLabel then diff --git a/api/hunterbook-tab-npcfinder.lua b/api/hunterbook-tab-npcfinder.lua index 0a00c5a..aac056f 100644 --- a/api/hunterbook-tab-npcfinder.lua +++ b/api/hunterbook-tab-npcfinder.lua @@ -69,8 +69,14 @@ local function MTH_BOOKTAB_GetZoneAndSubzone(zoneId) local zid = tonumber(zoneId) if not zid then return "-", "-" end local zoneName = MTH_BOOK_GetZoneName(zid) - local row = MTH_DS_Zones and (MTH_DS_Zones[zid] or MTH_DS_Zones[tostring(zid)]) - if row and row.parent and tonumber(row.parent) and tonumber(row.parent) ~= zid then + -- Coord zone IDs are WMA; MTH_DS_Zones is keyed by pfQuest area IDs. Translate + -- first, or WMA 121 (Feralas) collides with pfQ 121 (Raven Hill -> parent Duskwood). + local pfqId = zid + if type(MTH_MAP_WMA_TO_PFQ) == "table" then + pfqId = MTH_MAP_WMA_TO_PFQ[zid] or zid + end + local row = MTH_DS_Zones and (MTH_DS_Zones[pfqId] or MTH_DS_Zones[tostring(pfqId)]) + if row and row.parent and tonumber(row.parent) and tonumber(row.parent) ~= pfqId then local parentName = MTH_BOOK_GetZoneName(row.parent) if parentName and string.sub(parentName, 1, 5) ~= "Zone " then return parentName, zoneName @@ -105,6 +111,47 @@ function MTH_BOOKTAB_GetNPCZoneSummary(vendor) end MTH_BOOK_GetNPCZoneSummary = MTH_BOOKTAB_GetNPCZoneSummary +-- Zone column text: every distinct zone the NPC sits in, most-specific (smallest +-- map) first, so multi-location NPCs read e.g. "Darnassus, Teldrassil". +function MTH_BOOKTAB_GetNPCZoneDisplay(vendor) + if not vendor or not vendor.coords or table.getn(vendor.coords) == 0 then + return "-" + end + local seen = {} + local list = {} + for i = 1, table.getn(vendor.coords) do + local c = vendor.coords[i] + if c and c[3] then + local zoneName = MTH_BOOK_GetZoneName(c[3]) + if zoneName and zoneName ~= "-" and not seen[zoneName] then + seen[zoneName] = true + local area = nil + local size = MTH_DS_MinimapSizes and (MTH_DS_MinimapSizes[tonumber(c[3])] or MTH_DS_MinimapSizes[c[3]]) + if type(size) == "table" and tonumber(size[1]) and tonumber(size[2]) then + area = tonumber(size[1]) * tonumber(size[2]) + end + table.insert(list, { name = zoneName, area = area, order = i }) + end + end + end + local count = table.getn(list) + if count == 0 then return "-" end + -- Smallest map first; zones with no known size keep data order, at the end. + table.sort(list, function(x, y) + if x.area and y.area then + if x.area ~= y.area then return x.area < y.area end + return x.order < y.order + end + if x.area then return true end + if y.area then return false end + return x.order < y.order + end) + local names = {} + for i = 1, count do table.insert(names, list[i].name) end + return table.concat(names, ", ") +end +MTH_BOOK_GetNPCZoneDisplay = MTH_BOOKTAB_GetNPCZoneDisplay + function MTH_BOOK_NPCHasZoneId(vendor, zoneId) local zid = tonumber(zoneId) if not zid or not vendor or not vendor.coords then diff --git a/api/hunterbook-tab-stable.lua b/api/hunterbook-tab-stable.lua index 7ba3618..2a9284e 100644 --- a/api/hunterbook-tab-stable.lua +++ b/api/hunterbook-tab-stable.lua @@ -1245,6 +1245,17 @@ function MTH_BOOKTAB_RenderStableCards() local row = slotEntry and slotEntry.row or nil MTH_BOOK_DebugTrace("Render card#" .. tostring(i) .. " petId=" .. tostring(petId) .. " hasRow=" .. tostring(type(row) == "table")) + -- Animations: flash the slot when its occupant changes (deposit / + -- withdraw / swap), but never on the first render of this card. + if card._stableInit and card._lastStablePetId ~= petId then + if type(MTH_FX_Flash) == "function" and type(MTH_FX_IsEnabled) == "function" + and MTH_FX_IsEnabled("ui.stable") then + MTH_FX_Flash(card, 1.00, 0.82, 0.20, 0.55) + end + end + card._lastStablePetId = petId + card._stableInit = true + card:ClearAllPoints() card:SetPoint("TOPLEFT", ui.frame, "TOPLEFT", 8, y) card:SetPoint("TOPRIGHT", ui.frame, "TOPRIGHT", -8, y) diff --git a/api/hunterbook-tab-trainers.lua b/api/hunterbook-tab-trainers.lua new file mode 100644 index 0000000..240aac9 --- /dev/null +++ b/api/hunterbook-tab-trainers.lua @@ -0,0 +1,225 @@ +if type(MTH_HUNTERBOOK_TABS) ~= "table" then MTH_HUNTERBOOK_TABS = {} end + +local trainerColumns = { + { x = 28, width = 150, align = "LEFT" }, + { x = 180, width = 62, align = "LEFT" }, + { x = 244, width = 43, align = "LEFT" }, + { x = 289, width = 80, align = "LEFT" }, + { x = 371, width = 105, align = "LEFT" }, + { x = 478, width = 74, align = "LEFT" }, +} + +MTH_HUNTERBOOK_TABS.hunterspells = { + headerLabel = "Hunter Spells", + columnLabels = { "Spell", "Rank", "Level", "Price", "Branch", "Status" }, + columnLayout = trainerColumns, +} + +MTH_HUNTERBOOK_TABS.petspells = { + headerLabel = "Pet Spells", + columnLabels = { "Spell", "Rank", "Level", "Price", "Branch", "Status" }, + columnLayout = trainerColumns, +} + +function MTH_BOOKTAB_IsTrainerSpellMode(mode) + return mode == "hunterspells" or mode == "petspells" +end + +local function MTH_BOOKTAB_GetTrainerKind() + if MTH_BOOK_STATE and MTH_BOOK_STATE.mode == "petspells" then return "pet" end + return "hunter" +end + +function MTH_BOOKTAB_FormatTrainerCost(copper) + local value = tonumber(copper) or 0 + local gold = math.floor(value / 10000) + value = value - gold * 10000 + local silver = math.floor(value / 100) + local bronze = value - silver * 100 + local parts = {} + if gold > 0 then table.insert(parts, "|cffffd100" .. tostring(gold) .. "g|r") end + if silver > 0 or gold > 0 then table.insert(parts, "|cffc7c7cf" .. tostring(silver) .. "s|r") end + table.insert(parts, "|cffeda55f" .. tostring(bronze) .. "c|r") + return table.concat(parts, " ") +end + +function MTH_BOOKTAB_GetTrainerDisplayCost(copper) + if type(MTH_TrainerPlan_GetPrice) == "function" then + return MTH_TrainerPlan_GetPrice({ trainerCost = copper }, MTH_BOOK_STATE and MTH_BOOK_STATE.trainerHonoredDiscount) + end + return tonumber(copper) or 0 +end + +function MTH_BOOKTAB_GetTrainerSpellStatus(row) + local kind = MTH_BOOKTAB_GetTrainerKind() + local playerLevel = MTH_BOOK_GetPlayerLevelValue and MTH_BOOK_GetPlayerLevelValue() or nil + local status = type(MTH_TrainerPlan_GetStatus) == "function" and MTH_TrainerPlan_GetStatus(kind, row, playerLevel) or "notyet" + if status == "known" then return "Known", "|cff40ff40" end + if status == "notlearned" then return "Not learned", "|cffffa500" end + return "Not yet learnable", "|cffff4040" +end + +function MTH_BOOKTAB_BuildTrainerSpellResults() + local results = {} + local kind = MTH_BOOKTAB_GetTrainerKind() + local plan = type(MTH_TrainerPlan_Build) == "function" and MTH_TrainerPlan_Build(kind, { + playerLevel = MTH_BOOK_GetPlayerLevelValue and MTH_BOOK_GetPlayerLevelValue() or nil, + honored = MTH_BOOK_STATE and MTH_BOOK_STATE.trainerHonoredDiscount, + }) or nil + if type(plan) ~= "table" or type(plan.rows) ~= "table" then return results end + local search = MTH_BOOK_SafeLower(MTH_BOOK_STATE.search or "") + for _, planned in ipairs(plan.rows) do + local row = planned.row + local level = tonumber(row.requiredLevel) or 0 + local haystack = tostring(row.name or "") .. " " .. tostring(row.rank or "") .. " " .. tostring(row.section or "") .. " " .. tostring(row.description or "") + if type(row.prerequisites) == "table" then + for _, prerequisite in ipairs(row.prerequisites) do + haystack = haystack .. " " .. tostring(prerequisite) + end + end + haystack = MTH_BOOK_SafeLower(haystack) + if (search == "" or string.find(haystack, search, 1, true) ~= nil) + and (not MTH_BOOK_STATE.minLevel or level >= MTH_BOOK_STATE.minLevel) + and (not MTH_BOOK_STATE.maxLevel or level <= MTH_BOOK_STATE.maxLevel) + and (type(MTH_TrainerPlan_MatchesQuick) ~= "function" or MTH_TrainerPlan_MatchesQuick(planned.status, MTH_BOOK_STATE.quick)) + then + table.insert(results, row) + end + end + return results +end + +function MTH_BOOKTAB_UpdateTrainerPlanPanel(detail) + local parent = detail and detail:GetParent() + if not parent then return end + if not parent.mthTrainerPlan then + local panel = CreateFrame("Frame", nil, parent) + panel:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -238) + panel:SetWidth(136) + panel:SetHeight(128) + panel:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 12, insets = { left = 3, right = 3, top = 3, bottom = 3 } }) + panel:SetBackdropColor(0.06, 0.06, 0.06, 1) + panel:SetBackdropBorderColor(0.28, 0.28, 0.28, 1) + panel.text = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") + panel.text:SetPoint("TOPLEFT", panel, "TOPLEFT", 7, -7) + panel.text:SetWidth(122) + panel.text:SetJustifyH("LEFT") + panel.text:SetJustifyV("TOP") + parent.mthTrainerPlan = panel + end + local plan = type(MTH_TrainerPlan_Build) == "function" and MTH_TrainerPlan_Build(MTH_BOOKTAB_GetTrainerKind(), { + playerLevel = MTH_BOOK_GetPlayerLevelValue and MTH_BOOK_GetPlayerLevelValue() or nil, + honored = MTH_BOOK_STATE.trainerHonoredDiscount, + }) or nil + if not plan then return end + local nextLevel = plan.nextLevel.level + local nextLabel = "Next" + if nextLevel then nextLabel = "At level " .. tostring(nextLevel) end + local function formatBlock(label, summary) + return "|cffffffff" .. label .. "|r\n" + .. "|cff4da6ff" .. tostring(summary.count) .. "|r |cff9a9a9aspells|r |cffffffff| |r" + .. MTH_BOOKTAB_FormatTrainerCost(summary.cost) + end + parent.mthTrainerPlan.text:SetText("|cffffd100Training Plan|r\n" + .. formatBlock("Now", plan.now) + .. "\n\n" .. formatBlock(nextLabel, plan.nextLevel) + .. "\n\n" .. formatBlock("Remaining", plan.remaining)) + parent.mthTrainerPlan:Show() +end + +function MTH_BOOKTAB_GetTrainerSpellTotal() + local catalog = MTH_DS_TrainerCatalog + local rows = catalog and catalog[MTH_BOOKTAB_GetTrainerKind()] or nil + return type(rows) == "table" and table.getn(rows) or 0 +end + +function MTH_BOOKTAB_TrainerSpellSort(a, b) + local al = tonumber(a and a.requiredLevel) or 0 + local bl = tonumber(b and b.requiredLevel) or 0 + if al ~= bl then return al < bl end + local an = MTH_BOOK_SafeLower(a and a.name) + local bn = MTH_BOOK_SafeLower(b and b.name) + if an ~= bn then return an < bn end + return MTH_BOOK_SafeLower(a and a.rank) < MTH_BOOK_SafeLower(b and b.rank) +end + +function MTH_BOOKTAB_GetTrainerSpellSortKey(row, col) + if col == 1 then return MTH_BOOK_SafeLower(row and row.name) end + if col == 2 then return MTH_BOOK_SafeLower(row and row.rank) end + if col == 3 then return tonumber(row and row.requiredLevel) or 0 end + if col == 4 then return tonumber(row and row.trainerCost) or 0 end + if col == 5 then return MTH_BOOK_SafeLower(row and row.section) end + if col == 6 then + local status = MTH_BOOKTAB_GetTrainerSpellStatus(row) + return MTH_BOOK_SafeLower(status) + end + return nil +end + +function MTH_BOOKTAB_GetTrainerSpellRowValues(row) + local requiredLevel = tonumber(row and row.requiredLevel) or 0 + local playerLevel = MTH_BOOK_GetPlayerLevelValue and MTH_BOOK_GetPlayerLevelValue() or nil + local levelColor = (playerLevel and playerLevel >= requiredLevel) and "|cff40ff40" or "|cffff4040" + local status, statusColor = MTH_BOOKTAB_GetTrainerSpellStatus(row) + return { + tostring(row and row.name or "-"), + tostring(row and row.rank or "-"), + levelColor .. tostring(row and row.requiredLevel or "-") .. "|r", + MTH_BOOKTAB_FormatTrainerCost(MTH_BOOKTAB_GetTrainerDisplayCost(row and row.trainerCost)), + tostring(row and row.section or "-"), + statusColor .. status .. "|r", + } +end + +function MTH_BOOKTAB_UpdateTrainerSpellDetail(detail, selected) + if not MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE and MTH_BOOK_STATE.mode) then return false end + MTH_BOOKTAB_UpdateTrainerPlanPanel(detail) + if not selected then + MTH_BOOK_SetDetailText(detail, "|cFFFFD100Trainer Spells|r\n\nSelect a spell rank to inspect its trainer level, undiscounted cost, and prerequisites.") + return true + end + local lines = {} + table.insert(lines, "|cffffff00" .. tostring(selected.name or "Unknown") .. "|r") + if selected.rank and selected.rank ~= "" then table.insert(lines, "|cffaaaaaa" .. tostring(selected.rank) .. "|r") end + table.insert(lines, "") + table.insert(lines, "|cff9a9a9aBranch:|r |cffffffff" .. tostring(selected.section or "-") .. "|r") + local requiredLevel = tonumber(selected.requiredLevel) or 0 + local playerLevel = MTH_BOOK_GetPlayerLevelValue and MTH_BOOK_GetPlayerLevelValue() or nil + local levelColor = (playerLevel and playerLevel >= requiredLevel) and "|cff40ff40" or "|cffff4040" + table.insert(lines, "|cff9a9a9aRequired Level:|r " .. levelColor .. tostring(selected.requiredLevel or "-") .. "|r") + local status, statusColor = MTH_BOOKTAB_GetTrainerSpellStatus(selected) + table.insert(lines, "|cff9a9a9aStatus:|r " .. statusColor .. status .. "|r") + table.insert(lines, "|cff9a9a9aBase Price:|r " .. MTH_BOOKTAB_FormatTrainerCost(selected.trainerCost)) + table.insert(lines, "|cff9a9a9aHonored Price:|r " .. MTH_BOOKTAB_FormatTrainerCost(math.floor((tonumber(selected.trainerCost) or 0) * 0.90))) + if type(selected.prerequisites) == "table" and table.getn(selected.prerequisites) > 0 then + table.insert(lines, "") + table.insert(lines, "|cffffd100Prerequisites|r") + for _, prerequisite in ipairs(selected.prerequisites) do + table.insert(lines, " " .. tostring(prerequisite)) + end + end + if selected.description and selected.description ~= "" then + table.insert(lines, "") + table.insert(lines, "|cffffd100Description|r") + table.insert(lines, "|cffffffff " .. tostring(selected.description) .. "|r") + end + local detailFrame = detail.mthTrainerDetailParent or detail:GetParent() + if not detail.mthTrainerIcon then + detail.mthTrainerIcon = detail:CreateTexture(nil, "ARTWORK") + detail.mthTrainerIcon:SetWidth(28) + detail.mthTrainerIcon:SetHeight(28) + end + detail.mthTrainerIcon:SetParent(detail) + detail.mthTrainerIcon:ClearAllPoints() + detail.mthTrainerIcon:SetPoint("TOPLEFT", detail, "TOPLEFT", 0, 0) + detail.mthTrainerIcon:SetTexture(MTH_BOOK_ResolveIconPath(selected.icon) or "Interface\\Icons\\INV_Misc_QuestionMark") + detail.mthTrainerIcon:Show() + if detail.SetTextInsets then detail:SetTextInsets(4, 4, 36, 4) end + if not detail.mthTrainerDetailScroll then + detail:ClearAllPoints() + detail:SetPoint("TOPLEFT", detailFrame, "TOPLEFT", 8, -42) + detail:SetWidth(detailFrame:GetWidth() - 16) + end + MTH_BOOK_SetDetailText(detail, table.concat(lines, "\n")) + return true +end \ No newline at end of file diff --git a/api/hunterbook.lua b/api/hunterbook.lua index 1e69c65..9f4c3b5 100644 --- a/api/hunterbook.lua +++ b/api/hunterbook.lua @@ -1,5 +1,63 @@ -- MetaHunt standalone Hunter Book UI (XML + Lua) +-- Capture our PizzaSauce instance for opening animations (may be nil if the +-- library is unavailable; all uses are guarded). +local Sauce = PizzaSauce + +-- Fade the book window in on open, unless the player disabled it in options. +local function MTH_BOOK_FadeInWindow(frame) + if not Sauce or not frame then return end + if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("book.open") then return end + -- Hide the window's alpha THIS frame: Show() already ran, so without this the + -- book renders fully opaque for one frame before the tween's onStart snaps it + -- to 0 -- a flash that makes the fade look like it never happened. + if type(frame.SetAlpha) == "function" then frame:SetAlpha(0) end + -- Fade only: the window's first anchor is relative to a child, so a position + -- tween's per-frame SetPoint throws " is dependent on this". Alpha is safe. + Sauce:FadeIn(frame, 0.5, "outQuad") +end + +-- Fade the results list back in when switching Hunter's Book tabs, unless disabled. +local function MTH_BOOK_AnimateTabSwitch() + if not Sauce then return end + if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("book.tabs") then return end + local list = getglobal("MTH_BOOK_ListBackdrop") + if not list then return end + if type(list.SetAlpha) == "function" then list:SetAlpha(0) end + -- Fade only (position tween SetPoint is unsafe on this anchor; see FadeInWindow). + Sauce:FadeIn(list, 0.3, "outQuad") +end + +-- Fade the book window out before hiding it, unless disabled (shares the +-- "fade windows closed" toggle with the options window). +local function MTH_BOOK_FadeOutWindow(frame) + if not frame then return end + if not Sauce + or (type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("ui.optionsclose")) then + if type(frame.SetAlpha) == "function" then frame:SetAlpha(1) end + frame:Hide() + return + end + -- Fade only (position tween SetPoint is unsafe on this anchor; see FadeInWindow). + Sauce:FadeTo(frame, 0, 0.26, "inQuad", { + onFinish = function() + frame:Hide() + if type(frame.SetAlpha) == "function" then frame:SetAlpha(1) end + end, + }) +end + +-- Fade the results list back in after a search/filter is applied, unless disabled. +local function MTH_BOOK_AnimateSearchResults() + if not Sauce then return end + if type(MTH_FX_IsEnabled) == "function" and not MTH_FX_IsEnabled("book.search") then return end + local list = getglobal("MTH_BOOK_ListBackdrop") + if not list then return end + if type(list.SetAlpha) == "function" then list:SetAlpha(0) end + -- Fade only (position tween SetPoint is unsafe on this anchor; see FadeInWindow). + Sauce:FadeIn(list, 0.26, "outQuad") +end + MTH_HUNTERBOOK_LOADED = true MTH_HUNTERBOOK_TABS = MTH_HUNTERBOOK_TABS or {} @@ -20,34 +78,34 @@ local MTH_BOOK_GetZoneName local MTH_BOOK_GetPlayerLevelValue local MTH_BOOK_IsSpellInLevelScope local MTH_BOOK_GetScopedRankSummary -local MTH_BOOK_BEAST_DATABASE_URL = "https://database.turtlecraft.gg/?npc=%d" -local MTH_BOOK_NPC_DATABASE_URL = "https://database.turtlecraft.gg/?npc=%d" -local MTH_BOOK_ITEM_DATABASE_URL = "https://database.turtlecraft.gg/?item=%d" +local MTH_BOOK_BEAST_DATABASE_URL = "https://octowow.st/db/?npc=%d" +local MTH_BOOK_NPC_DATABASE_URL = "https://octowow.st/db/?npc=%d" +local MTH_BOOK_ITEM_DATABASE_URL = "https://octowow.st/db/?item=%d" local MTH_BOOK_STABLE_CURRENT_ID_CACHE = nil local MTH_BOOK_FALLBACK_BAG_ITEMS = { - [2101] = { name = "Light Quiver", subtype = "quiver", slots = 6, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=2101" }, - [2102] = { name = "Small Ammo Pouch", subtype = "ammo pouch", slots = 6, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=2102" }, - [2662] = { name = "Ribbly's Quiver", subtype = "quiver", slots = 16, reqlevel = 50, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=2662" }, - [2663] = { name = "Ribbly's Bandolier", subtype = "ammo pouch", slots = 16, reqlevel = 50, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=2663" }, - [3573] = { name = "Hunting Quiver", subtype = "quiver", slots = 10, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=3573" }, - [3574] = { name = "Hunting Ammo Sack", subtype = "ammo pouch", slots = 10, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=3574" }, - [3604] = { name = "Bandolier of the Night Watch", subtype = "ammo pouch", slots = 12, reqlevel = nil, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=3604" }, - [3605] = { name = "Quiver of the Night Watch", subtype = "quiver", slots = 12, reqlevel = nil, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=3605" }, - [5439] = { name = "Small Quiver", subtype = "quiver", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=5439" }, - [5441] = { name = "Small Shot Pouch", subtype = "ammo pouch", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=5441" }, - [7278] = { name = "Light Leather Quiver", subtype = "quiver", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=7278" }, - [7279] = { name = "Small Leather Ammo Pouch", subtype = "ammo pouch", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=7279" }, - [7371] = { name = "Heavy Quiver", subtype = "quiver", slots = 14, reqlevel = 30, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=7371" }, - [7372] = { name = "Heavy Leather Ammo Pouch", subtype = "ammo pouch", slots = 14, reqlevel = 30, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=7372" }, - [8217] = { name = "Quickdraw Quiver", subtype = "quiver", slots = 16, reqlevel = 40, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=8217" }, - [8218] = { name = "Thick Leather Ammo Pouch", subtype = "ammo pouch", slots = 16, reqlevel = 40, quality = 2, sourceUrl = "https://database.turtlecraft.gg/?item=8218" }, - [11362] = { name = "Medium Quiver", subtype = "quiver", slots = 10, reqlevel = 10, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=11362" }, - [11363] = { name = "Medium Shot Pouch", subtype = "ammo pouch", slots = 10, reqlevel = 10, quality = 1, sourceUrl = "https://database.turtlecraft.gg/?item=11363" }, - [18714] = { name = "Ancient Sinew Wrapped Lamina", subtype = "quiver", slots = 18, reqlevel = 60, quality = 4, sourceUrl = "https://database.turtlecraft.gg/?item=18714" }, - [19319] = { name = "Harpy Hide Quiver", subtype = "quiver", slots = 16, reqlevel = 55, quality = 3, sourceUrl = "https://database.turtlecraft.gg/?item=19319" }, - [19320] = { name = "Gnoll Skin Bandolier", subtype = "ammo pouch", slots = 16, reqlevel = 55, quality = 3, sourceUrl = "https://database.turtlecraft.gg/?item=19320" }, - [61549] = { name = "Swiftfeather Quiver", subtype = "quiver", slots = 16, reqlevel = 57, quality = 3, sourceUrl = "https://database.turtlecraft.gg/?item=61549" }, + [2101] = { name = "Light Quiver", subtype = "quiver", slots = 6, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=2101" }, + [2102] = { name = "Small Ammo Pouch", subtype = "ammo pouch", slots = 6, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=2102" }, + [2662] = { name = "Ribbly's Quiver", subtype = "quiver", slots = 16, reqlevel = 50, quality = 2, sourceUrl = "https://octowow.st/db/?item=2662" }, + [2663] = { name = "Ribbly's Bandolier", subtype = "ammo pouch", slots = 16, reqlevel = 50, quality = 2, sourceUrl = "https://octowow.st/db/?item=2663" }, + [3573] = { name = "Hunting Quiver", subtype = "quiver", slots = 10, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=3573" }, + [3574] = { name = "Hunting Ammo Sack", subtype = "ammo pouch", slots = 10, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=3574" }, + [3604] = { name = "Bandolier of the Night Watch", subtype = "ammo pouch", slots = 12, reqlevel = nil, quality = 2, sourceUrl = "https://octowow.st/db/?item=3604" }, + [3605] = { name = "Quiver of the Night Watch", subtype = "quiver", slots = 12, reqlevel = nil, quality = 2, sourceUrl = "https://octowow.st/db/?item=3605" }, + [5439] = { name = "Small Quiver", subtype = "quiver", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=5439" }, + [5441] = { name = "Small Shot Pouch", subtype = "ammo pouch", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=5441" }, + [7278] = { name = "Light Leather Quiver", subtype = "quiver", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=7278" }, + [7279] = { name = "Small Leather Ammo Pouch", subtype = "ammo pouch", slots = 8, reqlevel = nil, quality = 1, sourceUrl = "https://octowow.st/db/?item=7279" }, + [7371] = { name = "Heavy Quiver", subtype = "quiver", slots = 14, reqlevel = 30, quality = 2, sourceUrl = "https://octowow.st/db/?item=7371" }, + [7372] = { name = "Heavy Leather Ammo Pouch", subtype = "ammo pouch", slots = 14, reqlevel = 30, quality = 2, sourceUrl = "https://octowow.st/db/?item=7372" }, + [8217] = { name = "Quickdraw Quiver", subtype = "quiver", slots = 16, reqlevel = 40, quality = 2, sourceUrl = "https://octowow.st/db/?item=8217" }, + [8218] = { name = "Thick Leather Ammo Pouch", subtype = "ammo pouch", slots = 16, reqlevel = 40, quality = 2, sourceUrl = "https://octowow.st/db/?item=8218" }, + [11362] = { name = "Medium Quiver", subtype = "quiver", slots = 10, reqlevel = 10, quality = 1, sourceUrl = "https://octowow.st/db/?item=11362" }, + [11363] = { name = "Medium Shot Pouch", subtype = "ammo pouch", slots = 10, reqlevel = 10, quality = 1, sourceUrl = "https://octowow.st/db/?item=11363" }, + [18714] = { name = "Ancient Sinew Wrapped Lamina", subtype = "quiver", slots = 18, reqlevel = 60, quality = 4, sourceUrl = "https://octowow.st/db/?item=18714" }, + [19319] = { name = "Harpy Hide Quiver", subtype = "quiver", slots = 16, reqlevel = 55, quality = 3, sourceUrl = "https://octowow.st/db/?item=19319" }, + [19320] = { name = "Gnoll Skin Bandolier", subtype = "ammo pouch", slots = 16, reqlevel = 55, quality = 3, sourceUrl = "https://octowow.st/db/?item=19320" }, + [61549] = { name = "Swiftfeather Quiver", subtype = "quiver", slots = 16, reqlevel = 57, quality = 3, sourceUrl = "https://octowow.st/db/?item=61549" }, } local function MTH_BOOK_FamiliesTrace(message) @@ -79,6 +137,7 @@ local MTH_BOOK_STATE = { familyStatsPercent = false, petHideNoAbilities = true, petHideUnknown = true, + trainerHonoredDiscount = false, forcedBeastId = nil, page = 1, pageSize = 16, @@ -102,6 +161,8 @@ local MTH_BOOK_STATE = { npcs = { col = nil, asc = true }, stable = { col = nil, asc = true }, pethistory = { col = 5, asc = false }, + hunterspells = { col = 3, asc = true }, + petspells = { col = 3, asc = true }, }, sliderDragging = false, } @@ -240,6 +301,17 @@ local MTH_BOOK_CONTENT_LAYOUT_ITEMS = { detailH = 366, } +local MTH_BOOK_CONTENT_LAYOUT_TRAINERS = { + listX = 16, + listY = -146, + listW = 564, + listH = 374, + detailX = 608, + detailY = -146, + detailW = 136, + detailH = 230, +} + local MTH_BOOK_CONTENT_LAYOUT_PETABILITIES = { listX = 16, listY = -106, @@ -295,7 +367,52 @@ local MTH_BOOK_CONTENT_LAYOUT_HISTORY = { detailH = 446, } -local function MTH_BOOK_ApplyContentLayoutForMode() +function MTH_BOOK_ConfigureTrainerDetailScroll(isTrainerMode, detailParent, detailText) + if not (detailParent and detailText) then return end + if isTrainerMode then + if not detailParent.mthTrainerDetailScroll then + local scroll = CreateFrame("ScrollFrame", "MTH_BOOK_TrainerDetailScroll", detailParent, "UIPanelScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", detailParent, "TOPLEFT", 4, -4) + scroll:SetPoint("BOTTOMRIGHT", detailParent, "BOTTOMRIGHT", -24, 4) + detailParent.mthTrainerDetailScroll = scroll + detailText:EnableMouseWheel(true) + detailText:SetScript("OnMouseWheel", function() + local current = scroll:GetVerticalScroll() + if arg1 and arg1 > 0 then + scroll:SetVerticalScroll(current - 24) + else + scroll:SetVerticalScroll(current + 24) + end + end) + end + local scroll = detailParent.mthTrainerDetailScroll + detailText.mthTrainerDetailParent = detailParent + detailText.mthTrainerDetailScroll = scroll + if detailText:GetParent() ~= scroll then detailText:SetParent(scroll) end + detailText:ClearAllPoints() + detailText:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0) + scroll:SetScrollChild(detailText) + scroll:SetWidth(detailParent:GetWidth() - 28) + scroll:SetHeight(detailParent:GetHeight() - 8) + detailText:SetWidth(detailParent:GetWidth() - 28) + detailText:SetHeight(scroll:GetHeight()) + scroll:Show() + else + local scroll = detailParent.mthTrainerDetailScroll + if scroll then scroll:Hide() end + if detailText.mthTrainerDetailParent then + detailText:SetParent(detailText.mthTrainerDetailParent) + detailText:ClearAllPoints() + detailText:SetPoint("TOPLEFT", detailParent, "TOPLEFT", 6, -6) + detailText:SetWidth(detailParent:GetWidth() - 12) + detailText:SetHeight(detailParent:GetHeight() - 12) + if detailText.SetTextInsets then detailText:SetTextInsets(4, 4, 4, 4) end + detailText.mthTrainerDetailScroll = nil + end + end +end + +function MTH_BOOK_ApplyContentLayoutForMode() local listParent = getglobal("MTH_BOOK_ListBackdrop") local detailParent = getglobal("MTH_BOOK_DetailBackdrop") local detailText = getglobal("MTH_BOOK_DetailBackdropDetailText") @@ -306,6 +423,9 @@ local function MTH_BOOK_ApplyContentLayoutForMode() local layout = MTH_BOOK_CONTENT_LAYOUT_DEFAULT if MTH_BOOK_STATE.mode == "petabilities" then layout = MTH_BOOK_CONTENT_LAYOUT_PETABILITIES + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + layout = MTH_BOOK_CONTENT_LAYOUT_TRAINERS elseif MTH_BOOK_IsItemMode() then layout = MTH_BOOK_CONTENT_LAYOUT_ITEMS elseif MTH_BOOK_STATE.mode == "npcs" then @@ -343,6 +463,9 @@ local function MTH_BOOK_ApplyContentLayoutForMode() if listSlider then listSlider:SetHeight(sliderHeight) end + local isTrainerMode = type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) + MTH_BOOK_ConfigureTrainerDetailScroll(isTrainerMode, detailParent, detailText) if MTH_BOOK_STATE.petUI then local paneHeight = layout.listH - 8 @@ -409,6 +532,45 @@ local function MTH_BOOK_DropdownCreateInfo() return {} end +function MTH_BOOK_EnsureTrainerStatusDropdown() + local dropdown = getglobal("MTH_BOOK_TrainerStatusDropdown") + if not dropdown then + local filter = getglobal("MTH_BOOK_Filter1") + local parent = filter and filter:GetParent() + if not parent then return nil end + dropdown = CreateFrame("Frame", "MTH_BOOK_TrainerStatusDropdown", parent, "UIDropDownMenuTemplate") + dropdown:SetPoint("TOPLEFT", parent, "TOPLEFT", 150, -116) + UIDropDownMenu_Initialize(dropdown, function() + local options = { + { value = "all", label = "All" }, + { value = "known", label = "Known" }, + { value = "notlearned", label = "Not learned" }, + { value = "notyet", label = "Not yet learnable" }, + } + for i = 1, table.getn(options) do + local option = options[i] + local value = option.value + local label = option.label + local info = MTH_BOOK_DropdownCreateInfo() + info.text = label + info.checked = MTH_BOOK_STATE.quick == value + info.func = function() + MTH_BOOK_STATE.quick = value + MTH_BOOK_STATE.page = 1 + MTH_BOOK_STATE.selectedEntry = nil + MTH_BOOK_DropdownSetText(dropdown, label) + MTH_BOOK_RefreshFilter() + end + UIDropDownMenu_AddButton(info) + end + end) + UIDropDownMenu_SetWidth(136, dropdown) + UIDropDownMenu_JustifyText("LEFT", dropdown) + dropdown:Hide() + end + return dropdown +end + local function MTH_BOOK_SetSliderFromCursor(slider) if not slider then return end local minValue, maxValue = slider:GetMinMaxValues() @@ -1356,7 +1518,7 @@ local function MTH_BOOK_GetSortKey(entry, col) if not npc then return nil end local react = MTH_BOOK_GetNPCReactBucket(npc.fac) local functionSummary = MTH_BOOK_GetNPCFunctionSummary(npc) - local zoneName = MTH_BOOK_GetNPCZoneSummary(npc) + local zoneName = (type(MTH_BOOK_GetNPCZoneDisplay) == "function" and MTH_BOOK_GetNPCZoneDisplay(npc)) or MTH_BOOK_GetNPCZoneSummary(npc) if col == 1 then return tonumber(entry) or 0 end if col == 2 then local npcName = (MTH and MTH.GetLocalizedNPCNameById and MTH:GetLocalizedNPCNameById(entry, npc.name)) or npc.name @@ -1391,6 +1553,12 @@ local function MTH_BOOK_GetSortKey(entry, col) if col == 6 then return MTH_BOOK_SafeLower(tostring(row.abandonReason or "")) end end + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + return type(MTH_BOOKTAB_GetTrainerSpellSortKey) == "function" + and MTH_BOOKTAB_GetTrainerSpellSortKey(entry, col) or nil + end + local items = MTH_BOOK_GetItemsTable() local item = items and items[entry] if not item then return nil end @@ -1446,6 +1614,10 @@ local function MTH_BOOK_DefaultCompare(a, b) if MTH_BOOK_STATE.mode == "npcs" then return MTH_BOOK_NPCSort(a, b) end if MTH_BOOK_STATE.mode == "stable" then return MTH_BOOK_StableSort(a, b) end if MTH_BOOK_STATE.mode == "pethistory" then return MTH_BOOK_PetHistorySort(a, b) end + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + return type(MTH_BOOKTAB_TrainerSpellSort) == "function" and MTH_BOOKTAB_TrainerSpellSort(a, b) or false + end return MTH_BOOK_ItemSort(a, b) end @@ -1714,6 +1886,15 @@ local function MTH_BOOK_BuildResults() return results end + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + if type(MTH_BOOKTAB_BuildTrainerSpellResults) == "function" then + results = MTH_BOOKTAB_BuildTrainerSpellResults() + end + MTH_BOOK_ApplyActiveSort(results) + return results + end + local items = MTH_BOOK_GetItemsTable() if not items then return results end for itemId, item in pairs(items) do @@ -1757,6 +1938,10 @@ local function MTH_BOOK_GetTotalCount() local store = MTH_BOOK_GetPetDatastore() return (type(store) == "table" and type(store.historyById) == "table") and MTH_BOOK_CountMap(store.historyById) or 0 end + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + return type(MTH_BOOKTAB_GetTrainerSpellTotal) == "function" and MTH_BOOKTAB_GetTrainerSpellTotal() or 0 + end local items = MTH_BOOK_GetItemsTable() return items and MTH_BOOK_CountMap(items) or 0 end @@ -2260,7 +2445,9 @@ end local function MTH_BOOK_UpdateQuickFilterControls() MTH_BOOK_UpdateCheckboxLayoutByMode() - if MTH_BOOK_IsItemMode() then + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + MTH_BOOK_STATE.pageSize = 17 + elseif MTH_BOOK_IsItemMode() then MTH_BOOK_STATE.pageSize = 18 elseif MTH_BOOK_STATE.mode == "families" then MTH_BOOK_STATE.pageSize = MTH_BOOK_MAX_ROWS @@ -2282,6 +2469,7 @@ local function MTH_BOOK_UpdateQuickFilterControls() local itemSubtypeDropdown = getglobal("MTH_BOOK_ItemSubtypeDropdown") local npcFunctionDropdown = getglobal("MTH_BOOK_NPCFunctionDropdown") local npcZoneDropdown = getglobal("MTH_BOOK_NPCZoneDropdown") + local trainerStatusDropdown = MTH_BOOK_EnsureTrainerStatusDropdown() local searchLabel = getglobal("MTH_BOOK_SearchLabel") local minLabel = getglobal("MTH_BOOK_MinLabel") local maxLabel = getglobal("MTH_BOOK_MaxLabel") @@ -2291,6 +2479,7 @@ local function MTH_BOOK_UpdateQuickFilterControls() local hideNoAbilities = getglobal("MTH_BOOK_HideNoAbilities") local hideUnknown = getglobal("MTH_BOOK_HideUnknown") local petOnlyMyLevel = getglobal("MTH_BOOK_PetOnlyMyLevel") + local trainerHonoredDiscount = getglobal("MTH_BOOK_TrainerHonoredDiscount") local petInZoneOnly = getglobal("MTH_BOOK_PetInZoneOnly") local npcInZoneOnly = getglobal("MTH_BOOK_NPCInZoneOnly") local showAllOnMapButton = getglobal("MTH_BOOK_ShowAllOnMapButton") @@ -2301,6 +2490,7 @@ local function MTH_BOOK_UpdateQuickFilterControls() local nextButton = getglobal("MTH_BOOK_NextButton") local listSlider = getglobal("MTH_BOOK_ListSlider") local sliderBackdrop = getglobal("MTH_BOOK_ListSliderBackdrop") + if trainerStatusDropdown then trainerStatusDropdown:Hide() end if MTH_BOOK_STATE.mode == "petabilities" or MTH_BOOK_STATE.mode == "stable" then if stats then stats:Hide() end @@ -2475,6 +2665,59 @@ local function MTH_BOOK_UpdateQuickFilterControls() resetButton:ClearAllPoints() resetButton:SetPoint("TOPLEFT", resetButton:GetParent(), "TOPLEFT", 546, -80) end + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + if searchLabel then searchLabel:Show() end + if minLabel then minLabel:Show() end + if maxLabel then maxLabel:Show() end + if search then search:Show(); search:ClearAllPoints(); search:SetPoint("TOPLEFT", search:GetParent(), "TOPLEFT", 66, -80); search:SetWidth(160) end + if minLevel then minLevel:Show(); minLevel:ClearAllPoints(); minLevel:SetPoint("TOPLEFT", minLevel:GetParent(), "TOPLEFT", 302, -80) end + if maxLevel then maxLevel:Show(); maxLevel:ClearAllPoints(); maxLevel:SetPoint("TOPLEFT", maxLevel:GetParent(), "TOPLEFT", 414, -80) end + if applyButton then applyButton:Show(); applyButton:ClearAllPoints(); applyButton:SetPoint("TOPLEFT", applyButton:GetParent(), "TOPLEFT", 478, -80) end + local resetButton = getglobal("MTH_BOOK_ResetButton") + if resetButton then resetButton:Show(); resetButton:ClearAllPoints(); resetButton:SetPoint("TOPLEFT", resetButton:GetParent(), "TOPLEFT", 546, -80) end + if scanButton then scanButton:Hide() end + for i = 1, 6 do + local btn = getglobal("MTH_BOOK_Filter" .. i) + if btn then btn:Hide() end + end + if trainerStatusDropdown then + local labels = { all = "All", known = "Known", notlearned = "Not learned", notyet = "Not yet learnable" } + trainerStatusDropdown:ClearAllPoints() + trainerStatusDropdown:SetPoint("TOPLEFT", trainerStatusDropdown:GetParent(), "TOPLEFT", 150, -116) + MTH_BOOK_DropdownSetText(trainerStatusDropdown, labels[MTH_BOOK_STATE.quick] or "All") + trainerStatusDropdown:Show() + end + if familyDropdown then familyDropdown:Hide() end + if abilityDropdown then abilityDropdown:Hide() end + if rankDropdown then rankDropdown:Hide() end + if petLearnSourceDropdown then petLearnSourceDropdown:Hide() end + if beastZoneLabel then beastZoneLabel:Hide() end + if beastContinentDropdown then beastContinentDropdown:Hide() end + if beastZoneDropdown then beastZoneDropdown:Hide() end + if itemSubtypeDropdown then itemSubtypeDropdown:Hide() end + if npcFunctionDropdown then npcFunctionDropdown:Hide() end + if npcZoneDropdown then npcZoneDropdown:Hide() end + if hideNoAbilities then hideNoAbilities:Hide() end + if hideUnknown then hideUnknown:Hide() end + if petOnlyMyLevel then petOnlyMyLevel:Hide() end + if trainerHonoredDiscount then + trainerHonoredDiscount:ClearAllPoints() + trainerHonoredDiscount:SetPoint("TOPLEFT", trainerHonoredDiscount:GetParent(), "TOPLEFT", 18, -110) + trainerHonoredDiscount:SetChecked(MTH_BOOK_STATE.trainerHonoredDiscount and 1 or nil) + local trainerHonoredDiscountText = getglobal("MTH_BOOK_TrainerHonoredDiscountText") + if trainerHonoredDiscountText then trainerHonoredDiscountText:SetText("Honored prices") end + trainerHonoredDiscount:Show() + end + if petInZoneOnly then petInZoneOnly:Hide() end + if npcInZoneOnly then npcInZoneOnly:Hide() end + if showAllOnMapButton then showAllOnMapButton:Hide() end + local requireVendor = getglobal("MTH_BOOK_RequireVendor") + local requireDrop = getglobal("MTH_BOOK_RequireDrop") + local requireObject = getglobal("MTH_BOOK_RequireObject") + if requireVendor then requireVendor:Hide() end + if requireDrop then requireDrop:Hide() end + if requireObject then requireObject:Hide() end elseif MTH_BOOK_STATE.mode == "npcs" then if searchLabel then searchLabel:Show() end if minLabel then minLabel:Hide() end @@ -2700,6 +2943,9 @@ local function MTH_BOOK_UpdateQuickFilterControls() if MTH_BOOK_STATE.mode ~= "petabilities" and not MTH_BOOK_IsItemMode() and petOnlyMyLevel then petOnlyMyLevel:Hide() end + if (type(MTH_BOOKTAB_IsTrainerSpellMode) ~= "function" or not MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode)) and trainerHonoredDiscount then + trainerHonoredDiscount:Hide() + end if MTH_BOOK_STATE.mode ~= "pets" and petInZoneOnly then petInZoneOnly:Hide() end @@ -3202,13 +3448,20 @@ end function MTH_BOOK_SetDetailText(detail, text) if not detail then return end local wrapped = tostring(text or "") - if MTH_BOOK_STATE.mode ~= "pethistory" and MTH_BOOK_STATE.mode ~= "pets" and MTH_BOOK_STATE.mode ~= "abilities" then + if MTH_BOOK_STATE.mode ~= "pethistory" and MTH_BOOK_STATE.mode ~= "pets" and MTH_BOOK_STATE.mode ~= "abilities" + and (type(MTH_BOOKTAB_IsTrainerSpellMode) ~= "function" or not MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode)) then wrapped = MTH_BOOK_WrapDetailText(wrapped) end detail._mthLocking = true detail:SetText(wrapped) detail._mthLockedText = wrapped detail._mthLocking = false + local scroll = detail.mthTrainerDetailScroll + if scroll then + local textHeight = detail.GetStringHeight and detail:GetStringHeight() or 0 + detail:SetHeight(math.max(scroll:GetHeight(), textHeight + 12)) + scroll:SetVerticalScroll(0) + end end function MTH_BOOK_ConfigureReadOnlyEditBox(detail) @@ -3255,6 +3508,9 @@ end function MTH_BOOK_UpdateDetail() local detail = getglobal("MTH_BOOK_DetailBackdropDetailText") if not detail then return end + if detail.mthTrainerIcon then detail.mthTrainerIcon:Hide() end + local detailParent = detail:GetParent() + if detailParent and detailParent.mthTrainerPlan then detailParent.mthTrainerPlan:Hide() end MTH_BOOK_HideDetailTitle() -- Hide model skin preview when not in a beast-list mode if MTH_BOOK_STATE.mode ~= "pets" and MTH_BOOK_STATE.mode ~= "abilities" then @@ -3262,6 +3518,11 @@ function MTH_BOOK_UpdateDetail() end local petTop = MTH_BOOK_STATE.petUI and MTH_BOOK_STATE.petUI.detailTop local petBottom = MTH_BOOK_STATE.petUI and MTH_BOOK_STATE.petUI.detailBottom + if type(MTH_BOOKTAB_UpdateTrainerSpellDetail) == "function" + and MTH_BOOKTAB_UpdateTrainerSpellDetail(detail, MTH_BOOK_STATE.selectedEntry) then + MTH_BOOK_UpdateOpenMapButton() + return + end if not MTH_BOOK_STATE.selectedEntry then if MTH_BOOK_ModelViewer_Clear then MTH_BOOK_ModelViewer_Clear() end if MTH_BOOK_STATE.mode == "petabilities" then @@ -3963,6 +4224,12 @@ local function MTH_BOOK_ApplyColumnLayout(parent) end local function MTH_BOOK_GetRowValues(entry) + if type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + return type(MTH_BOOKTAB_GetTrainerSpellRowValues) == "function" + and MTH_BOOKTAB_GetTrainerSpellRowValues(entry) or { "", "", "", "", "" } + end + if MTH_BOOK_STATE.mode == "pets" then -- MetaHunt DB entry local beast = MTH_DS_Beasts and MTH_DS_Beasts[entry] @@ -4066,7 +4333,7 @@ local function MTH_BOOK_GetRowValues(entry) if not vendor then return { "", "", "", "", "" } end local react = MTH_BOOK_GetNPCReactBucket(vendor.fac) local functionSummary = MTH_BOOK_GetNPCFunctionSummary(vendor) - local zoneName = MTH_BOOK_GetNPCZoneSummary(vendor) + local zoneName = (type(MTH_BOOK_GetNPCZoneDisplay) == "function" and MTH_BOOK_GetNPCZoneDisplay(vendor)) or MTH_BOOK_GetNPCZoneSummary(vendor) local vendorName = (MTH and MTH.GetLocalizedNPCNameById and MTH:GetLocalizedNPCNameById(entry, vendor.name)) or vendor.name return { "|cFF33CCFF" .. tostring(entry) .. "|r", @@ -4249,6 +4516,9 @@ local function MTH_BOOK_AssignEntry(btn, entry) btn.entry = entry elseif MTH_BOOK_STATE.mode == "pethistory" then btn.entry = { petId = entry } + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" + and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + btn.entry = entry else btn.entry = { itemId = entry } end @@ -4414,11 +4684,18 @@ MTH_BOOK_UpdateResults = function() btn.itemIcon:SetTexture(nil) btn.itemIcon:Hide() end + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + local iconPath = MTH_BOOK_ResolveIconPath(entry.icon) + btn.itemIcon:ClearAllPoints() + btn.itemIcon:SetPoint("LEFT", btn, "LEFT", 8, 0) + btn.itemIcon:SetTexture(iconPath or "Interface\\Icons\\INV_Misc_QuestionMark") + btn.itemIcon:Show() else btn.itemIcon:SetTexture(nil) btn.itemIcon:Hide() end end + if btn.trainerCoinIcon then btn.trainerCoinIcon:Hide() end if btn.familyAbilityButtons then local abilityEntries = nil local abilityColIndex = nil @@ -4504,6 +4781,7 @@ MTH_BOOK_UpdateResults = function() btn.itemIcon:SetTexture(nil) btn.itemIcon:Hide() end + if btn.trainerCoinIcon then btn.trainerCoinIcon:Hide() end if btn.familyAbilityButtons then for chipIndex = 1, table.getn(btn.familyAbilityButtons) do local chip = btn.familyAbilityButtons[chipIndex] @@ -4560,6 +4838,11 @@ local function MTH_BOOK_UpdateModeLabels() check1 = "" check2 = "" check3 = "" + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + filterNames = { "All", "Known", "Not learned", "Not yet learnable", "", "" } + check1 = "" + check2 = "" + check3 = "" elseif MTH_BOOK_STATE.mode == "npcs" then filterNames = { "", "", "", "", "", "" } check1 = "Alliance" @@ -4690,6 +4973,7 @@ local function MTH_BOOK_ApplyInputs() MTH_BOOK_STATE.selectedPetRankEntry = nil MTH_BOOK_STATE.petRankRows = {} MTH_BOOK_RefreshFilter() + MTH_BOOK_AnimateSearchResults() end local function MTH_BOOK_SetQuickFilter(index) @@ -4700,6 +4984,9 @@ local function MTH_BOOK_SetQuickFilter(index) MTH_BOOK_STATE.quick = "all" elseif MTH_BOOK_STATE.mode == "petabilities" then MTH_BOOK_STATE.quick = "all" + elseif type(MTH_BOOKTAB_IsTrainerSpellMode) == "function" and MTH_BOOKTAB_IsTrainerSpellMode(MTH_BOOK_STATE.mode) then + local map = { [1] = "all", [2] = "known", [3] = "notlearned", [4] = "notyet" } + MTH_BOOK_STATE.quick = map[index] or "all" elseif MTH_BOOK_STATE.mode == "npcs" then MTH_BOOK_STATE.quick = "all" elseif MTH_BOOK_STATE.mode == "abilities" then @@ -4717,10 +5004,94 @@ end local function MTH_BOOK_StyleSectionTab(button) if not button then return end - button:SetHeight(24) + button:SetHeight(26) + button:SetNormalTexture(nil) + button:SetPushedTexture(nil) + button:SetHighlightTexture(nil) + button:SetDisabledTexture(nil) + if not button.mthMenuBackground then + button.mthMenuBackground = button:CreateTexture(nil, "BACKGROUND") + button.mthMenuBackground:SetAllPoints(button) + button.mthMenuBackground:SetTexture("Interface\\Buttons\\WHITE8X8") + end + local regions = { button:GetRegions() } + for _, region in ipairs(regions) do + if region ~= button.mthMenuBackground and region.GetObjectType + and region:GetObjectType() == "Texture" then + region:Hide() + end + end + button:SetScript("OnEnter", function() + if not this.mthSelected then + this.mthMenuBackground:SetVertexColor(0.20, 0.17, 0.06, 1.00) + end + end) + button:SetScript("OnLeave", function() + if not this.mthSelected then + this.mthMenuBackground:SetVertexColor(0.10, 0.10, 0.10, 1.00) + end + end) button:SetScript("OnShow", nil) end +function MTH_BOOK_EnsureVerticalNavigation(frame) + if not frame then return end + local contentPane = getglobal("MTH_BOOK_ContentPane") + if not contentPane then + contentPane = CreateFrame("Frame", "MTH_BOOK_ContentPane", frame) + contentPane:SetPoint("TOPLEFT", frame, "TOPLEFT", 112, 0) + contentPane:SetWidth(760) + contentPane:SetHeight(560) + + local navBackdrop = CreateFrame("Frame", "MTH_BOOK_NavBackdrop", frame) + navBackdrop:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -42) + navBackdrop:SetWidth(100) + navBackdrop:SetHeight(476) + navBackdrop:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, + tileSize = 16, + edgeSize = 16, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + navBackdrop:SetBackdropColor(0.07, 0.07, 0.07, 1.00) + navBackdrop:SetBackdropBorderColor(0.28, 0.28, 0.28, 1.00) + navBackdrop:SetFrameLevel(1) + + local contentChildren = { + "StatsText", "SearchLabel", "MinLabel", "MaxLabel", "BeastZoneLabel", + "ListBackdrop", "DetailBackdrop", "OpenMapButton", "TopAreaBackdrop", + "Search", "MinLevel", "MaxLevel", "ApplyButton", "PetLearnSourceDropdown", + "ItemSubtypeDropdown", "PetOnlyMyLevel", "PetBookScanButton", "ResetButton", "TrainerHonoredDiscount", + "Filter1", "Filter2", "Filter3", "Filter4", "Filter5", "Filter6", + "FamilyDropdown", "AbilityDropdown", "RankDropdown", "PetInZoneOnly", + "ShowAllOnMapButton", "NPCFunctionDropdown", "NPCInZoneOnly", "RequireVendor", + "RequireDrop", "RequireObject", "HideNoAbilities", "HideUnknown", + "BeastContinentDropdown", "BeastZoneDropdown", "ListSliderBackdrop", "ListSlider", + "PrevButton", "NextButton", + } + for _, suffix in ipairs(contentChildren) do + local child = getglobal("MTH_BOOK_" .. suffix) + if child and child:GetParent() == frame then + local point, relativeTo, relativePoint, x, y = child:GetPoint(1) + child:SetParent(contentPane) + if relativeTo == frame then + child:ClearAllPoints() + child:SetPoint(point, contentPane, relativePoint, x, y) + end + end + end + end + + local topAreaBackdrop = getglobal("MTH_BOOK_TopAreaBackdrop") + if topAreaBackdrop then + topAreaBackdrop:ClearAllPoints() + topAreaBackdrop:SetPoint("TOPLEFT", contentPane, "TOPLEFT", 0, -44) + topAreaBackdrop:SetWidth(760) + end +end + local function MTH_BOOK_UpdateSectionTabs() local sectionPets = getglobal("MTH_BOOK_SectionPets") local sectionFamilies = getglobal("MTH_BOOK_SectionFamilies") @@ -4731,13 +5102,18 @@ local function MTH_BOOK_UpdateSectionTabs() local sectionNPCs = getglobal("MTH_BOOK_SectionNPCs") local sectionStable = getglobal("MTH_BOOK_SectionStable") local sectionPetHistory = getglobal("MTH_BOOK_SectionPetHistory") + local sectionHunterSpells = getglobal("MTH_BOOK_SectionHunterSpells") + local sectionPetSpells = getglobal("MTH_BOOK_SectionPetSpells") local function setState(button, selected) if not button then return end + button.mthSelected = selected and true or false if selected then - PanelTemplates_SelectTab(button) + button.mthMenuBackground:SetVertexColor(0.38, 0.28, 0.05, 1.00) + button:SetTextColor(1.00, 0.82, 0.00) else - PanelTemplates_DeselectTab(button) + button.mthMenuBackground:SetVertexColor(0.10, 0.10, 0.10, 1.00) + button:SetTextColor(0.90, 0.90, 0.90) end end @@ -4750,6 +5126,8 @@ local function MTH_BOOK_UpdateSectionTabs() setState(sectionNPCs, MTH_BOOK_STATE.mode == "npcs") setState(sectionStable, MTH_BOOK_STATE.mode == "stable") setState(sectionPetHistory, MTH_BOOK_STATE.mode == "pethistory") + setState(sectionHunterSpells, MTH_BOOK_STATE.mode == "hunterspells") + setState(sectionPetSpells, MTH_BOOK_STATE.mode == "petspells") end local function MTH_BOOK_LayoutSectionTabs() @@ -4763,40 +5141,23 @@ local function MTH_BOOK_LayoutSectionTabs() local sectionNPCs = getglobal("MTH_BOOK_SectionNPCs") local sectionStable = getglobal("MTH_BOOK_SectionStable") local sectionPetHistory = getglobal("MTH_BOOK_SectionPetHistory") + local sectionHunterSpells = getglobal("MTH_BOOK_SectionHunterSpells") + local sectionPetSpells = getglobal("MTH_BOOK_SectionPetSpells") if not (tabBar and sectionPets and sectionFamilies and sectionPetAbilities and sectionItems and sectionNPCs and sectionProjectiles and sectionAmmoBags) then return end - sectionPets:ClearAllPoints() - sectionPets:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0) - - sectionPetAbilities:ClearAllPoints() - sectionPetAbilities:SetPoint("LEFT", sectionPets, "RIGHT", 2, 0) - - sectionFamilies:ClearAllPoints() - sectionFamilies:SetPoint("LEFT", sectionPetAbilities, "RIGHT", 2, 0) - - sectionNPCs:ClearAllPoints() - sectionNPCs:SetPoint("LEFT", sectionFamilies, "RIGHT", 2, 0) - - sectionItems:ClearAllPoints() - sectionItems:SetPoint("LEFT", sectionNPCs, "RIGHT", 2, 0) - - sectionProjectiles:ClearAllPoints() - sectionProjectiles:SetPoint("LEFT", sectionItems, "RIGHT", 2, 0) - - sectionAmmoBags:ClearAllPoints() - sectionAmmoBags:SetPoint("LEFT", sectionProjectiles, "RIGHT", 2, 0) - - if sectionStable then - sectionStable:ClearAllPoints() - sectionStable:SetPoint("LEFT", sectionAmmoBags, "RIGHT", 2, 0) - end - - if sectionPetHistory then - sectionPetHistory:ClearAllPoints() - if sectionStable then - sectionPetHistory:SetPoint("LEFT", sectionStable, "RIGHT", 2, 0) - else - sectionPetHistory:SetPoint("LEFT", sectionAmmoBags, "RIGHT", 2, 0) + local sections = { + sectionPets, sectionPetAbilities, sectionFamilies, sectionNPCs, sectionItems, + sectionProjectiles, sectionAmmoBags, sectionStable, sectionPetHistory, + sectionHunterSpells, sectionPetSpells, + } + local y = 0 + for _, section in ipairs(sections) do + if section then + section:ClearAllPoints() + section:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, y) + section:SetWidth(96) + section:SetHeight(26) + y = y - 28 end end end @@ -4929,6 +5290,7 @@ local function MTH_BOOK_SetMode(mode) MTH_BOOK_UpdateModeLabels() MTH_BOOK_UpdateSectionTabs() MTH_BOOK_RefreshFilter() + MTH_BOOK_AnimateTabSwitch() end function MTH_BOOK_JumpToBeastById(beastId) @@ -5234,6 +5596,7 @@ local function MTH_BOOK_WireUI(frame) frame:SetBackdropColor(0.05, 0.05, 0.05, 1.00) frame:SetBackdropBorderColor(0.35, 0.35, 0.35, 1.00) end + MTH_BOOK_EnsureVerticalNavigation(frame) local closeButton = getglobal("MTH_BOOK_CloseButton") local applyButton = getglobal("MTH_BOOK_ApplyButton") @@ -5247,6 +5610,9 @@ local function MTH_BOOK_WireUI(frame) local sectionNPCs = getglobal("MTH_BOOK_SectionNPCs") local sectionStable = getglobal("MTH_BOOK_SectionStable") local sectionPetHistory = getglobal("MTH_BOOK_SectionPetHistory") + local sectionHunterSpells = getglobal("MTH_BOOK_SectionHunterSpells") + local sectionPetSpells = getglobal("MTH_BOOK_SectionPetSpells") + local trainerHonoredDiscount = getglobal("MTH_BOOK_TrainerHonoredDiscount") local requireVendor = getglobal("MTH_BOOK_RequireVendor") local requireDrop = getglobal("MTH_BOOK_RequireDrop") local requireObject = getglobal("MTH_BOOK_RequireObject") @@ -5271,7 +5637,7 @@ local function MTH_BOOK_WireUI(frame) closeButton:SetWidth(22) closeButton:SetHeight(22) closeButton:SetText("X") - closeButton:SetScript("OnClick", function() this:GetParent():Hide() end) + closeButton:SetScript("OnClick", function() MTH_BOOK_FadeOutWindow(this:GetParent()) end) end if applyButton then applyButton:SetScript("OnClick", MTH_BOOK_ApplyInputs) end @@ -5288,6 +5654,14 @@ local function MTH_BOOK_WireUI(frame) if sectionNPCs then sectionNPCs:SetScript("OnClick", function() MTH_BOOK_SetMode("npcs") end) end if sectionStable then sectionStable:SetScript("OnClick", function() MTH_BOOK_SetMode("stable") end) end if sectionPetHistory then sectionPetHistory:SetScript("OnClick", function() MTH_BOOK_SetMode("pethistory") end) end + if sectionHunterSpells then sectionHunterSpells:SetScript("OnClick", function() MTH_BOOK_SetMode("hunterspells") end) end + if sectionPetSpells then sectionPetSpells:SetScript("OnClick", function() MTH_BOOK_SetMode("petspells") end) end + if trainerHonoredDiscount then + trainerHonoredDiscount:SetScript("OnClick", function() + MTH_BOOK_STATE.trainerHonoredDiscount = this:GetChecked() == 1 + MTH_BOOK_UpdateResults() + end) + end MTH_BOOK_StyleSectionTab(sectionPets) MTH_BOOK_StyleSectionTab(sectionFamilies) @@ -5298,7 +5672,10 @@ local function MTH_BOOK_WireUI(frame) MTH_BOOK_StyleSectionTab(sectionNPCs) MTH_BOOK_StyleSectionTab(sectionStable) MTH_BOOK_StyleSectionTab(sectionPetHistory) + MTH_BOOK_StyleSectionTab(sectionHunterSpells) + MTH_BOOK_StyleSectionTab(sectionPetSpells) MTH_BOOK_LayoutSectionTabs() + MTH_BOOK_ApplyContentLayoutForMode() for i = 1, 6 do local filterBtn = getglobal("MTH_BOOK_Filter" .. i) @@ -5314,6 +5691,7 @@ local function MTH_BOOK_WireUI(frame) MTH_BOOK_InitPetLearnSourceDropdown() MTH_BOOK_InitItemSubtypeDropdown() MTH_BOOK_InitNPCFunctionDropdown() + MTH_BOOK_EnsureTrainerStatusDropdown() if petBookScanButton then petBookScanButton:SetScript("OnClick", function() @@ -5661,11 +6039,59 @@ _G.MTH_BOOK_GetScopedRankSummary = MTH_BOOK_GetScopedRankSummary _G.MTH_BOOK_UpdateResults = MTH_BOOK_UpdateResults _G.MTH_BOOK_RefreshFilter = MTH_BOOK_RefreshFilter +-- On a blocked realm the whole book must be inert. Rather than rendering any pages we +-- drop an opaque "DISABLED." panel over the entire window (with its own close button so +-- the frame can still be dismissed). Returns true when the realm gate is active. +local function MTH_BOOK_UpdateRealmGateOverlay(frame) + if not frame then return false end + local blocked = (MTH and MTH.IsRealmGateBlocked and MTH:IsRealmGateBlocked()) and true or false + + local overlay = frame.realmGateOverlay + if not overlay then + if not blocked then return false end + + overlay = CreateFrame("Frame", "MTH_BOOK_RealmGateOverlay", frame) + overlay:SetAllPoints(frame) + overlay:EnableMouse(true) + overlay:EnableMouseWheel(true) + overlay:SetFrameStrata("FULLSCREEN_DIALOG") + if overlay.SetFrameLevel and frame.GetFrameLevel then + overlay:SetFrameLevel(frame:GetFrameLevel() + 50) + end + + local bg = overlay:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints(overlay) + bg:SetTexture("Interface\\Buttons\\WHITE8X8") + if bg.SetVertexColor then bg:SetVertexColor(0, 0, 0, 1) end + + local label = overlay:CreateFontString(nil, "OVERLAY") + label:SetPoint("CENTER", overlay, "CENTER", 0, 0) + if label.SetFont then label:SetFont("Fonts\\FRIZQT__.TTF", 48, "OUTLINE") end + label:SetText("DISABLED.") + label:SetTextColor(1.0, 0.1, 0.1) + + local close = CreateFrame("Button", nil, overlay, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", overlay, "TOPRIGHT", -4, -4) + close:SetScript("OnClick", function() frame:Hide() end) + + frame.realmGateOverlay = overlay + end + + if blocked then + overlay:Show() + else + overlay:Hide() + end + return blocked +end + function MTH_OpenHunterBook() if not MTH_HB_RequireOpenDeps() then return end local frame = MTH_BOOK_EnsureWindow() if not frame then return end frame:Show() + MTH_BOOK_FadeInWindow(frame) + if MTH_BOOK_UpdateRealmGateOverlay(frame) then return end MTH_BOOK_RefreshFilter() end @@ -5674,9 +6100,11 @@ function MTH_ToggleHunterBook() local frame = MTH_BOOK_EnsureWindow() if not frame then return end if frame:IsShown() then - frame:Hide() + MTH_BOOK_FadeOutWindow(frame) else frame:Show() + MTH_BOOK_FadeInWindow(frame) + if MTH_BOOK_UpdateRealmGateOverlay(frame) then return end MTH_BOOK_RefreshFilter() end end diff --git a/api/hunterbook.xml b/api/hunterbook.xml index 1126347..7da9855 100644 --- a/api/hunterbook.xml +++ b/api/hunterbook.xml @@ -2,7 +2,7 @@ ..\FrameXML\UI.xsd"> - + @@ -128,7 +128,7 @@ - + @@ -188,6 +188,18 @@ + + @@ -235,6 +247,12 @@ + +