Files

2524 lines
93 KiB
Lua

-- multi api compat
local compat = pfQuestCompat
-- Performance: cache frequently-used globals
local pairs, ipairs, next = pairs, ipairs, next
local strfind, strlower, strsub = strfind, strlower, strsub
local format = string.format
local min, max, abs = math.min, math.max, math.abs
local sqrt, sin, cos = sqrt or math.sqrt, sin or math.sin, cos or math.cos
local floor, ceil = floor or math.floor, ceil or math.ceil
local getn, insert = table.getn, table.insert
local tostring, tonumber, type, unpack = tostring, tonumber, type, unpack
local GetTime = GetTime
local MouseIsOver = MouseIsOver
-- fake the pfQuest minimap node names to Gatherer names,
-- if any minimap-breaking addon collector is found.
local nodename = "pfMiniMapPin"
local minimapbreakers = {
["ElvUI_MinimapButtons"] = true,
["MBB"] = true,
}
local compatnamefake = CreateFrame("Frame")
compatnamefake:RegisterEvent("PLAYER_ENTERING_WORLD")
compatnamefake:SetScript("OnEvent", function()
-- only run once on login
this:UnregisterAllEvents()
-- scan through all addons to identify button collectors
for i = 1, GetNumAddOns() do
local name, title, notes, enabled = GetAddOnInfo(i)
if enabled and minimapbreakers[name] then
nodename = "GatherNoteCompatFake"
end
end
end)
-- checking for control key is very time expensive in 1.12
-- this loop puts it into one place and only updates it every .2 seconds
-- it also only updates the key if the mouse is over a relevant frame
local controlkey = CreateFrame("Frame", "pfQuestControlKey", UIParent)
controlkey:SetScript("OnUpdate", function()
if (this.throttle or 0.2) > GetTime() then
return
else
this.throttle = GetTime() + 0.2
end
if WorldMapFrame:IsShown() and MouseIsOver(WorldMapFrame) or MouseIsOver(pfMap.drawlayer) then
controlkey.pressed = IsControlKeyDown()
end
end)
local validmaps = setmetatable({}, { __mode = "kv" })
local rgbcache = setmetatable({}, { __mode = "kv" })
local minimap_sizes = pfDB["minimap"]
local minimap_zoom = {
[0] = {
[0] = 300,
[1] = 240,
[2] = 180,
[3] = 120,
[4] = 80,
[5] = 50,
},
[1] = {
[0] = 466 + 2 / 3,
[1] = 400,
[2] = 333 + 1 / 3,
[3] = 266 + 2 / 6,
[4] = 200,
[5] = 133 + 1 / 3,
},
}
local unifiedcache = {}
-- used to store/cache combined meta data across nodes of
-- the same kind to avoid duplicating data for each pin
-- the objects here get directly attached to the pfMap nodes
local similar_nodes = {}
-- Coordinate parse cache (shared between UpdateNodes and UpdateMinimap)
local coord_cache = {}
local function IsEmpty(tabl)
for k, v in pairs(tabl) do
return false
end
return true
end
-- Ensure pfQuestConfig.path exists (fallback if config.lua failed)
local addon_path = (pfQuestConfig and pfQuestConfig.path) or "Interface\\AddOns\\pfQuest"
local layers = {
-- regular icons
[addon_path .. "\\img\\available"] = 1,
[addon_path .. "\\img\\available_c"] = 2,
[addon_path .. "\\img\\complete"] = 3,
[addon_path .. "\\img\\complete_c"] = 4,
[addon_path .. "\\img\\icon_vendor"] = 5,
[addon_path .. "\\img\\fav"] = 6,
-- cluster textures
[addon_path .. "\\img\\cluster_item"] = 9,
[addon_path .. "\\img\\cluster_mob"] = 9,
[addon_path .. "\\img\\cluster_misc"] = 9,
[addon_path .. "\\img\\cluster_mob_mono"] = 9,
[addon_path .. "\\img\\cluster_item_mono"] = 9,
[addon_path .. "\\img\\cluster_misc_mono"] = 9,
}
-- Pre-computed texture paths (avoid string concatenation in hot paths)
local TEX_NODECUT = addon_path .. "\\img\\nodecut"
local TEX_NODE = addon_path .. "\\img\\node"
-- Party-quest pins have no meta.texture of their own (that is what makes them
-- recolorable via the same click-to-recolor path as an ordinary spawn dot),
-- but they still get a distinct base shape so they read as "party" at a
-- glance: the existing star icon, tinted the same way TEX_NODE/TEX_NODECUT
-- are below instead of drawn with a fixed color.
local TEX_STAR = addon_path .. "\\img\\fav"
local function GetLayerByTexture(tex)
if layers[tex] then
return layers[tex]
else
return 1
end
end
local function minimap_indoor()
local tempzoom = 0
local state = 1
if GetCVar("minimapZoom") == GetCVar("minimapInsideZoom") then
if GetCVar("minimapInsideZoom") + 0 >= 3 then
pfMap.drawlayer:SetZoom(pfMap.drawlayer:GetZoom() - 1)
tempzoom = 1
else
pfMap.drawlayer:SetZoom(pfMap.drawlayer:GetZoom() + 1)
tempzoom = -1
end
end
if GetCVar("minimapInsideZoom") + 0 == pfMap.drawlayer:GetZoom() then
state = 0
end
pfMap.drawlayer:SetZoom(pfMap.drawlayer:GetZoom() + tempzoom)
return state
end
local function str2rgb(text)
if not text then
return 1, 1, 1
end
if pfQuest_colors[text] then
return unpack(pfQuest_colors[text])
end
if rgbcache[text] then
return unpack(rgbcache[text])
end
local counter = 1
local l = string.len(text)
for i = 1, l, 3 do
counter = compat.mod(counter * 8161, 4294967279)
+ (string.byte(text, i) * 16776193)
+ ((string.byte(text, i + 1) or (l - i + 256)) * 8372226)
+ ((string.byte(text, i + 2) or (l - i + 256)) * 3932164)
end
local hash = compat.mod(compat.mod(counter, 4294967291), 16777216)
local r = (hash - (compat.mod(hash, 65536))) / 65536
local g = ((hash - r * 65536) - (compat.mod((hash - r * 65536), 256))) / 256
local b = hash - r * 65536 - g * 256
rgbcache[text] = { r / 255, g / 255, b / 255 }
return unpack(rgbcache[text])
end
local fpsmod, step
local function NodeAnimate(self, zoom, alpha, fps)
local cur_zoom = self:GetWidth()
local cur_alpha = self:GetAlpha()
local change = nil
self:EnableMouse(true)
fpsmod = math.min(2 / fps, 2)
step = fpsmod / 10
-- update size
if math.abs(cur_zoom - zoom) < 3 then
self:SetWidth(zoom)
self:SetHeight(zoom)
elseif cur_zoom < zoom then
self:SetWidth(cur_zoom + fpsmod)
self:SetHeight(cur_zoom + fpsmod)
change = true
elseif cur_zoom > zoom then
self:SetWidth(cur_zoom - fpsmod)
self:SetHeight(cur_zoom - fpsmod)
change = true
end
-- update alpha
if math.abs(cur_alpha - alpha) < step then
self:SetAlpha(alpha)
-- disable mouse on hidden
if alpha < 0.1 then
self:EnableMouse(nil)
end
elseif cur_alpha < alpha then
self:SetAlpha(cur_alpha + step)
change = true
elseif cur_alpha > alpha then
self:SetAlpha(cur_alpha - step)
change = true
end
return change
end
-- put player position above everything on worldmap
for k, v in pairs({ WorldMapFrame:GetChildren() }) do
if v:IsObjectType("Model") and not v:GetName() then
if string.find(strlower(v:GetModel()), "interface\\minimap\\minimaparrow") then
v:SetFrameLevel(255)
break
end
end
end
pfMap = CreateFrame("Frame", "pfQuestMap", WorldFrame)
pfMap.str2rgb = str2rgb
pfMap.tooltips = {}
pfMap.nodes = {}
pfMap.pins = {}
pfMap.mpins = {}
-- Minimap frames are retained by their node-table identity. The old numeric
-- pool reassigned every following frame when a quest completion removed one
-- visible node, which made a small quest update rebuild a large marker set.
-- Weak keys allow deleted node tables to be collected normally.
pfMap.mpinNodeIndex = setmetatable({}, { __mode = "k" })
pfMap.drawlayer = Minimap
pfMap.unifiedcache = unifiedcache
-- Reverse indexes for O(1) DeleteNode lookups.
-- titleIndex[addon][title][map][coords] = true — set by AddNode
-- tooltipIndex[title][spawn] = true — set by AddNode
pfMap.titleIndex = {}
pfMap.tooltipIndex = {}
-- Set of node tables that have been modified since the last UpdateNodes call.
-- Keyed by node table reference so the node table itself stays clean.
-- AddNode/DeleteNode insert here; UpdateNodes reads and clears entries.
pfMap.dirtyNodes = {}
-- World-map rendering clears dirtyNodes. Keep minimap work separate so opening
-- the World Map cannot consume an objective refresh before the minimap sees it.
pfMap.dirtyMinimapNodes = {}
-- Set of map IDs that have at least one dirty node table.
-- Keyed by zone map ID (integer). Allows WORLD_MAP_UPDATE to cheaply check
-- whether the current zone has pending writes without scanning all dirtyNodes.
pfMap.dirtyMaps = {}
pfMap.minimap_indoor = minimap_indoor
pfMap.minimap_zoom = minimap_zoom
pfMap.minimap_sizes = minimap_sizes
pfMap.tooltip = CreateFrame("Frame", "pfMapTooltip", GameTooltip)
pfMap.tooltip:SetScript("OnShow", function()
local focus = GetMouseFocus()
-- abort on pfQuest nodes
if focus and focus.title then
return
end
-- abort on quest timers
if focus and focus.GetName and strsub((focus:GetName() or ""), 0, 10) == "QuestTimer" then
return
end
-- abort if tooltips are disabled
if pfQuest_config.showtooltips == "0" then
return
end
local name = getglobal("GameTooltipTextLeft1") and getglobal("GameTooltipTextLeft1"):GetText() or "__NONE__"
local zone = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
-- remove all colors from received tooltip text
name = string.gsub(name, "|c%x%x%x%x%x%x%x%x", "")
name = string.gsub(name, "|r", "")
if pfMap.tooltips[name] and pfMap.tooltips[name] then
for title, obj in pairs(pfMap.tooltips[name]) do
-- PFPARTY registers here too (it sets meta.spawn so its own map-pin
-- tooltip keeps a real name instead of "Unknown"), but it has its own
-- richer world-unit tooltip addition (HookGameTooltip in
-- pfQuest-partyprogress.lua, with per-player progress). Showing both
-- duplicated the "[!] QuestName" block on the same creature's tooltip.
if obj[zone] and obj[zone].addon ~= "PFPARTY" then
pfMap:ShowTooltip(obj[zone], GameTooltip)
GameTooltip:Show()
end
end
end
end)
-- dummy function that can be used by extensions
-- to avoid drawing the minimap at some locations
function pfMap:HasMinimap()
return true
end
function pfMap.tooltip:GetColor(min, max)
local max = max or 1
local min = min or max or 1
local perc = min / max
local r1, g1, b1, r2, g2, b2
if perc <= 0.5 then
perc = perc * 2
r1, g1, b1 = 1, 0, 0
r2, g2, b2 = 1, 1, 0
else
perc = perc * 2 - 1
r1, g1, b1 = 1, 1, 0
r2, g2, b2 = 0, 1, 0
end
r = r1 + (r2 - r1) * perc
g = g1 + (g2 - g1) * perc
b = b1 + (b2 - b1) * perc
return r, g, b
end
function pfMap:HexDifficultyColor(level, force)
-- Quest levels can include a suffix such as "19+". The color helper needs
-- a number, while callers still retain the original value for display.
local numericLevel = tonumber(level) or tonumber(string.match(tostring(level or ""), "%d+"))
if not numericLevel then return "|cffffffff" end
if force and UnitLevel("player") < numericLevel then
return "|cffff5555"
else
local c = pfQuestCompat.GetDifficultyColor(numericLevel)
return string.format("|cff%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255)
end
end
local function ObjectiveNameMatches(spawn, objective)
spawn = string.lower(tostring(spawn or ""))
objective = string.lower(tostring(objective or ""))
if spawn == "" or objective == "" then return false end
if spawn == objective then return true end
-- Some quest-log rows pluralize a creature name even though the unit
-- database and tooltip use its singular form (Harvest Watcher/Watchers).
return objective == spawn .. "s" or spawn == objective .. "s"
or objective == spawn .. "es" or spawn == objective .. "es"
end
function pfMap:ShowTooltip(meta, tooltip)
local catch = nil
local catch_obj = nil
local tooltip = tooltip or GameTooltip
-- add quest data
if meta["quest"] then
-- scan all quest entries for matches
for qid = 1, GetNumQuestLogEntries() do
local qtitle, _, _, _, _, complete = compat.GetQuestLogTitle(qid)
if meta["quest"] == qtitle then
-- handle active quests
local objectives = GetNumQuestLeaderBoards(qid)
catch = true
local symbol = (complete or objectives == 0) and "|cff555555[|cffffcc00?|cff555555]|r "
or "|cff555555[|cffffcc00!|cff555555]|r "
tooltip:AddLine(symbol .. meta["quest"], 1, 1, 0)
if objectives then
for i = 1, objectives, 1 do
local text, type, finished = GetQuestLogLeaderBoard(i, qid)
if type == "monster" then
-- kill
local i, j, monsterName, objNum, objNeeded =
strfind(text, pfUI.api.SanitizePattern(QUEST_MONSTERS_KILLED))
if monsterName and ObjectiveNameMatches(meta["spawn"], monsterName) then
catch_obj = true
local r, g, b = pfMap.tooltip:GetColor(objNum, objNeeded)
tooltip:AddLine("|cffaaaaaa- |r" .. monsterName .. ": " .. objNum .. "/" .. objNeeded, r, g, b)
end
elseif meta["QTYPE"] == "OBJECT_OBJECTIVE" and (type == "object" or type == "item") then
-- Direct object objectives use the same localized progress
-- format as item objectives, but have no drop-rate item entry.
local _, _, objectName, objNum, objNeeded =
strfind(text, pfUI.api.SanitizePattern(QUEST_OBJECTS_FOUND))
if objectName and meta["spawn"] == objectName then
catch_obj = true
local r, g, b = pfMap.tooltip:GetColor(objNum, objNeeded)
tooltip:AddLine("|cffaaaaaa- |r" .. objectName .. ": " .. objNum .. "/" .. objNeeded, r, g, b)
end
elseif table.getn(meta["item"]) > 0 and type == "item" and meta["droprate"] then
-- loot
local i, j, itemName, objNum, objNeeded = strfind(text, pfUI.api.SanitizePattern(QUEST_OBJECTS_FOUND))
for mid, item in pairs(meta["item"]) do
if item == itemName then
catch_obj = true
local r, g, b = pfMap.tooltip:GetColor(objNum, objNeeded)
local dr, dg, db = pfMap.tooltip:GetColor(tonumber(meta["droprate"]), 100)
local lootcolor = string.format("%02x%02x%02x", dr * 255, dg * 255, db * 255)
tooltip:AddLine(
"|cffaaaaaa- |r"
.. itemName
.. ": "
.. objNum
.. "/"
.. objNeeded
.. " |cff555555[|cff"
.. lootcolor
.. meta["droprate"]
.. "%|cff555555]",
r,
g,
b
)
end
end
elseif table.getn(meta["item"]) > 0 and type == "item" and meta["sellcount"] then
-- vendor
local i, j, itemName, objNum, objNeeded = strfind(text, pfUI.api.SanitizePattern(QUEST_OBJECTS_FOUND))
for mid, item in pairs(meta["item"]) do
if item == itemName then
catch_obj = true
local r, g, b = pfMap.tooltip:GetColor(objNum, objNeeded)
local sellcount = tonumber(meta["sellcount"]) > 0
and " |cff555555[|cffcccccc" .. meta["sellcount"] .. "x" .. "|cff555555]"
or ""
tooltip:AddLine(
"|cffaaaaaa- |r"
.. pfQuest_Loc["Buy"]
.. ": "
.. itemName
.. ": "
.. objNum
.. "/"
.. objNeeded
.. sellcount,
r,
g,
b
)
end
end
end
end
end
end
end
if not catch then
tooltip:AddLine("|cff555555[|cffffcc00!|cff555555]|r " .. meta["quest"], 1, 1, 0.7)
end
if not catch_obj then
-- handle inactive quests
local catchFallback = nil
if meta["item"] and meta["item"][1] and meta["droprate"] then
for mid, item in pairs(meta["item"]) do
catchFallback = true
local dr, dg, db = pfMap.tooltip:GetColor(tonumber(meta["droprate"]), 100)
local lootcolor = string.format("%02x%02x%02x", dr * 255, dg * 255, db * 255)
tooltip:AddLine(
"|cffaaaaaa- |r" .. item .. " |cff555555[|cff" .. lootcolor .. meta["droprate"] .. "%|cff555555]",
0.7,
0.7,
0.7
)
end
end
if meta["item"] and meta["item"][1] and meta["sellcount"] then
for mid, item in pairs(meta["item"]) do
catchFallback = true
local sellcount = tonumber(meta["sellcount"]) > 0
and " |cff555555[|cffcccccc" .. meta["sellcount"] .. "x" .. "|cff555555]"
or ""
tooltip:AddLine("|cffaaaaaa- |r" .. pfQuest_Loc["Buy"] .. ": " .. item .. sellcount, 0.7, 0.7, 0.7)
end
end
if not catchFallback and meta["spawn"] and not meta["texture"] then
catchFallback = true
tooltip:AddLine(
"|cffaaaaaa- |r"
.. (meta["spawntype"] and meta["spawntype"] == "Trigger" and pfQuest_Loc["Explore"] or meta["spawn"]),
0.7,
0.7,
0.7
)
end
if not catchFallback and meta["texture"] and meta["qlvl"] then
local texts = meta["questid"] and pfDB["quests"]["loc"][meta["questid"]] or nil
local objective = meta["questObjective"]
if (not objective or objective == "") and texts then objective = texts["O"] end
if objective and objective ~= "" then
tooltip:AddLine(pfDatabase:FormatQuestText(objective), 1, 1, 0.9, true)
end
local qlvlstr = pfQuest_Loc["Level"] .. ": " .. pfMap:HexDifficultyColor(meta["qlvl"]) .. meta["qlvl"] .. "|r"
local qminstr = meta["qmin"]
and " / " .. pfQuest_Loc["Required"] .. ": " .. pfMap:HexDifficultyColor(meta["qmin"], true) .. meta["qmin"] .. "|r"
or ""
tooltip:AddLine("|cffaaaaaa- |r" .. qlvlstr .. qminstr, 0.8, 0.8, 0.8)
end
end
else
-- handle non-quest objects
if meta["item"][1] and meta["itemid"] and not meta["itemlink"] then
local _, _, itemQuality = GetItemInfo(meta["itemid"])
if itemQuality then
local itemColor = "|c"
.. string.format(
"%02x%02x%02x%02x",
255,
ITEM_QUALITY_COLORS[itemQuality].r * 255,
ITEM_QUALITY_COLORS[itemQuality].g * 255,
ITEM_QUALITY_COLORS[itemQuality].b * 255
)
meta["itemlink"] = itemColor .. "|Hitem:" .. meta["itemid"] .. ":0:0:0|h[" .. meta["item"][1] .. "]|h|r"
end
end
if meta["sellcount"] then
local item = meta["itemlink"] or "[" .. meta["item"][1] .. "]"
local sellcount = tonumber(meta["sellcount"]) > 0
and " |cff555555[|cffcccccc" .. meta["sellcount"] .. "x" .. "|cff555555]"
or ""
tooltip:AddLine(pfQuest_Loc["Vendor"] .. ": " .. item .. sellcount, 1, 1, 1)
elseif meta["item"][1] then
local item = meta["itemlink"] or "[" .. meta["item"][1] .. "]"
local r, g, b = pfMap.tooltip:GetColor(tonumber(meta["droprate"]), 100)
tooltip:AddLine(
"|cffffffff" .. pfQuest_Loc["Loot"] .. ": " .. item .. " |cff555555[|r" .. meta["droprate"] .. "%|cff555555]",
r,
g,
b
)
end
end
tooltip:Show()
end
function pfMap:GetMapNameByID(id)
id = tonumber(id)
return pfDB["zones"]["loc"][id] or nil
end
function pfMap:GetMapIDByName(search)
for id, name in pairs(pfDB["zones"]["loc"]) do
if name == search then
return id
end
end
end
function pfMap:ShowMapID(map)
if map then
local wasShown = WorldMapFrame:IsShown()
if ToggleWorldMap then
-- vanilla & tbc
if not wasShown then
ToggleWorldMap()
end
else
-- wotlk
WorldMapFrame:Show()
end
if wasShown then
pfMap:SetMapByID(map)
pfMap:UpdateNodes()
else
-- Blizzard's own World Map frame has not finished laying itself out on
-- the same frame it is first shown. Zooming immediately afterward can
-- read a nil GetCenter() from its positioning guide and error inside
-- Blizzard's FrameXML. Give it one frame to settle before zooming.
local deferFrame = CreateFrame("Frame")
deferFrame:SetScript("OnUpdate", function()
this:SetScript("OnUpdate", nil)
pfMap:SetMapByID(map)
pfMap:UpdateNodes()
end)
end
return true
end
return nil
end
function pfMap:SetMapByID(id)
local search = pfDB["zones"]["loc"][id]
for cid, cname in pairs({ GetMapContinents() }) do
for mid, mname in pairs({ GetMapZones(cid) }) do
if mname == search then
SetMapZoom(cid, mid)
return
end
end
end
end
local customids = {
["AlteracValley"] = 2597,
}
local map_zone_cache = {}
function pfMap:GetMapID(cid, mid)
cid = cid or GetCurrentMapContinent()
mid = mid or GetCurrentMapZone()
-- Capital maps are not listed by GetMapZones in the 1.12 client. Prefer
-- ClassicAPI's direct area ID when available, then use the database name as
-- a clean-client fallback.
local apiMapID = pfQuestCompat.GetCurrentMapAreaID and pfQuestCompat.GetCurrentMapAreaID()
if apiMapID then return apiMapID end
-- GetMapZones() should always return the same amount
-- of zones for each continent, so we can cache it to
-- avoid further creations of the same table.
if not map_zone_cache[cid] then
map_zone_cache[cid] = { GetMapZones(cid) }
end
local list = map_zone_cache[cid]
local name = list[mid]
local id = pfMap:GetMapIDByName(name)
id = id or pfMap:GetMapIDByName(GetMapInfo())
id = id or customids[GetMapInfo()]
return id
end
-- A map returned by GetMapZones is a normal world/zone surface. Browser
-- searches can find an NPC both outside an instance and just inside it; when
-- their source counts tie, opening the outdoor zone is the useful default.
local zoneMapCache = {}
function pfMap:IsZoneMapID(id)
id = tonumber(id)
if not id then return false end
if zoneMapCache[id] ~= nil then return zoneMapCache[id] end
for cid in pairs({ GetMapContinents() }) do
for _, name in pairs({ GetMapZones(cid) }) do
if pfMap:GetMapIDByName(name) == id then
zoneMapCache[id] = true
return true
end
end
end
zoneMapCache[id] = false
return false
end
function pfMap:AddNode(meta)
if not meta then
return
end
if not meta["zone"] then
return
end
if not meta["title"] then
return
end
-- only compute description if the caller hasn't already done it
-- (SearchMobID / SearchObjectID hoist this call outside their coord loops)
if meta["description"] == nil then
meta["description"] = pfDatabase:BuildQuestDescription(meta)
end
local addon = meta["addon"] or "PFDB"
local map = meta["zone"]
local coords = meta["x"] .. "|" .. meta["y"]
local title = meta["title"]
local layer = GetLayerByTexture(meta["texture"])
local spawn = meta["spawn"]
local item = meta["item"]
local sindex = string.format(
"%s:%s:%s:%s:%s:%s",
(addon or ""),
(map or ""),
(coords or ""),
(title or ""),
(layer or ""),
(spawn or ""),
(item or "")
)
-- use prioritized clusters
if layer >= 9 and meta["priority"] then
layer = layer + (10 - min(meta["priority"], 10))
end
if not pfMap.nodes[addon] then
pfMap.nodes[addon] = {}
end
if not pfMap.nodes[addon][map] then
pfMap.nodes[addon][map] = {}
end
if not pfMap.nodes[addon][map][coords] then
pfMap.nodes[addon][map][coords] = {}
end
-- skip early on existing nodes
if pfMap.nodes[addon][map][coords][title] then
local existing = pfMap.nodes[addon][map][coords][title]
-- Quest progress can change an ender from the incomplete grey question
-- mark to the completed yellow question mark without changing its title,
-- coordinate, or priority layer. Treat that as a real node update before
-- the same-layer early return below, otherwise the minimap keeps the
-- stale texture until a reload or unrelated map rebuild.
if existing.questid and meta.questid and existing.questid == meta.questid
and (existing.texture ~= meta.texture or existing.vertex ~= meta.vertex) then
existing.texture = meta.texture
existing.vertex = meta.vertex
existing.QTYPE = meta.QTYPE
existing.qlogid = meta.qlogid
existing.description = meta.description
existing.layer = layer
-- Combined node metadata can be shared by several coordinates. Mark
-- every container referencing this same node so all of its world-map
-- and minimap frames repaint, not only the coordinate encountered first.
for indexedMap, indexedCoords in pairs(
pfMap.titleIndex[addon] and pfMap.titleIndex[addon][title] or {}) do
for indexedCoord in pairs(indexedCoords) do
local container = pfMap.nodes[addon][indexedMap]
and pfMap.nodes[addon][indexedMap][indexedCoord]
if container and container[title] == existing then
pfMap.dirtyNodes[container] = true
pfMap.dirtyMinimapNodes[container] = true
pfMap.dirtyMaps[indexedMap] = true
end
end
end
pfMap.queue_update = GetTime()
return
end
if item and table.getn(pfMap.nodes[addon][map][coords][title].item) > 0 then
-- check if item already exists
for id, name in pairs(pfMap.nodes[addon][map][coords][title].item) do
if name == item then
-- Several creatures can share the exact same spawn coordinate and
-- quest-item node (for example Tunnel Rat Vermin and Scout). Keep
-- a tooltip alias for every matching creature even though the map
-- pin itself is intentionally merged into one node.
if spawn and title then
local existing = pfMap.nodes[addon][map][coords][title]
existing.sharedspawns = existing.sharedspawns or { [existing.spawn] = true }
existing.sharedspawns[spawn] = true
pfMap.tooltips[spawn] = pfMap.tooltips[spawn] or {}
pfMap.tooltips[spawn][title] = pfMap.tooltips[spawn][title] or {}
pfMap.tooltips[spawn][title][map] = existing
pfMap.tooltipIndex[title] = pfMap.tooltipIndex[title] or {}
pfMap.tooltipIndex[title][spawn] = true
-- The map pin stays visually merged, but its tooltip needs to
-- refresh so it can list every creature sharing this location.
pfMap.dirtyNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMinimapNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMaps[map] = true
end
return
end
end
-- add new item and refresh both map surfaces before exiting.
table.insert(pfMap.nodes[addon][map][coords][title].item, item)
pfMap.dirtyNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMinimapNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMaps[map] = true
return
end
if
pfMap.nodes[addon][map][coords][title]
and pfMap.nodes[addon][map][coords][title].layer
and layer
and pfMap.nodes[addon][map][coords][title].layer >= layer
then
-- identical node already exists, exit here
return
end
end
-- create new combined data node from given meta data
if not similar_nodes[sindex] then
similar_nodes[sindex] = {}
for key, val in pairs(meta) do
similar_nodes[sindex][key] = val
end
similar_nodes[sindex].item = { [1] = item }
end
-- set current node to combined node
pfMap.nodes[addon][map][coords][title] = similar_nodes[sindex]
-- mark this coord's node table dirty so UpdateNodes knows to reprocess it
pfMap.dirtyNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMinimapNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMaps[map] = true
-- maintain reverse title index for O(1) DeleteNode
if not pfMap.titleIndex[addon] then
pfMap.titleIndex[addon] = {}
end
if not pfMap.titleIndex[addon][title] then
pfMap.titleIndex[addon][title] = {}
end
if not pfMap.titleIndex[addon][title][map] then
pfMap.titleIndex[addon][title][map] = {}
end
pfMap.titleIndex[addon][title][map][coords] = true
-- add node to unified cluster cache
if not meta["cluster"] and not meta["texture"] then
local node_index = meta.item or meta.spawn or UNKNOWN
local x, y = tonumber(meta.x), tonumber(meta.y)
-- create prerequisite table structure
unifiedcache[title] = unifiedcache[title] or {}
unifiedcache[title][map] = unifiedcache[title][map] or {}
if not unifiedcache[title][map][node_index] then
-- create new unified node from given meta data
local unified_meta = {}
for key, val in pairs(meta) do
unified_meta[key] = val
end
-- save node to unified cache
unifiedcache[title][map][node_index] = { meta = unified_meta, coords = {} }
end
-- append new coords to unified cache unified cache
table.insert(unifiedcache[title][map][node_index].coords, { x, y })
end
-- add to gametooltips
if spawn and title then
pfMap.tooltips[spawn] = pfMap.tooltips[spawn] or {}
pfMap.tooltips[spawn][title] = pfMap.tooltips[spawn][title] or {}
pfMap.tooltips[spawn][title][map] = pfMap.tooltips[spawn][title][map] or similar_nodes[sindex]
-- maintain reverse tooltip index for O(1) DeleteNode
if not pfMap.tooltipIndex[title] then
pfMap.tooltipIndex[title] = {}
end
pfMap.tooltipIndex[title][spawn] = true
end
pfMap.queue_update = GetTime()
end
function pfMap:GetNodes(addon, title)
local nodes = {}
if title and pfMap.nodes[addon] then
for map, foo in pairs(pfMap.nodes[addon]) do
for coords, node in pairs(pfMap.nodes[addon][map]) do
if pfMap.nodes[addon][map][coords][title] then
table.insert(nodes, pfMap.nodes[addon][map][coords][title])
end
end
end
end
return nodes
end
function pfMap:DeleteNode(addon, title)
if not addon then
-- wipe everything
pfMap.tooltips = {}
pfMap.nodes = {}
pfMap.titleIndex = {}
pfMap.tooltipIndex = {}
pfMap.dirtyNodes = {}
pfMap.dirtyMinimapNodes = {}
pfMap.dirtyMaps = {}
elseif not title then
-- wipe all nodes for this addon; clean up both reverse indexes
if pfMap.titleIndex[addon] then
for t, maps in pairs(pfMap.titleIndex[addon]) do
-- clean tooltipIndex entries that belonged to this addon's titles
local spawns = pfMap.tooltipIndex[t]
if spawns then
for spawn in pairs(spawns) do
if pfMap.tooltips[spawn] then
pfMap.tooltips[spawn][t] = nil
if IsEmpty(pfMap.tooltips[spawn]) then
pfMap.tooltips[spawn] = nil
end
end
end
pfMap.tooltipIndex[t] = nil
end
end
pfMap.titleIndex[addon] = nil
end
pfMap.nodes[addon] = nil
elseif pfMap.titleIndex[addon] and pfMap.titleIndex[addon][title] then
-- fast path: use reverse index to find exactly which (map, coords) to clear
for map, coords_set in pairs(pfMap.titleIndex[addon][title]) do
-- Removing the last title at a coordinate still changes the route input.
-- Always invalidate every affected map so automatic routing drops stale
-- coordinates and selects the next active quest immediately.
pfMap.dirtyMaps[map] = true
if pfMap.nodes[addon] and pfMap.nodes[addon][map] then
for coords in pairs(coords_set) do
if pfMap.nodes[addon][map][coords] then
pfMap.nodes[addon][map][coords][title] = nil
if IsEmpty(pfMap.nodes[addon][map][coords]) then
pfMap.nodes[addon][map][coords] = nil
else
-- coord survives with remaining titles; reprocess on next UpdateNodes
pfMap.dirtyNodes[pfMap.nodes[addon][map][coords]] = true
pfMap.dirtyMinimapNodes[pfMap.nodes[addon][map][coords]] = true
end
end
end
end
end
pfMap.titleIndex[addon][title] = nil
-- clean up tooltip entries for this title using the reverse tooltip index
local spawns = pfMap.tooltipIndex[title]
if spawns then
for spawn in pairs(spawns) do
if pfMap.tooltips[spawn] then
pfMap.tooltips[spawn][title] = nil
if IsEmpty(pfMap.tooltips[spawn]) then
pfMap.tooltips[spawn] = nil
end
end
end
pfMap.tooltipIndex[title] = nil
end
end
pfMap.queue_update = GetTime()
end
function pfMap:NodeClick()
if IsShiftKeyDown() then
local questid = tonumber(this.questid)
if questid and this.texture and this.layer < 5 then
-- Keep the SavedVariables key numeric. HDB rows may expose IDs as text,
-- and string history keys are intentionally discarded during migration.
pfQuest_history[questid] = { time(), UnitLevel("player") }
end
if this.node and this.title and this.node[this.title] then
-- delete node from map
pfMap:DeleteNode(this.node[this.title].addon, this.title)
end
pfQuest.updateQuestGivers = true
elseif
this.texture
and pfQuest.route
and (this.addon ~= "PFPARTY" or pfQuest_config["showPartyQuestPinsRoutable"] ~= "0")
and (
(pfQuest_config["routecluster"] == "1" and this.layer >= 9)
or (pfQuest_config["routeender"] == "1" and this.layer == 4)
or (pfQuest_config["routestarter"] == "1" and this.layer == 1)
or (pfQuest_config["routestarter"] == "1" and this.layer == 2)
)
then
-- set as arrow target priority
pfQuest.route.SetTarget((not pfQuest.route.IsTarget(this) and this))
pfMap.queue_update = GetTime()
else
-- switch color
pfQuest_colors[this.color] = { str2rgb(this.color .. GetTime()) }
-- Color keys are shared by every node for the quest (or spawn, when
-- spawn-color mode is enabled). A color change does not mutate any node
-- table, so invalidate all render tables and let each visible pin re-read
-- the shared saved color.
for _, addonData in pairs(pfMap.nodes or {}) do
for _, zoneNodes in pairs(addonData) do
for _, node in pairs(zoneNodes) do
pfMap.dirtyNodes[node] = true
pfMap.dirtyMinimapNodes[node] = true
end
end
end
pfMap.queue_update = GetTime()
end
end
function pfMap:BeginNodeTooltip(frame)
if self.nodeTooltipFrame and self.nodeTooltipFrame ~= frame then
self:ReleaseNodeTooltip(self.nodeTooltipFrame)
end
frame.pfQuestTooltip = frame.worldmap and WorldMapTooltip or GameTooltip
self.nodeTooltipFrame = frame
end
function pfMap:NodeEnter()
pfMap:BeginNodeTooltip(this)
-- wotlk: need to disable blop tooltips first
if compat.client >= 30300 then
WorldMapPOIFrame.allowBlobTooltip = false
end
local tooltip = this.worldmap and WorldMapTooltip or GameTooltip
tooltip:SetOwner(this, "ANCHOR_LEFT")
this.spawn = this.spawn or UNKNOWN
tooltip:SetText(
this.spawn .. (pfQuest_config.showids == "1" and " |cffcccccc(" .. this.spawnid .. ")|r" or ""),
0.3,
1,
0.8
)
tooltip:AddDoubleLine(pfQuest_Loc["Level"] .. ":", (this.level or UNKNOWN), 0.8, 0.8, 0.8, 1, 1, 1)
tooltip:AddDoubleLine(pfQuest_Loc["Type"] .. ":", (this.spawntype or UNKNOWN), 0.8, 0.8, 0.8, 1, 1, 1)
tooltip:AddDoubleLine(pfQuest_Loc["Respawn"] .. ":", (this.respawn or UNKNOWN), 0.8, 0.8, 0.8, 1, 1, 1)
for title, meta in pairs(this.node) do
pfMap:ShowTooltip(meta, tooltip)
end
-- add tooltip help if setting is enabled
if pfQuest_config["tooltiphelp"] == "1" then
local text = string.gsub(pfQuest_Loc["Use <Shift>-Click To Remove Nodes"], "^Use ", "")
local shifttext
if this.cluster then
text = pfQuest_Loc["Hold <Ctrl> To Hide Cluster"]
elseif tooltip == GameTooltip then
text = pfQuest_Loc["Hold <Ctrl> To Hide Minimap Nodes"]
shifttext = this.questid and this.texture and this.layer < 5
and string.gsub(pfQuest_Loc["Use <Shift>-Click To Mark Quest As Done"], "^Use ", "")
or string.gsub(pfQuest_Loc["Use <Shift>-Click To Remove Nodes"], "^Use ", "")
elseif not this.texture then
text = pfQuest_Loc["Click Node To Change Color"]
elseif this.questid and this.texture and this.layer < 5 then
text = string.gsub(pfQuest_Loc["Use <Shift>-Click To Mark Quest As Done"], "^Use ", "")
end
-- update tooltip and sizes
tooltip:AddLine(text, 0.6, 0.6, 0.6)
if shifttext then tooltip:AddLine(shifttext, 0.6, 0.6, 0.6) end
tooltip:Show()
end
pfMap.highlight = pfQuest_config["mouseover"] == "1" and this.title
end
-- Pins may disappear during a refresh without receiving OnLeave. Tooltip
-- addons can also alter mouse focus, so keep cleanup independent of that event.
function pfMap:ReleaseNodeTooltip(frame)
if not frame then return end
local tooltip = frame.pfQuestTooltip
frame.pfQuestTooltip = nil
if tooltip and tooltip:IsOwned(frame) then tooltip:Hide() end
if self.nodeTooltipFrame == frame then
self.nodeTooltipFrame = nil
self.highlight = nil
if compat.client >= 30300 then WorldMapPOIFrame.allowBlobTooltip = true end
end
end
function pfMap:UpdateNodeTooltip()
local frame = self.nodeTooltipFrame
if not frame then return end
local tooltip = frame.pfQuestTooltip
if not tooltip or not tooltip:IsOwned(frame) or not tooltip:IsShown()
or not frame:IsShown()
or (frame.clickThrough and not MouseIsOver(frame))
or (not frame.clickThrough and GetMouseFocus() ~= frame) then
self:ReleaseNodeTooltip(frame)
end
end
function pfMap:NodeLeave()
pfMap:ReleaseNodeTooltip(this)
end
function pfMap:BuildNode(name, parent)
local f = CreateFrame("Button", name, parent)
f.worldmap = parent == WorldMapButton
if parent == WorldMapButton then
f.defalpha = tonumber(pfQuest_config["worldmaptransp"]) or 1
f.defsize = 14
else
f.defalpha = tonumber(pfQuest_config["minimaptransp"]) or 1
f.defsize = 14
f.minimap = true
end
f:SetWidth(f.defsize)
f:SetHeight(f.defsize)
f.Animate = NodeAnimate
f:SetScript("OnEnter", function()
-- Resolve dynamically: Turtle extends NodeEnter after the core loads.
pfMap.NodeEnter()
end)
f:SetScript("OnLeave", pfMap.NodeLeave)
f:SetScript("OnHide", function() pfMap:ReleaseNodeTooltip(this) end)
f.tex = f:CreateTexture(nil, "BACKGROUND")
f.tex:SetAllPoints(f)
f.pic = f:CreateTexture(nil, "NORMAL")
f.pic:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1)
f.pic:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1)
f.hl = f:CreateTexture(nil, "BORDER")
f.hl:SetTexture(pfQuestConfig.path .. "\\img\\track")
f.hl:SetPoint("TOPLEFT", f, "TOPLEFT", -5, 5)
f.hl:SetWidth(12)
f.hl:SetHeight(12)
return f
end
pfMap.highlightdb = {}
local function UpdateMinimapIconFade(frame, distance)
if not frame.pic:IsShown() then return end
local halfsize = pfMap.drawlayer:GetWidth() / 2
local fade_range = frame.fade_range or 8
local fade_in = halfsize / 100 * (fade_range - 4)
local fade_out = halfsize / 100 * (fade_range + 4)
local alpha = ((distance or fade_out) - fade_in) / (fade_out - fade_in)
frame.pic:SetAlpha(math.max(0, math.min(alpha, 1)))
end
-- Saved colors can change without changing a node or its cache key.
function pfMap:RefreshPinColor(colorKey)
local red, green, blue = self.str2rgb(colorKey)
for frame in pairs(self.highlightdb or {}) do
if frame.color == colorKey and not frame.texture and frame.tex then
frame.tex:SetVertexColor(red, green, blue, 1)
end
end
end
local clusterTextureBase = {}
for _, kind in ipairs({ "item", "mob", "misc" }) do
local texture = addon_path .. "\\img\\cluster_" .. kind
clusterTextureBase[texture] = texture
clusterTextureBase[texture .. "_mono"] = texture
end
local function GetClusterTexture(texture)
local base = texture and clusterTextureBase[texture]
if not base then return texture end
return pfQuest_config.clustermono == "1" and base .. "_mono" or base
end
local function GetNodePicture(frame)
local icon = (frame.title and pfQuest.icons[frame.title]) or frame.icon
if not icon and frame.tracking then
icon = frame.spawn and pfDatabase.icons[frame.spawn]
if not icon and frame.spawnid and pfDatabase.iconsByID then
local kind = frame.spawntype == pfQuest_Loc["Object"] and "O" or "U"
icon = pfDatabase.iconsByID[kind .. frame.spawnid]
end
end
return icon
end
local function PaintPlainNode(frame, obj, distance)
local r, g, b = str2rgb(frame.color)
local showIcon = not (frame.tracking and pfQuest_config.trackingicons == "0")
frame.showTrackingIcon = showIcon
local icon = showIcon and GetNodePicture(frame)
if icon then
frame.pic:SetTexture(icon)
frame.pic:Show()
if obj == "minimap" then UpdateMinimapIconFade(frame, distance) end
else
frame.pic:Hide()
end
local texture = TEX_NODE
if frame.addon == "PFPARTY" then
texture = TEX_STAR
elseif pfQuest_config[obj == "minimap" and "cutoutminimap" or "cutoutworldmap"] == "1" then
texture = TEX_NODECUT
end
frame.tex:SetTexture(texture)
frame.tex:SetVertexColor(r, g, b, 1)
end
-- Appearance changes do not mutate source nodes. Repaint reusable frames and
-- invalidate the visual cache without querying or rebuilding quest data.
function pfMap:ApplyMapAppearance()
self.appearanceRevision = (self.appearanceRevision or 0) + 1
for frame in pairs(self.highlightdb or {}) do
if frame.cluster and frame.texture and frame.tex then
local texture = GetClusterTexture(frame.texture)
if texture ~= frame.texture then
frame.texture = texture
frame.tex:SetTexture(texture)
end
elseif not frame.texture and frame.tex and frame.pic then
frame.color = pfQuest_config.spawncolors == "1" and (frame.spawn or frame.title) or frame.title
PaintPlainNode(frame, frame.minimap and "minimap" or nil)
end
frame.appearanceRevision = self.appearanceRevision
end
self.minimapDisabled = nil
-- The minimap's timing/movement caches must allow a one-time settings redraw.
self.minimapSettingsRevision = (self.minimapSettingsRevision or 0) + 1
self:UpdateMinimap()
end
-- Repaint existing tracking pictures without rebuilding quest data or positions.
function pfMap:RefreshTrackingIcons()
local enabled = pfQuest_config.trackingicons ~= "0"
for frame in pairs(self.highlightdb or {}) do
if frame.tracking and not frame.texture and frame.pic then
local icon = GetNodePicture(frame)
frame.showTrackingIcon = enabled
if enabled and icon then
frame.pic:SetTexture(icon)
frame.pic:Show()
else
frame.pic:Hide()
end
end
end
end
function pfMap:UpdateNode(frame, node, color, obj, distance)
-- clear node to title association table
if pfMap.highlightdb[frame] then
for k, v in pairs(pfMap.highlightdb[frame]) do
pfMap.highlightdb[frame][k] = nil
end
else
pfMap.highlightdb[frame] = {}
end
-- reset layer
frame.layer = 0
for title, tab in pairs(node) do
pfMap.highlightdb[frame][title] = true
tab.layer = GetLayerByTexture(tab.texture)
-- use prioritized clusters
if tab.cluster and tab.priority then
tab.layer = tab.layer + (10 - min(tab.priority, 10))
end
if tab.spawn and (tab.layer > frame.layer or not frame.spawn) then
frame.updateTexture = (frame.texture ~= GetClusterTexture(tab.texture))
frame.updateVertex = (frame.vertex ~= tab.vertex)
frame.updateColor = (frame.color ~= tab.color)
frame.updateLayer = (frame.layer ~= tab.layer)
-- A recycled pin frame can move from one addon's untextured node to
-- another's (e.g. a plain PFQUEST spawn dot to a PFPARTY star) without
-- updateTexture/updateColor changing, if their color keys happen to
-- coincide. Track the addon switch explicitly so that case still
-- repaints the base shape below instead of keeping the previous one.
frame.updateAddon = (frame.addon ~= tab.addon)
-- set title and texture to the entry with highest layer
-- and add core information
frame.layer = tab.layer
frame.addon = tab.addon
frame.spawn = tab.spawn
frame.spawnid = tab.spawnid
frame.spawntype = tab.spawntype
frame.respawn = tab.respawn
frame.level = tab.level
frame.questid = tab.questid
frame.texture = GetClusterTexture(tab.texture)
frame.vertex = tab.vertex
frame.title = title
frame.func = tab.func
frame.cluster = tab.cluster
frame.description = tab.description
frame.priority = tab.priority
frame.quest = tab.quest
frame.qlvl = tab.qlvl
frame.itemreq = tab.itemreq
frame.arrow = tab.arrow
frame.icon = tab.icon
frame.tracking = tab.tracking
frame.fade_range = tab.fade_range
frame.sharedspawns = tab.sharedspawns
if pfQuest_config["spawncolors"] == "1" then
frame.color = tab.spawn or tab.title
else
frame.color = tab.title
end
end
end
if (frame.updateTexture or frame.updateVertex or not frame.tex:GetTexture()) and frame.texture then
frame.tex:SetTexture(frame.texture)
frame.tex:SetVertexColor(1, 1, 1)
frame.pic:Hide()
if frame.updateVertex and frame.vertex then
local r, g, b = unpack(frame.vertex)
if r > 0 or g > 0 or b > 0 then
frame.tex:SetVertexColor(r, g, b, 1)
end
end
end
local showTrackingIcon = not (frame.tracking and pfQuest_config.trackingicons == "0")
local trackingIconChanged = frame.showTrackingIcon ~= showTrackingIcon
frame.showTrackingIcon = showTrackingIcon
if (frame.updateColor or frame.updateTexture or frame.updateAddon or trackingIconChanged
or frame.appearanceRevision ~= pfMap.appearanceRevision or not frame.tex:GetTexture()) and not frame.texture then
PaintPlainNode(frame, obj, distance)
end
frame.appearanceRevision = pfMap.appearanceRevision
if frame.updateLayer then
-- City maps such as Ironforge draw their detailed map artwork above the
-- normal outdoor-map pin layer. Keep world-map pins above that artwork;
-- minimap pins retain their existing compact layer range.
frame:SetFrameLevel((obj == "minimap" and 4 or 240) + frame.layer)
end
if frame.updateTexture or frame.updateVertex or frame.updateColor or frame.updateLayer then
frame:SetScript("OnClick", (frame.func or pfMap.NodeClick))
end
local highlight = frame.texture and pfMap.highlightdb[frame][pfMap.highlight] and true or nil
local target = frame.texture and pfQuest.route and pfQuest.route.IsTarget(frame) or nil
-- set default sizes for different node types
frame.defsize = (frame.cluster or frame.layer == 4) and 18 or 14
-- make the current route target visible
if target then
frame.hl:Show()
else
frame.hl:Hide()
end
-- reset frame size except for highlights
if not highlight then
frame:SetWidth(frame.defsize)
frame:SetHeight(frame.defsize)
end
frame.node = node
end
local function GetExploredBounds(mapid)
-- ClassicAPI reads fog data for any zone by ID. This makes the unexplored
-- filter immediately accurate on continent maps without calling SetMapZoom
-- or disturbing the map a player is viewing.
local apiOverlays = pfQuestCompat.GetExploredMapTextures and pfQuestCompat.GetExploredMapTextures(mapid)
if apiOverlays then
-- Capitals and a few special maps have no exploration-overlay geometry at
-- all. They are fully readable map art, not an unexplored blank area, so
-- leave their quest pins alone.
local allOverlays = C_Map and type(C_Map.GetMapOverlays) == "function"
and C_Map.GetMapOverlays(mapid)
if type(allOverlays) ~= "table" then return nil end
if type(allOverlays) == "table" and table.getn(allOverlays) == 0 then return nil, true end
-- Overlay rectangles use the fixed 1002x668 World Map canvas, rather
-- than C_Map.GetMapWorldSize's yard coordinates.
local bounds = {}
for _, overlay in ipairs(apiOverlays) do
-- The art rectangle often contains transparent padding. ClassicAPI's
-- hit rect follows the actual clickable landmass and avoids allowing
-- pins at the loose edges of an otherwise explored overlay.
if overlay.hitRectLeft and overlay.hitRectRight and overlay.hitRectTop and overlay.hitRectBottom then
table.insert(bounds, {
left = overlay.hitRectLeft / 1002 * 100,
right = overlay.hitRectRight / 1002 * 100,
top = overlay.hitRectTop / 668 * 100,
bottom = overlay.hitRectBottom / 668 * 100,
})
else
local width, height = overlay.textureWidth, overlay.textureHeight
local offsetX, offsetY = overlay.offsetX, overlay.offsetY
if width and height and offsetX and offsetY then
table.insert(bounds, {
left = offsetX / 1002 * 100,
right = (offsetX + width) / 1002 * 100,
top = offsetY / 668 * 100,
bottom = (offsetY + height) / 668 * 100,
})
end
end
end
return bounds, true
end
if not GetNumMapOverlays or not GetMapOverlayInfo or not GetMapInfo then return nil end
local _, mapHeight, mapWidth = GetMapInfo()
if not mapWidth or not mapHeight or mapWidth == 0 or mapHeight == 0 then return nil end
local bounds = {}
for index = 1, GetNumMapOverlays() do
local texture, width, height, offsetX, offsetY = GetMapOverlayInfo(index)
if texture and texture ~= "" and width and height and offsetX and offsetY then
table.insert(bounds, {
left = offsetX / mapWidth * 100,
right = (offsetX + width) / mapWidth * 100,
top = offsetY / mapHeight * 100,
bottom = (offsetY + height) / mapHeight * 100,
})
end
end
-- Vanilla can report zero overlays while a map is still initializing. An
-- empty result is not evidence that every coordinate is unexplored; keep
-- pins visible until it supplies actual explored rectangles.
if table.getn(bounds) == 0 then return nil end
return bounds
end
-- pfQuest-turtle uses this for projected continent pins. Its callers only use
-- the direct form when ClassicAPI advertises map-exploration support.
pfMap.GetExploredBounds = GetExploredBounds
local function IsExploredPosition(bounds, x, y)
if not bounds then return true end
for _, area in pairs(bounds) do
if x >= area.left and x <= area.right and y >= area.top and y <= area.bottom then
return true
end
end
return false
end
-- The Turtle database extension also draws projected continent pins. Keep the
-- most recently known exploration overlays by source zone so it can apply the
-- same visibility rule there without changing the map the player is viewing.
-- Persist discoveries made during ordinary map browsing. We never switch maps
-- ourselves: each zone enters this cache only after the player has opened it.
pfQuest_config.exploredareas = pfQuest_config.exploredareas or {}
pfMap.exploredAreas = pfQuest_config.exploredareas
-- Older builds could save an empty table when vanilla had not populated map
-- overlays yet. That means "unknown", never "nothing explored"; remove such
-- stale records once so they cannot keep hiding an entire zone after upgrade.
for mapID, bounds in pairs(pfMap.exploredAreas) do
if type(bounds) ~= "table" or not next(bounds) then
pfMap.exploredAreas[mapID] = nil
end
end
pfQuest_config.visitedmaps = pfQuest_config.visitedmaps or {}
pfMap.visitedMaps = pfQuest_config.visitedmaps
pfMap.IsExploredPosition = IsExploredPosition
-- Capital maps do not have meaningful regional fog for quest filtering. Some
-- clients still expose decorative overlay rows for them, so use the character
-- visit record rather than interpreting those rows as unexplored terrain.
local cityMaps = {
[1497] = true, -- Undercity
[1519] = true, -- Stormwind City
[1537] = true, -- Ironforge
[1637] = true, -- Orgrimmar
[1638] = true, -- Thunder Bluff
[1657] = true, -- Darnassus
}
function pfMap:IsMapVisited(mapID)
return mapID and self.visitedMaps[mapID] and true or false
end
function pfMap:MarkMapVisited(mapID)
if mapID then self.visitedMaps[mapID] = true end
end
function pfMap:IsVisitedCityPosition(mapID, x, y)
for cityMapID in pairs(cityMaps) do
if self:IsMapVisited(cityMapID) then
local left, right, top, bottom = pfQuestCompat.GetMapRectOnMap
and pfQuestCompat.GetMapRectOnMap(cityMapID, mapID)
if left and right and top and bottom
and x >= left * 100 and x <= right * 100
and y >= top * 100 and y <= bottom * 100 then
return true
end
end
end
return false
end
function pfMap:GetPlayerMapID()
-- SetMapToCurrentZone resolves capitals (for example Orgrimmar) correctly,
-- while GetRealZoneText can report their surrounding outdoor zone (Durotar).
-- Prefer the ID captured on the player's actual zone-change event so Current
-- Zone Only follows the same map the client uses for the character.
if pfMap.playerMapID then
return pfMap.playerMapID
end
-- Fallback for the short period before the first zone-change event fires.
return GetRealZoneText and pfMap:GetMapIDByName(GetRealZoneText()) or nil
end
function pfMap:CacheCurrentExploration(mapID)
local bounds = GetExploredBounds(mapID)
if mapID and bounds then
self.exploredAreas[mapID] = bounds
end
end
function pfMap:UpdateNodes()
pfQuest:Debug("Update Nodes")
local color = pfQuest_config["spawncolors"] == "1" and "spawn" or "title"
local map = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
local i = 1
-- reset tracker
pfQuest.tracker.Reset()
-- Continent and world views do not resolve to a zone map ID. Extensions can
-- render their own pins there, but the core zone-node renderer must not use
-- nil as a dirty-map key.
if not map then
pfQuest.route:Clear()
pfMap.lastRouteMap = nil
for _, pin in pairs(pfMap.pins) do
pin:Hide()
end
pfMap.lastUpdateZone = nil
pfMap.mapJustOpened = nil
if pfQuest.tracker and pfQuest.tracker.DoLayout then
pfQuest.tracker.DoLayout()
end
return
end
-- Current Zone Only follows the player's zone, not a different zone selected
-- while browsing the World Map.
if tonumber(pfQuest_config["trackingmethod"]) == 5 and pfMap:GetPlayerMapID() and map ~= pfMap:GetPlayerMapID() then
pfQuest.route:Clear()
for _, pin in pairs(pfMap.pins) do pin:Hide() end
if pfQuest.tracker and pfQuest.tracker.DoLayout then pfQuest.tracker.DoLayout() end
return
end
local boundaryAliases = tonumber(pfQuest_config["trackingmethod"]) == 5
and pfMap.BuildBoundaryAliasKeys and pfMap:BuildBoundaryAliasKeys(map) or nil
local function FilterBoundaryAliases(nodes)
if not boundaryAliases or not next(boundaryAliases) or not pfMap.IsBoundaryAliasNode then
return nodes or {}
end
local filtered = {}
for coords, node in pairs(nodes or {}) do
if not pfMap:IsBoundaryAliasNode(node, boundaryAliases) then
filtered[coords] = node
end
end
return filtered
end
local exploredBounds, explorationHandled = GetExploredBounds(map)
if cityMaps[map] then
if pfMap:IsMapVisited(map) then
exploredBounds, explorationHandled = nil, nil
else
exploredBounds, explorationHandled = {}, true
end
end
if exploredBounds then
pfMap:CacheCurrentExploration(map)
end
-- Some item/object quests resolve to this zone but do not leave a rendered
-- spawn pin in every map refresh. Preserve their confirmed, map-scoped
-- tracker entry in Current Zone Only mode. Entries vanish as soon as the
-- quest is no longer active.
if tonumber(pfQuest_config["trackingmethod"]) == 5 and pfMap.currentZoneTracker and pfMap.currentZoneTracker[map] then
for questid, title in pairs(pfMap.currentZoneTracker[map]) do
local quest = pfQuest.questlog and pfQuest.questlog[questid]
if quest then
-- The Current Zone fallback can be added before the real ender pin.
-- Use the quest-log state here so simple report/talk turn-ins do not
-- leave the tracker with the fallback's unfinished grey question mark.
local _, _, _, _, _, complete = compat.GetQuestLogTitle(quest.qlogid)
local texture = complete
and pfQuestConfig.path .. "\\img\\complete_c"
or pfQuestConfig.path .. "\\img\\complete"
pfQuest.tracker.ButtonAdd(title, {
dummy = true,
addon = "PFQUEST",
questid = questid,
texture = texture,
})
else
pfMap.currentZoneTracker[map][questid] = nil
end
end
end
-- Quest events also fire while the world map is closed. Rebuilding every
-- hidden world-map frame in that state caused a visible accept/abandon
-- hitch. Refresh only the quest tracker here; minimap pins have their own
-- updater, and dirtyMaps keeps the full world-map render pending until the
-- player actually opens it.
if not WorldMapFrame:IsShown() then
local questNodes = pfMap.nodes.PFQUEST and pfMap.nodes.PFQUEST[map]
local rawObjectiveCandidates = {}
local rebuildRoute = pfMap.lastRouteMap ~= map or pfMap.dirtyMaps[map]
if rebuildRoute then
pfQuest.route:Reset()
pfMap.lastRouteMap = map
end
for coords, node in pairs(questNodes or {}) do
local x, y
if coord_cache[coords] then
x, y = coord_cache[coords][1], coord_cache[coords][2]
else
local _, _, strx, stry = strfind(coords, "(.*)|(.*)")
x, y = strx + 0, stry + 0
coord_cache[coords] = { x, y }
end
local routeNode
local routeLayer = 0
for title, meta in pairs(node) do
pfQuest.tracker.ButtonAdd(title, meta)
pfQuest.tracker.RegisterQuestPoint(title, meta, x, y)
-- Select the same highest-priority entry that UpdateNode would bind to
-- a visible map pin, but keep this path free of frame work.
meta.layer = GetLayerByTexture(meta.texture)
if meta.cluster and meta.priority then
meta.layer = meta.layer + (10 - min(meta.priority, 10))
end
if meta.spawn and (meta.layer > routeLayer or not routeNode) then
routeNode = meta
routeNode.title = title
routeLayer = meta.layer
end
end
if rebuildRoute and routeNode then
local routeEligible =
(routeNode.addon ~= "PFPARTY" or pfQuest_config["showPartyQuestPinsRoutable"] ~= "0")
and (
(pfQuest_config["routecluster"] == "1" and routeNode.layer >= 9)
or (pfQuest_config["routeender"] == "1" and routeNode.layer == 4)
or (pfQuest_config["routestarter"] == "1" and routeNode.layer == 1 and routeNode.texture)
or (pfQuest_config["routestarter"] == "1" and routeNode.layer == 2)
or routeNode.arrow == true
)
local hidden = pfQuest_config["hideunexplored"] == "1"
and ((explorationHandled and not exploredBounds and not pfMap:IsMapVisited(map))
or not IsExploredPosition(exploredBounds, x, y))
and not pfMap:IsVisitedCityPosition(map, x, y)
hidden = hidden or (pfQuest_config["showcluster"] == "0" and routeNode.cluster)
hidden = hidden or (pfQuest_config["showspawn"] == "0" and not routeNode.texture)
if routeEligible and not hidden then
pfQuest.route:AddPoint({ x, y, routeNode })
end
end
end
-- questNodes above is PFQUEST-only (deliberately, so PFPARTY never shows
-- in the tracker), so a party pin is otherwise invisible to routing for
-- as long as the World Map stays closed -- the common case while
-- actually questing. Without this, the arrow only ever reflected party
-- progress from the instant the map was last opened, then went stale.
-- Route-candidate purposes only: no tracker/ButtonAdd calls here, so
-- PFPARTY still never appears in the quest tracker sidebar. No layer
-- priority dance needed like above -- a PFPARTY node is always
-- untextured/layer 1 by construction, with no competing textured or
-- clustered entry to out-prioritize.
local partyNodes = pfMap.nodes.PFPARTY and pfMap.nodes.PFPARTY[map]
if partyNodes and pfQuest_config["showPartyQuestPinsRoutable"] ~= "0" then
for coords, node in pairs(partyNodes) do
local x, y
if coord_cache[coords] then
x, y = coord_cache[coords][1], coord_cache[coords][2]
else
local _, _, strx, stry = strfind(coords, "(.*)|(.*)")
x, y = strx + 0, stry + 0
coord_cache[coords] = { x, y }
end
for title, meta in pairs(node) do
if meta.spawn and not meta.texture then
table.insert(rawObjectiveCandidates, { x, y, meta, nil, true })
end
end
end
end
pfQuest.route:SetRawObjectiveCandidates(rawObjectiveCandidates)
if pfQuest.tracker and pfQuest.tracker.DoLayout then
pfQuest.tracker.DoLayout()
end
return
end
-- A tracker/UI refresh can call UpdateNodes without changing any map node.
-- Keep the existing route in that case; resetting it redraws the path every
-- couple of seconds even though its inputs are unchanged.
if pfMap.lastRouteMap ~= map or pfMap.dirtyMaps[map] then
pfQuest.route:Reset()
pfMap.lastRouteMap = map
end
-- refresh all nodes
local n_pins, n_skipped = 0, 0
-- hoist map dimensions: same for every pin this call, and if the map
-- is resized between calls the new values will invalidate cached px/py.
local mapW = WorldMapButton:GetWidth()
local mapH = WorldMapButton:GetHeight()
-- The enhanced client renders certain capital-city maps on DetailFrame and
-- hides WorldMapButton. Pins parented to the hidden button still receive
-- valid coordinates but can never be drawn. Use the visible detail surface
-- in that case, then return to WorldMapButton for ordinary outdoor maps.
local mapParent = WorldMapButton
if not WorldMapButton:IsVisible() and WorldMapDetailFrame and WorldMapDetailFrame:IsVisible() then
mapParent = WorldMapDetailFrame
end
-- A party-quest pin never gets a "cluster" pin the way the player's own
-- quest objectives do (that only happens inside pfDatabase:SearchQuestID,
-- which is tied to the player's own qlogid), so it stays an ordinary
-- un-clustered, untextured layer-1 node even with the World Map open and
-- would otherwise never satisfy any routeEligible condition below. Collect
-- it as a raw route candidate instead, the same way pfQuest-HDB's map.lua
-- does for its own closed-map item objectives; route.lua picks the nearest
-- one on every movement tick. Scoped to PFPARTY only so this never changes
-- routing for the player's own (already-working) quest objectives.
local rawObjectiveCandidates = {}
for addon, _ in pairs(pfMap.nodes) do
if pfMap.nodes[addon][map] then
for coords, node in pairs(FilterBoundaryAliases(pfMap.nodes[addon][map])) do
if not pfMap.pins[i] then
pfMap.pins[i] = pfMap:BuildNode("pfMapPin" .. i, WorldMapButton)
end
if pfMap.pins[i]:GetParent() ~= mapParent then
pfMap.pins[i]:SetParent(mapParent)
pfMap.pins[i].lastX = nil
pfMap.pins[i].lastY = nil
end
-- skip UpdateNode if this pin is already bound to this exact node table
-- and nothing has been added/removed from it since the last UpdateNodes call.
-- pfMap.dirtyNodes[node] is set by AddNode/DeleteNode on any real write.
-- frame.node ~= node catches coord-slot shifts from insertions/removals.
if pfMap.pins[i].node ~= node or pfMap.dirtyNodes[node]
or pfMap.pins[i].appearanceRevision ~= pfMap.appearanceRevision
or pfMap.pins[i].showTrackingIcon ~= not (pfMap.pins[i].tracking and pfQuest_config.trackingicons == "0") then
pfMap:UpdateNode(pfMap.pins[i], node, color)
pfMap.dirtyNodes[node] = nil
else
n_skipped = n_skipped + 1
end
-- set position (use cached coord parse to avoid strfind alloc)
local x, y
if coord_cache[coords] then
x, y = coord_cache[coords][1], coord_cache[coords][2]
else
local _, _, strx, stry = strfind(coords, "(.*)|(.*)")
x, y = strx + 0, stry + 0
coord_cache[coords] = { x, y }
end
-- QTYPE lives only on the raw per-title meta table, not on the pin
-- frame (UpdateNode never copies it there), so a PFPARTY raw-objective
-- check has to re-pick the same highest-priority entry UpdateNode
-- would bind to this pin, mirroring pfQuest-HDB's map.lua.
local partyRouteNode
local partyRouteLayer = 0
for title, meta in pairs(node) do
local layer = GetLayerByTexture(meta.texture)
if meta.cluster and meta.priority then
layer = layer + (10 - min(meta.priority, 10))
end
if meta.spawn and (layer > partyRouteLayer or not partyRouteNode) then
partyRouteNode = meta
partyRouteLayer = layer
end
end
local rawObjective = partyRouteNode and partyRouteLayer == 1 and not partyRouteNode.texture
and partyRouteNode.QTYPE and string.find(partyRouteNode.QTYPE, "OBJECTIVE", 1, true)
and partyRouteNode.addon == "PFPARTY" and pfQuest_config["showPartyQuestPinsRoutable"] ~= "0"
if rawObjective then
table.insert(rawObjectiveCandidates, { x, y, partyRouteNode, nil, true })
end
-- Route eligibility is determined here, but the point is only added
-- after the final map-visibility checks below. Otherwise the route
-- can lead to an objective that is hidden by fog or a display filter.
local routeEligible =
(pfMap.pins[i].addon ~= "PFPARTY" or pfQuest_config["showPartyQuestPinsRoutable"] ~= "0")
and (
(pfQuest_config["routecluster"] == "1" and pfMap.pins[i].layer >= 9)
or (pfQuest_config["routeender"] == "1" and pfMap.pins[i].layer == 4)
or (pfQuest_config["routestarter"] == "1" and pfMap.pins[i].layer == 1 and pfMap.pins[i].texture)
or (pfQuest_config["routestarter"] == "1" and pfMap.pins[i].layer == 2)
or pfMap.pins[i].arrow == true
)
-- Populate the tracker even when the matching map pin is hidden by a
-- display preference. Hidden objective spawns are still active quests
-- and must remain visible in Current Zone Only mode.
for title, node in pairs(pfMap.pins[i].node) do
pfQuest.tracker.ButtonAdd(title, node)
pfQuest.tracker.RegisterQuestPoint(title, node, x, y)
end
-- Hide pfQuest pins outside the character's discovered map overlays.
if addon == "PFQUEST" and pfQuest_config["hideunexplored"] == "1"
and ((explorationHandled and not exploredBounds and not pfMap:IsMapVisited(map))
or not IsExploredPosition(exploredBounds, x, y))
and not pfMap:IsVisitedCityPosition(map, x, y) then
pfMap.pins[i]:Hide()
-- hide cluster nodes if set
elseif pfQuest_config["showcluster"] == "0" and pfMap.pins[i].cluster then
pfMap.pins[i]:Hide()
-- hide individual quest spawns
elseif pfQuest_config["showspawn"] == "0" and addon == "PFQUEST" and not pfMap.pins[i].texture then
pfMap.pins[i]:Hide()
else
local px = x / 100 * mapW
local py = y / 100 * mapH
-- skip layout calls when the pin hasn't moved; ClearAllPoints +
-- SetPoint are the dominant cost in UpdateNodes for large pin counts
if pfMap.pins[i].lastX ~= px or pfMap.pins[i].lastY ~= py then
pfMap.pins[i].lastX = px
pfMap.pins[i].lastY = py
pfMap.pins[i]:ClearAllPoints()
pfMap.pins[i]:SetPoint("CENTER", mapParent, "TOPLEFT", px, -py)
end
pfMap.pins[i]:Show()
if routeEligible then
pfQuest.route:AddPoint({ x, y, pfMap.pins[i] })
end
end
n_pins = n_pins + 1
i = i + 1
end
end
end
pfQuest.route:SetRawObjectiveCandidates(rawObjectiveCandidates)
pfQuest:Debug(format("UpdateNodes pins=%d skipped=%d", n_pins, n_skipped))
-- hide remaining pins
for j = i, table.getn(pfMap.pins) do
if pfMap.pins[j] then
pfMap.pins[j]:Hide()
pfMap.pins[j].lastX = nil
pfMap.pins[j].lastY = nil
end
end
-- Perform tracker layout once after all ButtonAdd calls complete
if pfQuest.tracker and pfQuest.tracker.DoLayout then
pfQuest.tracker.DoLayout()
end
-- record which zone was rendered so WORLD_MAP_UPDATE can skip no-op opens
pfMap.lastUpdateZone = map
pfMap.dirtyMaps[map] = nil
-- map has fully rendered; subsequent zone changes are deliberate user actions
pfMap.mapJustOpened = nil
end
function pfMap:UpdateMinimap()
-- check for disabled minimap nodes
if pfQuest_config["minimapnodes"] == "0" then
if not pfMap.minimapDisabled then
for _, pin in pairs(pfMap.mpins or {}) do pin:Hide() end
pfMap.minimapDisabled = true
end
return
end
pfMap.minimapDisabled = nil
if this.minimapAppearanceSeen ~= pfMap.minimapSettingsRevision then
this.minimapAppearanceSeen = pfMap.minimapSettingsRevision
this.minimapTick, this.minimapAnchorAt, this.xPlayer = nil, nil, nil
end
-- Holding Ctrl over the minimap is an interaction gesture, not a normal map
-- refresh. Handle it before the selected refresh mode can defer work for
-- several hundred milliseconds, then force one redraw on release.
local hideMinimapNodes = controlkey.pressed and MouseIsOver(pfMap.drawlayer)
if hideMinimapNodes then
this.xPlayer = nil
this.minimapHiddenByCtrl = true
for _, pin in pairs(pfMap.mpins) do
pin:Hide()
end
return
elseif this.minimapHiddenByCtrl then
this.minimapHiddenByCtrl = nil
this.minimapTick = nil
end
-- Smooth preserves the original high-end behavior. The other modes reduce
-- repeated pin placement in dense areas, especially at wide minimap zoom.
local mZoom = pfMap.drawlayer:GetZoom()
local refreshMode = pfQuest_config["minimaprefresh"] or "smooth"
local interval
if refreshMode == "performance" then
interval = WorldMapFrame:IsShown() and 0.6 or (mZoom <= 1 and 0.75 or 0.4)
elseif refreshMode == "balanced" then
interval = WorldMapFrame:IsShown() and 0.4 or (mZoom <= 1 and 0.5 or 0.25)
else
interval = 0
end
if (this.minimapTick or 0) > GetTime() then
return
end
this.minimapTick = GetTime() + interval
-- The 1.12 API reports the player in the coordinates of whichever World Map
-- view is currently selected. While a continent map is open that is not the
-- player's zone, so use the last real-zone position for minimap placement.
-- It is refreshed continuously whenever the World Map is closed.
local xPlayer, yPlayer
if WorldMapFrame:IsShown() then
xPlayer, yPlayer = pfMap.minimapPlayerX, pfMap.minimapPlayerY
-- The coordinate API is valid when the player is browsing their current
-- zone. Keep minimap quest pins moving in that case, but retain the last
-- known real-zone position when they browse another zone or continent.
local visibleMap = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
if visibleMap and visibleMap == pfMap.minimapMapID then
local currentX, currentY = GetPlayerMapPosition("player")
if currentX and currentY and not (currentX == 0 and currentY == 0) then
xPlayer, yPlayer = currentX, currentY
pfMap.minimapPlayerX, pfMap.minimapPlayerY = currentX, currentY
end
end
else
xPlayer, yPlayer = GetPlayerMapPosition("player")
if xPlayer and yPlayer and not (xPlayer == 0 and yPlayer == 0) then
pfMap.minimapPlayerX, pfMap.minimapPlayerY = xPlayer, yPlayer
-- The World Map changes the active map context, including on the
-- opposite continent. Retain the player's actual map ID with the
-- coordinates so minimap nodes continue to use the real zone.
pfMap.minimapMapID = pfMap.playerMapID
or pfMap:GetMapIDByName(GetRealZoneText())
end
end
-- hide nodes and skip further processing in dungeons or before a usable
-- real-zone position has been captured.
if xPlayer == 0 and yPlayer == 0 then
for pins, pin in pairs(pfMap.mpins) do
pin:Hide()
end
return
end
if not xPlayer or not yPlayer then return end
xPlayer, yPlayer = xPlayer * 100, yPlayer * 100
-- force refresh every second even without changed values, otherwise skip
if this.xPlayer == xPlayer and this.yPlayer == yPlayer and this.mZoom == mZoom then
if (this.tick or 1) > GetTime() then
return
else
this.tick = GetTime() + 1
end
end
this.xPlayer, this.yPlayer, this.mZoom = xPlayer, yPlayer, mZoom
local color = pfQuest_config["spawncolors"] == "1" and "spawn" or "title"
local mapID = WorldMapFrame:IsShown() and pfMap.minimapMapID
or pfMap:GetMapIDByName(GetRealZoneText())
local mapZoom = minimap_zoom[minimap_indoor()][mZoom]
local mapWidth = minimap_sizes[mapID] and minimap_sizes[mapID][1] or 0
local mapHeight = minimap_sizes[mapID] and minimap_sizes[mapID][2] or 0
local xScale = mapZoom / mapWidth
local yScale = mapZoom / mapHeight
local xDraw = pfMap.drawlayer:GetWidth() / xScale / 100
local yDraw = pfMap.drawlayer:GetHeight() / yScale / 100
-- At a zoomed-out minimap scale, ordinary movement often shifts every pin
-- by less than a screen pixel. Keep the last layout until that shift is
-- visible (or the next scheduled refresh has elapsed), rather than
-- re-anchoring a large visible marker set on every polling interval.
local anchorX, anchorY = xPlayer * xDraw, yPlayer * yDraw
if this.minimapAnchorX and math.abs(anchorX - this.minimapAnchorX) < 1
and math.abs(anchorY - this.minimapAnchorY) < 1
and (this.minimapAnchorAt or 0) + interval > GetTime() then
return
end
this.minimapAnchorX, this.minimapAnchorY = anchorX, anchorY
this.minimapAnchorAt = GetTime()
-- Keep marker coordinates in a shared moving layer. Player movement now
-- moves this one layer instead of ClearAllPoints/SetPoint on every visible
-- minimap pin, which is vital when a zoomed-out map has many quest spawns.
local minimapLayer = pfMap.minimapLayer
if not minimapLayer then
minimapLayer = CreateFrame("Frame", nil, pfMap.drawlayer)
minimapLayer:SetFrameStrata(pfMap.drawlayer:GetFrameStrata())
pfMap.minimapLayer = minimapLayer
end
minimapLayer:SetWidth(pfMap.drawlayer:GetWidth())
minimapLayer:SetHeight(pfMap.drawlayer:GetHeight())
minimapLayer:ClearAllPoints()
minimapLayer:SetPoint("CENTER", pfMap.drawlayer, "CENTER", -anchorX, anchorY)
-- Nodes outside the minimap cannot be seen. Updating their anchors every
-- time the player moves is especially expensive in dense quest zones, so
-- cull them before texture or layout work. Keep a small margin for icons
-- entering from the edge.
local visibleHalfWidth = pfMap.drawlayer:GetWidth() / 2 + 24
local visibleHalfHeight = pfMap.drawlayer:GetHeight() / 2 + 24
-- Mark visible pins for this pass. A separate node-to-pin index prevents a
-- removal from shifting and rebuilding every later visual pin.
for _, pin in pairs(pfMap.mpins) do
pin.minimapUsed = nil
end
local freePin = 1
-- refresh all nodes
for addon, data in pairs(pfMap.nodes) do
-- hide minimap nodes in continent view
if data[mapID] and minimap_sizes[mapID] and pfMap:HasMinimap(mapID) then
for coords, node in pairs(data[mapID]) do
local x, y
if coord_cache[coords] then
x, y = coord_cache[coords][1], coord_cache[coords][2]
else
local _, _, strx, stry = strfind(coords, "(.*)|(.*)")
x, y = strx + 0, stry + 0
coord_cache[coords] = { x, y }
end
local xPos = (x - xPlayer) * xDraw
local yPos = (y - yPlayer) * yDraw
if pfQuestCompat.rotateMinimap then
-- TODO: this part is broken and does not work yet.
local sinFacing = sin(pfQuestCompat.GetPlayerFacing())
local cosFacing = cos(pfQuestCompat.GetPlayerFacing())
local dx, dy = xPos, -yPos
xPos = (dx * cosFacing) + (dy * sinFacing)
yPos = -((-dx * sinFacing) + (dy * cosFacing))
end
local isVisible = math.abs(xPos) <= visibleHalfWidth
and math.abs(yPos) <= visibleHalfHeight
if not isVisible then
-- Keep the visible-pin pool compact; remaining old pins are hidden
-- together after the scan.
else
local display = nil
local distance = sqrt(xPos * xPos + yPos * yPos)
if pfUI.minimap then
display = (abs(xPos) + 8 < pfMap.drawlayer:GetWidth() / 2 and abs(yPos) + 8 < pfMap.drawlayer:GetHeight() / 2)
and true
or nil
else
display = (distance + 8 < pfMap.drawlayer:GetWidth() / 2) and true or nil
end
if display then
local pin = pfMap.mpinNodeIndex[node]
if not pin or pin.minimapUsed then
while pfMap.mpins[freePin] and pfMap.mpins[freePin].minimapUsed do
freePin = freePin + 1
end
pin = pfMap.mpins[freePin]
if not pin then
pin = pfMap:BuildNode(nodename .. freePin, pfMap.drawlayer)
pfMap.mpins[freePin] = pin
elseif pin.node then
pfMap.mpinNodeIndex[pin.node] = nil
end
pfMap.mpinNodeIndex[node] = pin
-- Force one visual update when this reusable frame changes owner.
pin.node = nil
freePin = freePin + 1
end
pin.minimapUsed = true
if pin:GetParent() ~= minimapLayer then
pin:SetParent(minimapLayer)
pin.minimapX = nil
pin.minimapY = nil
end
-- skip expensive UpdateNode work (highlightdb rebuild, node iteration,
-- size calls) when this pin is already showing the correct node and
-- nothing has been added or removed from it since the last render.
if pin.node ~= node or pfMap.dirtyMinimapNodes[node]
or pin.appearanceRevision ~= pfMap.appearanceRevision
or pin.showTrackingIcon ~= not (pin.tracking and pfQuest_config.trackingicons == "0") then
pfMap:UpdateNode(pin, node, color, "minimap", distance)
pfMap.dirtyMinimapNodes[node] = nil
end
-- Custom tracking icons fade with player distance even when the
-- underlying node data is unchanged and its expensive rebuild is
-- skipped.
UpdateMinimapIconFade(pin, distance)
if pin.hl:IsShown() then
pin.hl:Hide()
end
if pfQuest_config["showclustermini"] == "0" and pin.cluster then
if pin:IsShown() then pin:Hide() end
elseif pfQuest_config["showspawnmini"] == "0" and addon == "PFQUEST" and not pin.texture then
if pin:IsShown() then pin:Hide() end
else
-- Anchor against absolute map coordinates. The shared layer above
-- supplies the player-position offset, so walking does not touch
-- this pin's anchors.
if pin.minimapX ~= x or pin.minimapY ~= y
or pin.minimapXDraw ~= xDraw or pin.minimapYDraw ~= yDraw then
pin.minimapX = x
pin.minimapY = y
pin.minimapXDraw = xDraw
pin.minimapYDraw = yDraw
pin:ClearAllPoints()
pin:SetPoint("CENTER", minimapLayer, "CENTER", x * xDraw, -y * yDraw)
end
if not pin:IsShown() then
pin:Show()
end
end
end
end
end
end
end
-- hide remaining pins
for _, pin in pairs(pfMap.mpins) do
if not pin.minimapUsed and pin:IsShown() then
pin:Hide()
end
end
end
local zone
local function CapturePlayerMapID()
-- SetMapToCurrentZone gives city maps their own ID. Keep this independent of
-- the map currently selected by the player in the World Map window.
if WorldMapFrame:IsShown() then
-- Do not change a map the player is browsing, but real-zone text remains
-- safe to read and lets us retain capital visits made with the map open.
if GetRealZoneText then
pfMap:MarkMapVisited(pfMap:GetMapIDByName(GetRealZoneText()))
end
return
end
-- MINIMAP_ZONE_CHANGED also fires while moving between named subareas in a
-- city. If the real map has not changed, calling SetMapToCurrentZone again
-- creates a WORLD_MAP_UPDATE burst and rebuilds the map for no benefit.
-- Keep the existing map ID in that common movement path.
local realMapID = GetRealZoneText and pfMap:GetMapIDByName(GetRealZoneText())
if realMapID and pfMap.playerMapID == realMapID then
pfMap:MarkMapVisited(realMapID)
return
end
SetMapToCurrentZone()
pfMap.playerMapID = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
or (GetRealZoneText and pfMap:GetMapIDByName(GetRealZoneText()))
pfMap:MarkMapVisited(pfMap.playerMapID)
-- Some clients draw a capital as its surrounding zone map, but still report
-- the capital's real zone text. Preserve that city visit as well.
if GetRealZoneText then
pfMap:MarkMapVisited(pfMap:GetMapIDByName(GetRealZoneText()))
end
end
pfMap:RegisterEvent("PLAYER_LOGIN")
pfMap:RegisterEvent("ZONE_CHANGED")
pfMap:RegisterEvent("ZONE_CHANGED_NEW_AREA")
pfMap:RegisterEvent("MINIMAP_ZONE_CHANGED")
pfMap:RegisterEvent("WORLD_MAP_UPDATE")
pfMap:SetScript("OnEvent", function()
-- Darnassus and other dense cities can emit MINIMAP_ZONE_CHANGED repeatedly
-- while the player moves. Once the real map is known, do not even query map
-- state for those cosmetic subarea notifications.
if event == "MINIMAP_ZONE_CHANGED" and pfMap.playerMapID then
return
end
-- save current zone
zone = GetCurrentMapZone()
-- set map to current zone when possible
if event == "PLAYER_LOGIN" or event == "ZONE_CHANGED" or event == "ZONE_CHANGED_NEW_AREA" then
-- Read the map immediately after SetMapToCurrentZone. This preserves a
-- capital's own map ID rather than replacing it with its parent zone.
CapturePlayerMapID()
elseif event == "MINIMAP_ZONE_CHANGED" and not pfMap.playerMapID then
-- This event can fire repeatedly while walking through named subareas.
-- It is only useful before the initial player map has been established;
-- real zone transitions emit the zone-change events above.
CapturePlayerMapID()
end
-- update nodes on world map changes.
-- Three distinct cases:
-- (1) Map just opened (burst of ~100 events while frame renders): use the
-- debounce to coalesce them into one call once rendering settles.
-- (2) Zone changed by user (deliberate click): call UpdateNodes immediately.
-- (3) Continent view (newzone == nil): hide all pins immediately.
-- (4) Same zone, dirty nodes: stamp debounce.
if event == "WORLD_MAP_UPDATE" then
local newzone = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
-- Let the selected zone finish drawing before reading its overlay data.
-- The first WORLD_MAP_UPDATE arrives before 1.12 has populated fog data.
if newzone and WorldMapFrame:IsShown() then
pfMap.explorationCacheMap = newzone
pfMap.explorationCacheAt = GetTime() + 0.35
end
if newzone == nil then
-- continent view: hide all worldmap pins immediately
for j = 1, table.getn(pfMap.pins) do
if pfMap.pins[j] then
pfMap.pins[j]:Hide()
end
end
pfMap.lastUpdateZone = nil
elseif WorldMapFrame:IsShown() and pfMap.hiddenPinsReady == newzone then
pfMap.hiddenPinJob = nil
pfMap.hiddenPinsReady = nil
pfMap.queue_update = nil
if pfQuest.route then
pfQuest.route.lastDrawX, pfQuest.route.lastDrawY, pfQuest.route.lastDrawNode = nil, nil, nil
end
pfMap:UpdateNodes()
elseif pfMap.mapJustOpened then
-- Map opens and map selections emit a short event burst. Render after
-- the normal settle period. Even enhanced clients emit a burst while
-- the map view changes. Ordinary
-- filter and quest-data updates retain the full debounce below.
-- A route created while the map was hidden has valid coordinates but
-- its last-draw cache refers to the hidden canvas. Invalidate only the
-- drawing cache so the existing target is painted on the visible map.
if pfQuest.route then
pfQuest.route.lastDrawX = nil
pfQuest.route.lastDrawY = nil
pfQuest.route.lastDrawNode = nil
end
pfMap.queue_update = GetTime()
elseif newzone ~= pfMap.lastUpdateZone then
-- deliberate zone change: update immediately, no debounce
pfMap.queue_update = nil
pfMap:UpdateNodes()
elseif pfMap.dirtyMaps[newzone] then
-- same zone, pending writes: coalesce via debounce
pfMap.queue_update = GetTime()
end
end
end)
local hlstate, shiftstate, transition, hidecluster, fps, resetmap
-- Keep queued node rebuilds independent of the map canvas. Some clients stop
-- updating WorldFrame children while particular UI panels are closed, which
-- leaves minimap pins and routes stale until a later world-map refresh.
-- Prepare hidden zone-pin frames in bounded batches. This never reads the
-- quest log or changes tracker/routes; the visible renderer remains authoritative.
function pfMap:PrepareHiddenMapPins()
if WorldMapFrame:IsShown() or self.queue_update then
self.hiddenPinJob = nil
return
end
local map = self:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
if not map or not self.dirtyMaps[map] then self.hiddenPinJob = nil; return end
local width, height = WorldMapButton:GetWidth(), WorldMapButton:GetHeight()
if width <= 0 or height <= 0 then return end
local job = self.hiddenPinJob
if not job or job.map ~= map or job.width ~= width or job.height ~= height then
job = { map = map, width = width, height = height, index = 1, entries = {}, nodes = {} }
for addon, maps in pairs(self.nodes) do
for coords, node in pairs(maps[map] or {}) do
table.insert(job.entries, coords)
table.insert(job.nodes, node)
end
end
self.hiddenPinJob = job
self.hiddenPinsReady = nil
end
if job.finished then return end
local stop = math.min(job.index + 5, table.getn(job.entries))
local color = pfQuest_config["spawncolors"] == "1" and "spawn" or "title"
for index = job.index, stop do
local coords, node = job.entries[index], job.nodes[index]
local pin = self.pins[index]
if not pin then
pin = self:BuildNode("pfMapPin" .. index, WorldMapButton)
self.pins[index] = pin
end
if pin:GetParent() ~= WorldMapButton then
pin:SetParent(WorldMapButton)
pin.lastX, pin.lastY = nil, nil
end
if pin.node ~= node or self.dirtyNodes[node]
or pin.appearanceRevision ~= pfMap.appearanceRevision
or pin.showTrackingIcon ~= not (pin.tracking and pfQuest_config.trackingicons == "0") then
self:UpdateNode(pin, node, color)
self.dirtyNodes[node] = nil
end
local x, y
if coord_cache[coords] then
x, y = coord_cache[coords][1], coord_cache[coords][2]
else
local _, _, sx, sy = strfind(coords, "(.*)|(.*)")
x, y = sx + 0, sy + 0
coord_cache[coords] = { x, y }
end
local px, py = x / 100 * width, y / 100 * height
if pin.lastX ~= px or pin.lastY ~= py then
pin:ClearAllPoints()
pin:SetPoint("CENTER", WorldMapButton, "TOPLEFT", px, -py)
pin.lastX, pin.lastY = px, py
end
-- A reused pin may retain a hover fade from its previous binding.
-- Set its resting opacity before the parent map becomes visible.
pin:SetAlpha((pin.texture or pin.cluster) and 1 or pin.defalpha)
pin:Hide()
end
job.index = stop + 1
-- Old surplus pins are retired in equally bounded batches.
if job.index > table.getn(job.entries) then
job.cleanup = job.cleanup or job.index
local last = math.min(job.cleanup + 5, table.getn(self.pins))
for index = job.cleanup, last do self.pins[index]:Hide() end
job.cleanup = last + 1
if job.cleanup > table.getn(self.pins) then
job.finished = true
self.hiddenPinsReady = map
end
end
end
local nodeUpdateDriver = CreateFrame("Frame", nil, UIParent)
nodeUpdateDriver:SetScript("OnUpdate", function()
-- Keep preparation bounded to six pins per rendered frame, rather than
-- six per50ms: dense zones otherwise need several seconds to get ready.
local preparingBusy = pfQuest and ((pfQuest.queueCount or 0) > 0
or pfQuest.updateQuestGivers or pfQuest.updateQuestLog)
if pfMap.queue_update or preparingBusy then
pfMap.hiddenPinJob = nil
pfMap.hiddenPinsReady = nil
elseif not WorldMapFrame:IsShown() then
pfMap:PrepareHiddenMapPins()
end
if (this.throttle or 0) > GetTime() then return end
this.throttle = GetTime() + 0.05
pfMap:UpdateNodeTooltip()
local renderDelay = WorldMapFrame:IsShown() and 0.05 or 0.25
if pfMap.queue_update and pfMap.queue_update + renderDelay < GetTime() then
local questBusy = pfQuest and ((pfQuest.queueCount or 0) > 0
or pfQuest.updateQuestGivers or pfQuest.updateQuestLog)
if not questBusy then
pfMap.queue_update = nil
-- Node changes must bypass the movement/zoom cache. UpdateNodes handles
-- tracker and route state while the world map is closed, but the normal
-- minimap poll can otherwise reuse its previous player position for up
-- to a second and leave a removed or restored quest icon visibly stale.
pfMap.xPlayer = nil
pfMap.minimapTick = nil
pfMap:UpdateNodes()
pfMap:UpdateMinimap()
end
end
end)
nodeUpdateDriver:Show()
pfMap:SetScript("OnUpdate", function()
-- handle highlights and animations
if pfMap.queue_update or transition or pfMap.highlight ~= hlstate or shiftstate ~= hidecluster then
hlstate, shiftstate, transition = pfMap.highlight, hidecluster, nil
fps = math.max(0.2, GetFramerate() / 30)
for frame, data in pairs(pfMap.highlightdb) do
local highlight = pfMap.highlightdb[frame][pfMap.highlight] and true or nil
if hidecluster and frame.cluster then
-- hide clusters
transition = frame:Animate(frame.defsize, 0, fps) or transition
elseif highlight then
-- zoom node
transition = frame:Animate((frame.texture and frame.defsize + 4 or frame.defsize), 1, fps) or transition
elseif not highlight and pfMap.highlight then
-- fade node
transition = frame:Animate(frame.defsize, tonumber(pfQuest_config["nodefade"]) or 0.3, fps) or transition
elseif frame.texture or frame.cluster then
-- defaults for textured nodes
transition = frame:Animate(frame.defsize, 1, fps) or transition
else
-- defaults
transition = frame:Animate(frame.defsize, frame.defalpha, fps) or transition
end
end
end
-- limit all map updates to once per .05 seconds
if (this.throttle or 0.2) > GetTime() then
return
else
this.throttle = GetTime() + 0.05
end
if pfMap.explorationCacheAt and pfMap.explorationCacheAt <= GetTime() then
local map = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
if WorldMapFrame:IsShown() and map == pfMap.explorationCacheMap then
pfMap:CacheCurrentExploration(map)
-- Map overlays arrive after the initial WORLD_MAP_UPDATE on many maps.
-- The first render may therefore have treated every pin as unexplored.
-- Rebuild once the client has populated its fog data so the display and
-- saved cache use the same bounds.
if pfQuest_config["hideunexplored"] == "1" then
pfMap.queue_update = GetTime()
end
end
pfMap.explorationCacheMap = nil
pfMap.explorationCacheAt = nil
end
-- reset map to current zone once map is closed
-- also flag the frame as just-opened so the WORLD_MAP_UPDATE handler
-- knows to use the debounce instead of calling UpdateNodes immediately
-- (the burst of ~100 events during map-open rendering would otherwise
-- trigger multiple immediate UpdateNodes calls)
if WorldMapFrame:IsShown() then
if not resetmap then
pfMap.mapJustOpened = true
end
resetmap = true
elseif resetmap == true then
SetMapToCurrentZone()
-- Do not call GetPlayerMapID here: it intentionally returns the cached
-- value first, which would preserve the map the player was browsing.
-- Read the client map context we just restored instead.
pfMap.playerMapID = pfMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
or (GetRealZoneText and pfMap:GetMapIDByName(GetRealZoneText()))
pfMap:MarkMapVisited(pfMap.playerMapID)
resetmap = nil
end
-- refresh minimap
pfMap:UpdateMinimap()
-- update hidecluster detection
if controlkey.pressed then
hidecluster = MouseIsOver(WorldMapFrame)
else
hidecluster = nil
end
end)
-- only hook for 3.3.5
if compat.client >= 30300 then
-- Initialize a variable to track the previous clicked title
local previousTitle = nil
-- Highlight Map Quest Log Selection Nodes
local pfHookWorldMapQuestFrame_OnMouseUp = WorldMapQuestFrame_OnMouseUp
WorldMapQuestFrame_OnMouseUp = function(self)
pfHookWorldMapQuestFrame_OnMouseUp(self)
WorldMapBlobFrame:Hide()
WorldMapFrame_ClearQuestPOIs()
if not IsShiftKeyDown() then
pfMap.highlight = nil
local questLogIndex = GetQuestLogSelection()
local title = GetQuestLogTitle(questLogIndex)
if title then
if previousTitle == title then
-- Reset the highlight if the same title is clicked again
pfMap.highlight = nil
previousTitle = nil
else
-- Logic for highlighting nodes associated with the clicked quest
pfMap.highlight = title
previousTitle = title
pfMap.queue_update = GetTime()
end
end
end
end
end