Files
Suppai_NekoandClaude Opus 5.5 2b01e23955 Remove floating XP text from README features
The option to hide Blizzard's floating XP text was removed in 1.0.1, so
the Features section now only covers hiding the XP chat line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 07:50:00 -08:00

531 lines
21 KiB
Lua

--[[
================================================================================
XP Percent - XPPercent.lua (core)
================================================================================
Shows every XP gain as a percentage of your current level ("+1.25% XP")
instead of Blizzard's flat number ("XP: 200").
What this file does:
1. Saved settings - loads/creates XPPercentDB and fills in defaults.
2. Popup - the floating purple text that rises and fades out.
3. Drag-to-move - "unlock" mode that lets the player drag the popup.
4. Blizzard text - optionally hides Blizzard's own XP chat line.
5. XP tracking - compares XP before/after each gain to get the %.
6. Events - wires all of the above to game events.
7. Slash commands - /xpp and /xppercent.
The options window lives in Options.lua. The two files share functions
through the addon namespace table `ns` (see below).
================================================================================
--]]
-- Every addon file receives two values from the game when it loads:
-- ADDON - the addon's folder name ("XPPercent")
-- ns - a private table shared by all of this addon's files. We put
-- functions on it (ns.Show, ns.DB, ...) so Options.lua can use them
-- without creating global variables.
local ADDON, ns = ...
--------------------------------------------------------------------------------
-- 1. SAVED SETTINGS
--------------------------------------------------------------------------------
-- Default value for every setting. Anything missing from the saved settings
-- (first install, or a new option added in an update) is filled from here.
local defaults = {
hideChat = true, -- hide Blizzard's "You gain 200 experience." chat line
chat = false, -- also print our own "+1.25% XP" line in chat
popup = true, -- show our floating popup text
decimals = 2, -- digits after the decimal point (0-4)
size = 26, -- popup font size
x = 0, -- popup horizontal offset from screen center (pixels)
y = 60, -- popup vertical offset from screen center (pixels)
color = { r = 0.75, g = 0.4, b = 1 }, -- popup color (0-1 per channel), purple
}
ns.defaults = defaults -- Options.lua uses this for the "Reset" buttons
--[[
DB() - returns the saved settings table, creating it if needed.
XPPercentDB is a *global* listed under "## SavedVariables" in the .toc file.
The game saves it to disk on logout and restores it on the next login.
It is nil until saved variables load, so every caller goes through DB().
--]]
local function DB()
XPPercentDB = XPPercentDB or {}
-- Remove settings from older versions that no longer exist:
-- hideFloat - "hide Blizzard's floating XP text" (removed in 1.0.1)
-- debugLog - log from a debug command used during development
XPPercentDB.hideFloat = nil
XPPercentDB.debugLog = nil
-- Fill in any missing settings from the defaults.
for key, value in pairs(defaults) do
if XPPercentDB[key] == nil then
-- Tables (like `color`) must be copied. Otherwise the saved settings and
-- `defaults` would be the same table, so changing the color would also
-- change the default and "Reset color" would stop working.
if type(value) == "table" then
local copy = {}
for k2, v2 in pairs(value) do copy[k2] = v2 end
value = copy
end
XPPercentDB[key] = value
end
end
return XPPercentDB
end
ns.DB = DB
--------------------------------------------------------------------------------
-- 2. THE POPUP (floating "+1.25% XP" text)
--------------------------------------------------------------------------------
-- The container frame. It is attached to UIParent (the main game UI), so it
-- hides with the rest of the UI when the player presses Alt+Z.
local popup = CreateFrame("Frame", nil, UIParent)
popup:SetSize(300, 40) -- real size is set by ApplyStyle() once settings load
popup:SetPoint("CENTER", UIParent, "CENTER", 0, 60)
-- "Strata" is the drawing layer. DIALOG is above the options window, so the
-- "Test popup" button's preview isn't hidden behind it.
popup:SetFrameStrata("DIALOG")
-- The text itself. The real font size and color are applied in ApplyStyle().
popup.text = popup:CreateFontString(nil, "OVERLAY")
popup.text:SetFont(STANDARD_TEXT_FONT, 26, "OUTLINE")
popup.text:SetPoint("CENTER")
popup.text:SetTextColor(0.75, 0.4, 1)
popup:Hide() -- hidden until there's something to show
-- Animation: drift up 70 pixels over 1.6 seconds while fading out.
-- Both animations are in one group, so they play at the same time.
local ag = popup:CreateAnimationGroup()
local move = ag:CreateAnimation("Translation")
move:SetOffset(0, 70) -- move 0 px sideways, 70 px up
move:SetDuration(1.6) -- seconds
local fade = ag:CreateAnimation("Alpha")
fade:SetFromAlpha(1) -- fully visible...
fade:SetToAlpha(0) -- ...to invisible
fade:SetStartDelay(0.9) -- stay solid for 0.9 s first, so it's readable
fade:SetDuration(0.7) -- then fade over 0.7 s (0.9 + 0.7 = 1.6 s total)
-- When the animation ends, hide the frame. Translation/alpha effects only
-- apply while playing, so the frame snaps back to its normal spot invisibly.
ag:SetScript("OnFinished", function() popup:Hide() end)
--[[
ApplyStyle() - applies the saved font size, color and position to the popup.
Called at login and whenever a setting changes (options window, slash commands).
--]]
local function ApplyStyle()
local db = DB()
popup.text:SetFont(STANDARD_TEXT_FONT, db.size, "OUTLINE")
popup.text:SetTextColor(db.color.r, db.color.g, db.color.b)
-- Size the box to fit the font. You can't see the box normally, but in
-- drag-to-move mode it's the blue area you grab, so it should cover the text.
popup:SetSize(math.max(300, db.size * 11), db.size + 14)
-- Position is stored as an offset from the center of the screen. That keeps
-- it in roughly the same spot across different resolutions and UI scales.
popup:ClearAllPoints()
popup:SetPoint("CENTER", UIParent, "CENTER", db.x, db.y)
end
ns.ApplyStyle = ApplyStyle
--------------------------------------------------------------------------------
-- 3. DRAG-TO-MOVE MODE
--------------------------------------------------------------------------------
-- When unlocked, the popup stays on screen as a translucent blue box with
-- sample text, and the player can drag it with the left mouse button.
-- Releasing it saves the new position. Locking hides it again.
local unlocked = false -- true while drag-to-move mode is on
local SAMPLE = "+1.25% XP" -- placeholder text shown while unlocked
-- Translucent blue background so the player can see what to grab.
local moverBg = popup:CreateTexture(nil, "BACKGROUND")
moverBg:SetAllPoints() -- fill the whole popup frame
moverBg:SetColorTexture(0, 0.6, 1, 0.3) -- r, g, b, alpha (30% opaque blue)
moverBg:Hide()
-- Small instruction line shown just below the box.
local moverHint = popup:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
moverHint:SetPoint("TOP", popup, "BOTTOM", 0, -4)
moverHint:SetText("Drag to move. Click \"Lock popup\" in options or type /xpp lock when done.")
moverHint:Hide()
popup:SetMovable(true) -- allow StartMoving() on this frame
popup:SetClampedToScreen(true) -- can't be dragged off the edge of the screen
popup:EnableMouse(false) -- clicks pass through while locked (normal play)
popup:RegisterForDrag("LeftButton")
-- Start following the mouse when a drag begins (only while unlocked).
popup:SetScript("OnDragStart", function(self)
if unlocked then self:StartMoving() end
end)
-- Drag ended: work out where the box is relative to screen center and save it.
popup:SetScript("OnDragStop", function(self)
self:StopMovingOrSizing()
local db = DB()
local cx, cy = self:GetCenter() -- popup center
local ux, uy = UIParent:GetCenter() -- screen center
-- Round to whole pixels so the saved numbers are tidy.
db.x = math.floor(cx - ux + 0.5)
db.y = math.floor(cy - uy + 0.5)
-- StartMoving() re-anchors the frame to a screen corner. Re-apply our
-- center-based anchor using the offset we just saved.
ApplyStyle()
-- Let the options window update its "Position: x, y" readout (if loaded).
if ns.OnPositionChanged then ns.OnPositionChanged() end
end)
--[[
SetUnlocked(on) - turns drag-to-move mode on (true) or off (false).
--]]
local function SetUnlocked(on)
unlocked = on and true or false -- force a real boolean
ag:Stop() -- cancel any popup currently animating
popup:EnableMouse(unlocked) -- only catch the mouse while unlocked
moverBg:SetShown(unlocked)
moverHint:SetShown(unlocked)
if unlocked then
popup.text:SetText(SAMPLE)
popup:Show() -- stays visible until locked again
else
popup:Hide()
end
-- Update the options window's "Unlock popup"/"Lock popup" button text.
if ns.OnPositionChanged then ns.OnPositionChanged() end
end
ns.SetUnlocked = SetUnlocked
ns.IsUnlocked = function() return unlocked end
--------------------------------------------------------------------------------
-- 4. HIDING BLIZZARD'S OWN XP TEXT
--------------------------------------------------------------------------------
-- Only the chat line can be hidden. Earlier versions also tried to hide the
-- floating XP text by filtering Blizzard's CombatText frame. That never
-- caught it: on newer clients (confirmed on the _forever_ client) the
-- in-world "XP: 200" text is drawn by the game engine, which addons can't
-- intercept, so the option was removed.
--[[
XPFilter() - chat filter for Blizzard's "You gain 200 experience." line.
Chat filters return true to hide a message and false/nil to let it through.
Two deliberate limits:
- It never reads the message text. That text may be a secret value on
newer clients, and we don't need it anyway.
- It doesn't change any addon state, because the game calls it once for
*each* chat window showing XP messages, not once per message.
--]]
local function XPFilter()
return XPPercentDB and XPPercentDB.hideChat
end
--------------------------------------------------------------------------------
-- 5. SHOWING A GAIN + XP TRACKING
--------------------------------------------------------------------------------
--[[
Show(pct, kind) - displays one XP gain.
pct - percent of a level gained, e.g. 1.25
kind - label after the number: "XP" or "XP (quest)"
--]]
local function Show(pct, kind)
local db = DB()
-- Build "+1.25%". The format is assembled from the decimals setting, e.g.
-- decimals = 2 gives "+%.2f%%". (%% is a literal % sign in string.format.)
local s = string.format("+%." .. db.decimals .. "f%%", pct)
-- Optional chat line, in the same color as the popup.
-- |cffRRGGBB starts a color in WoW text and |r ends it; RRGGBB is hex 00-ff.
if db.chat then
local c = db.color
local hex = string.format("|cff%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255)
DEFAULT_CHAT_FRAME:AddMessage(hex .. s .. " " .. kind .. "|r")
end
if unlocked then
-- In drag-to-move mode, just update the text without animating, so the
-- box doesn't float away while the player is placing it.
popup.text:SetText(s .. " " .. kind)
elseif db.popup then
popup.text:SetText(s .. " " .. kind)
ag:Stop() -- restart from the beginning if a popup is already playing
popup:Show()
ag:Play()
end
end
ns.Show = Show
-- XP values from the previous update. Each gain is "now minus last time".
local lastXP, lastMax, lastLevel
-- Highest level PLAYER_LEVEL_UP has told us about. That event passes the new
-- level before UnitLevel() reports it, so this can be ahead of UnitLevel().
local knownLevel = 0
--[[
CurrentLevel() - the player's level, using whichever source is further
ahead: UnitLevel() or the last PLAYER_LEVEL_UP.
--]]
local function CurrentLevel()
return math.max(UnitLevel("player"), knownLevel)
end
-- Quest labelling: QUEST_TURNED_IN fires just before its XP arrives. We note
-- the time, and an XP gain within QUEST_WINDOW seconds is labelled
-- "XP (quest)". Quests that report no XP reward are skipped (see
-- QUEST_TURNED_IN below); the time limit is a backstop, so a label whose XP
-- never arrives can't mislabel some later, unrelated kill.
local pendingKind, pendingTime = "XP", 0
local QUEST_WINDOW = 2 -- seconds
--[[
Snapshot() - remembers current XP, XP needed for the level, and level.
--]]
local function Snapshot()
lastXP = UnitXP("player") -- XP earned so far this level
lastMax = UnitXPMax("player") -- XP needed to finish this level
lastLevel = CurrentLevel()
end
--[[
OnXPUpdate() - runs on every PLAYER_XP_UPDATE. Works out the % gained
since the last update and shows it.
--]]
local function OnXPUpdate()
local xp, max, lvl = UnitXP("player"), UnitXPMax("player"), CurrentLevel()
-- Need a previous snapshot to compare against. lastMax > 0 avoids dividing
-- by zero (UnitXPMax can be 0 at max level).
if lastXP and lastMax and lastMax > 0 then
local pct
-- LEVEL-UP CHECK. Either sign means the player leveled:
-- - XP went *down*: it reset to 0 plus the overflow. This is the usual
-- sign, and it works even when the game fires PLAYER_XP_UPDATE
-- *before* the new level shows up.
-- - The level went up: catches a gain worth more than a whole level,
-- where the new XP ends up *higher* than before (e.g. a huge quest
-- turn-in at a low level). "xp ~= lastXP" skips level changes that
-- came with no XP gain.
if xp < lastXP or (lvl > lastLevel and xp ~= lastXP) then
-- Total = what was left of the old level
-- + 100% for any whole levels skipped (rare; huge quest turn-ins)
-- + how far into the new level we are now.
-- lvl - lastLevel is 1 for a normal level-up, so extraLevels is usually 0.
-- math.max keeps it from going negative if the level hasn't caught up.
local extraLevels = math.max(0, (lvl - lastLevel) - 1)
pct = (lastMax - lastXP) / lastMax * 100
+ extraLevels * 100
+ (max > 0 and xp / max * 100 or 0)
else
-- Normal gain within the same level.
pct = (xp - lastXP) / lastMax * 100
end
-- Only show real gains (no-change updates happen too).
if pct > 0 then
local recentQuest = (GetTime() - pendingTime <= QUEST_WINDOW)
local kind = recentQuest and pendingKind or "XP"
Show(pct, kind)
-- The quest label has been used (or was stale), so clear it. This only
-- happens after a real gain: a no-change update arriving between the
-- turn-in and its XP must not wipe the label.
pendingKind, pendingTime = "XP", 0
end
end
-- Save this update's values for next time.
Snapshot()
end
--[[
QuestGivesXP(xpReward) - false only if QUEST_TURNED_IN says for certain the
quest gave no XP (e.g. at max level). If the value is missing (older
clients) or secret (can't be read), we assume it might give XP.
--]]
local function QuestGivesXP(xpReward)
if issecretvalue and issecretvalue(xpReward) then return true end
if type(xpReward) ~= "number" then return true end
return xpReward > 0
end
--------------------------------------------------------------------------------
-- 6. EVENTS
--------------------------------------------------------------------------------
-- An invisible frame that receives game events and passes them to the code above.
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN") -- logged in / UI reloaded; saved variables are ready
f:RegisterEvent("PLAYER_XP_UPDATE") -- XP changed
f:RegisterEvent("PLAYER_LEVEL_UP") -- leveled up
f:RegisterEvent("QUEST_TURNED_IN") -- turned in a quest (its XP arrives right after)
-- arg1, arg2 are the event's first two arguments, e.g. the new level for
-- PLAYER_LEVEL_UP, or the quest ID and XP reward for QUEST_TURNED_IN.
f:SetScript("OnEvent", function(_, event, arg1, arg2)
if event == "PLAYER_LOGIN" then
-- Make sure saved settings exist and have every default.
DB()
-- Register the chat filter that hides Blizzard's XP line. Newer clients
-- moved this function into ChatFrameUtil, so try that name first.
local add = (ChatFrameUtil and ChatFrameUtil.AddMessageEventFilter)
or ChatFrame_AddMessageEventFilter
if add then add("CHAT_MSG_COMBAT_XP_GAIN", XPFilter) end
-- Apply saved look/position, and add our page to the game's Options menu
-- (InitOptions comes from Options.lua).
ApplyStyle()
if ns.InitOptions then ns.InitOptions() end
-- Starting point for measuring the first gain.
Snapshot()
elseif event == "QUEST_TURNED_IN" then
-- Label the next XP gain (if it arrives within QUEST_WINDOW) as quest XP.
-- arg2 is the quest's XP reward. Skip quests that gave none, so a later
-- kill isn't mislabelled.
if QuestGivesXP(arg2) then
pendingKind, pendingTime = "XP (quest)", GetTime()
end
elseif event == "PLAYER_LEVEL_UP" then
-- arg1 is the new level. UnitLevel may not show it yet, so remember it.
if type(arg1) == "number" and not (issecretvalue and issecretvalue(arg1)) then
knownLevel = math.max(knownLevel, arg1)
-- If XP still equals our last snapshot, this level-up's XP gain has
-- already been counted (it arrived before this event). Catch lastLevel
-- up now, or the next gain would look like another level-up.
-- Otherwise the gain is still on its way, and OnXPUpdate will see the
-- higher level and count it as a level-up.
if lastXP and UnitXP("player") == lastXP and lastLevel then
lastLevel = math.max(lastLevel, arg1)
end
end
-- UnitXPMax can still return the *old* level's max right after leveling.
-- Re-read max and level after 1 second, so the next gain is divided by
-- the new level's max. Only these two are updated; lastXP stays correct.
-- math.max: never move lastLevel backwards.
if C_Timer and C_Timer.After then
C_Timer.After(1, function()
lastMax = UnitXPMax("player")
lastLevel = math.max(lastLevel or 0, CurrentLevel())
end)
end
elseif event == "PLAYER_XP_UPDATE" then
OnXPUpdate()
end
end)
--------------------------------------------------------------------------------
-- 7. SLASH COMMANDS (/xpp and /xppercent)
--------------------------------------------------------------------------------
-- The game links SLASH_XPPERCENT1/2 to SlashCmdList.XPPERCENT by name.
-- `msg` is whatever was typed after the command, e.g. "size 30".
SLASH_XPPERCENT1 = "/xpp"
SLASH_XPPERCENT2 = "/xppercent"
SlashCmdList.XPPERCENT = function(msg)
local db = DB()
-- Lower-case and strip spaces at both ends, so " Size 30 " works too.
msg = (msg or ""):lower():match("^%s*(.-)%s*$")
if msg == "chat" then
-- Toggle our own % line in chat.
db.chat = not db.chat
print("XP Percent chat line:", db.chat and "on" or "off")
elseif msg == "popup" then
-- Toggle the floating popup.
db.popup = not db.popup
print("XP Percent popup:", db.popup and "on" or "off")
elseif msg == "blizz" then
-- Toggle hiding Blizzard's "You gain 200 experience." chat line.
db.hideChat = not db.hideChat
print("Blizzard XP chat message hidden:", db.hideChat and "yes" or "no")
elseif msg:match("^size %d+") then
-- "/xpp size 30": set the font size and show a preview. Kept within
-- 10-60, the same range as the slider in the options window.
db.size = math.max(10, math.min(60, tonumber(msg:match("%d+"))))
ApplyStyle()
Show(0.5, "XP")
elseif msg:match("^pos %-?%d+ %-?%d+") then
-- "/xpp pos 0 60": set an exact position (offset from screen center;
-- negative numbers allowed) and show a preview.
local x, y = msg:match("(%-?%d+) (%-?%d+)")
db.x, db.y = tonumber(x), tonumber(y)
ApplyStyle()
Show(0.5, "XP")
if ns.OnPositionChanged then ns.OnPositionChanged() end
elseif msg == "unlock" or msg == "move" then
SetUnlocked(true) -- start drag-to-move mode
elseif msg == "lock" then
SetUnlocked(false) -- end drag-to-move mode
elseif msg == "" or msg == "options" or msg == "config" then
-- Bare "/xpp" opens the options window (from Options.lua).
if ns.OpenOptions then ns.OpenOptions() end
elseif msg == "test" then
Show(0.5, "XP") -- sample popup
elseif tonumber(msg) then
-- "/xpp 1": number of decimal places. Rounded down and kept within 0-4,
-- because a fraction like 1.5 would build an invalid format string.
db.decimals = math.max(0, math.min(4, math.floor(tonumber(msg))))
print("XP Percent decimals:", db.decimals)
else
-- Anything else (including "/xpp help"): list the commands.
print("|cff9966ffXP Percent|r commands:")
print(" /xpp - open the options window")
print(" /xpp chat - toggle the % line in chat")
print(" /xpp popup - toggle the floating % on screen")
print(" /xpp blizz - toggle hiding Blizzard's flat XP chat message")
print(" /xpp 0-4 - number of decimal places")
print(" /xpp test - show a sample popup")
print(" /xpp size 26 - popup text size")
print(" /xpp unlock / lock - drag the popup to move it")
print(" /xpp pos 0 60 - popup position (x y from screen center)")
end
end