Files
Questie-X/Modules/Map/QuestieMap.lua
T
2026-06-13 07:49:04 -05:00

1286 lines
56 KiB
Lua

---@class QuestieMap
local QuestieMap = QuestieLoader:CreateModule("QuestieMap");
---@type QuestieMapUtils
QuestieMap.utils = QuestieMap.utils or {}
-------------------------
--Import modules.
-------------------------
---@type QuestieFramePool
local QuestieFramePool = QuestieLoader:ImportModule("QuestieFramePool");
---@type QuestieDBMIntegration
local QuestieDBMIntegration = QuestieLoader:ImportModule("QuestieDBMIntegration");
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib");
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer");
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB");
---@type ZoneDB
local ZoneDB = QuestieLoader:ImportModule("ZoneDB")
---@type l10n
local l10n = QuestieLoader:ImportModule("l10n")
---@type WeaponMasterSkills
local WeaponMasterSkills = QuestieLoader:ImportModule("WeaponMasterSkills")
--- COMPATIBILITY ---
local C_Timer = QuestieCompat.C_Timer
local C_Map = QuestieCompat.C_Map
QuestieMap.ICON_MAP_TYPE = "MAP";
QuestieMap.ICON_MINIMAP_TYPE = "MINIMAP";
-- List of frames sorted by quest ID (automatic notes)
-- E.g. {[questId] = {[frameName] = frame, ...}, ...}
QuestieMap.questIdFrames = {}
-- List of frames sorted by NPC/object ID (manual notes)
-- id > 0: NPC
-- id < 0: object
-- E.g. {[-objectId] = {[frameName] = frame, ...}, ...}
QuestieMap.manualFrames = {}
--Used in my fadelogic.
local fadeOverDistance = 10;
local normalizedValue = 1 / fadeOverDistance;
local HBD = QuestieCompat.HBD or LibStub("HereBeDragonsQuestie-2.0")
local HBDPins = QuestieCompat.HBDPins or LibStub("HereBeDragonsQuestie-Pins-2.0")
local tostring = tostring;
local tinsert = table.insert;
local pairs = pairs;
local ipairs = ipairs;
local tremove = table.remove;
local tunpack = unpack;
local drawTimer
local fadeLogicTimerShown
local fadeLogicCoroutine
local MINIMAP_VISIBILITY_CHECK_INTERVAL = 1
local function _ResolveMapUiMapId(uiMapId, x, y)
-- Ghost map 946 has no real coordinate data; redirect to Eversong (1941).
if uiMapId == 946 then
return 1941
end
-- Map 1241 (Sunstrider Isle) must keep its native render target so the
-- actual Sunstrider map can show pins too. Visibility is handled separately
-- through ResolveZone()/isSameZoneSpace logic, which keeps 1241 and 1941
-- linked without forcing the render target to the parent map.
return uiMapId
end
local function _ShouldMinimapIconBeHidden(icon, forceRefresh)
local now = GetTime()
if forceRefresh or (not icon._lastShouldBeHiddenCheck) or (now - icon._lastShouldBeHiddenCheck) >= MINIMAP_VISIBILITY_CHECK_INTERVAL then
icon._lastShouldBeHiddenCheck = now
icon._lastShouldBeHiddenResult = icon:ShouldBeHidden()
end
return icon._lastShouldBeHiddenResult
end
local function _GetMinimapDistanceFade(distance, profile)
local fade = 1 - (math.min(fadeOverDistance, (distance - profile.fadeLevel)) * normalizedValue)
local minimumFade = profile.iconFadeLevel or 0.3
if fade < minimumFade then
return minimumFade
end
return fade
end
local isDrawQueueDisabled = false
--* TODO: How the frames are handled needs to be reworked, why are we getting them from _G
--Get the frames for a quest, this returns all of the frames
function QuestieMap:GetFramesForQuest(questId)
local frames = {}
if QuestieMap.questIdFrames[questId] then
for _, name in pairs(QuestieMap.questIdFrames[questId]) do
if _G[name] then
frames[name] = _G[name]
end
end
end
return frames
end
-- Unload only the frames belonging to a single objective of a quest.
-- Frames carry data.ObjectiveIndex (set in _DetermineIconsToDraw), so we can remove a
-- completed objective's pins deterministically off the map's own frame registry instead
-- of relying on objective.AlreadySpawned, which desyncs from on-screen frames after
-- learner spawn-list invalidation or complete->abandon->reaccept cycles.
function QuestieMap:UnloadQuestFramesForObjective(questId, objectiveIndex)
-- objectiveIndex 0 is the SpecialObjectives sentinel (shared by all special objectives),
-- so unloading by it would cross-remove sibling specials mid-pass. Only act on real
-- positive standard-objective indices; specials keep using AlreadySpawned.
if (not objectiveIndex) or objectiveIndex <= 0 or (not QuestieMap.questIdFrames[questId]) then
return
end
for name, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do
if frame and frame.data and frame.data.ObjectiveIndex == objectiveIndex then
frame:Unload()
QuestieMap.questIdFrames[questId][name] = nil
_G[name] = nil
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Unloading objective frames for questid:", questId, "objIdx:", objectiveIndex)
end
function QuestieMap:UnloadQuestFrames(questId, iconType)
if not iconType then
QuestieMap:PurgeQuestFrames(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Unloading quest frames for questid:", questId)
return
end
if QuestieMap.questIdFrames[questId] then
for name, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do
if frame and frame.data and frame.data.Icon == iconType then
frame:Unload();
QuestieMap.questIdFrames[questId][name] = nil
_G[name] = nil
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Unloading quest frames for questid:", questId)
end
end
function QuestieMap:UnloadQuestFramesByDataType(questId, dataType)
if QuestieMap.questIdFrames[questId] then
for name, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do
if frame and frame.data and frame.data.Type == dataType then
frame:Unload()
-- Also drop the registry/global reference. Without this the frame name
-- lingered in questIdFrames and _G, so the available '!' could stay on the
-- minimap after a quest was accepted until a full /reload rebuilt frames. (#9)
QuestieMap.questIdFrames[questId][name] = nil
_G[name] = nil
end
end
if not next(QuestieMap.questIdFrames[questId]) then
QuestieMap.questIdFrames[questId] = nil
end
end
end
function QuestieMap:UnloadQuestFramesExceptDataType(questId, dataType)
if QuestieMap.questIdFrames[questId] then
for _, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do
if frame and ((not frame.data) or frame.data.Type ~= dataType) then
frame:Unload()
end
end
if not next(QuestieMap.questIdFrames[questId]) then
QuestieMap.questIdFrames[questId] = nil
end
end
end
--Get the frames for manual note, this returns all of the frames/spawns
---@param id number @The ID of the NPC (>0) or object (<0)
function QuestieMap:GetManualFrames(id, typ)
typ = typ or "any"
local frames = {}
if QuestieMap.manualFrames[typ] and (QuestieMap.manualFrames[typ][id]) then
for _, name in pairs(QuestieMap.manualFrames[typ][id]) do
tinsert(frames, _G[name])
end
end
return frames
end
---@param id number @The ID of the NPC (>0) or object (<0)
function QuestieMap:UnloadManualFrames(id, typ)
typ = typ or "any"
if QuestieMap.manualFrames[typ] and (QuestieMap.manualFrames[typ][id]) then
for _, frame in ipairs(QuestieMap:GetManualFrames(id, typ)) do
frame:Unload();
end
QuestieMap.manualFrames[typ][id] = nil;
end
end
function QuestieMap:ResetManualFrames(typ)
typ = typ or "any"
if QuestieMap.manualFrames[typ] then
for id in pairs(QuestieMap.manualFrames[typ]) do
QuestieMap:UnloadManualFrames(id, typ)
end
end
end
-- Rescale all the icons
function QuestieMap:RescaleIcons()
local mapScale = QuestieMap.GetScaleValue()
for _, framelist in pairs(QuestieMap.questIdFrames) do
for _, frameName in pairs(framelist) do
QuestieMap.utils:RescaleIcon(frameName, mapScale)
end
end
for _, frameTypeList in pairs(QuestieMap.manualFrames) do
for _, framelist in pairs(frameTypeList) do
for _, frameName in ipairs(framelist) do
QuestieMap.utils:RescaleIcon(frameName, mapScale)
end
end
end
end
local mapDrawQueue = {};
local minimapDrawQueue = {};
QuestieMap._mapDrawQueue = mapDrawQueue
QuestieMap._minimapDrawQueue = minimapDrawQueue
--- Removes any still-pending world-map / minimap draw calls for a frame.
--- The two draw queues are processed independently in ProcessQueue, so a frame that is
--- unloaded while still queued (e.g. a quest objective that completes the same tick its
--- pins were queued) could otherwise be re-added to the WORLD MAP by a later map-queue
--- entry AFTER it was already unloaded via its minimap-queue entry. Because Unload nils
--- the questIdFrames/_G reference, that re-added world-map pin becomes orphaned and stays
--- until /reload — while the minimap pin was correctly removed. Dropping the queued draw
--- calls on Unload keeps both maps in sync. (#9)
function QuestieMap:DequeueFrameDrawCalls(frame)
if not frame then return end
for i = #mapDrawQueue, 1, -1 do
if mapDrawQueue[i] and mapDrawQueue[i][2] == frame then
tremove(mapDrawQueue, i)
end
end
for i = #minimapDrawQueue, 1, -1 do
if minimapDrawQueue[i] and minimapDrawQueue[i][2] == frame then
tremove(minimapDrawQueue, i)
end
end
end
function QuestieMap:PurgeQuestFrames(questId)
if not questId then return end
local frames = {}
for _, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do
if frame then
frames[frame] = true
end
end
local function collectQueuedFrames(queue)
if type(queue) ~= "table" then return end
for i = #queue, 1, -1 do
local frame = queue[i] and queue[i][2]
if frame and frame.data and frame.data.Id == questId then
frames[frame] = true
tremove(queue, i)
end
end
end
collectQueuedFrames(QuestieMap._mapDrawQueue)
collectQueuedFrames(QuestieMap._minimapDrawQueue)
local function collectHbdFrames(pinTable)
if type(pinTable) ~= "table" then return end
for frame in pairs(pinTable) do
if frame and frame.data and frame.data.Id == questId then
frames[frame] = true
end
end
end
if HBDPins then
collectHbdFrames(HBDPins.activeMinimapPins)
collectHbdFrames(HBDPins.worldmapPins)
end
for frame in pairs(frames) do
if frame.Unload then
frame:Unload()
else
if HBDPins then
HBDPins:RemoveMinimapIcon(Questie, frame)
HBDPins:RemoveWorldMapIcon(Questie, frame)
end
if frame.Hide then frame:Hide() end
if frame.ClearAllPoints then frame:ClearAllPoints() end
end
if frame.GetName then
local frameName = frame:GetName()
if frameName then
_G[frameName] = nil
end
end
end
QuestieMap.questIdFrames[questId] = nil
end
--- Called at startup (Stage 3) and on PLAYER_ENTERING_WORLD to reset the draw queue.
function QuestieMap:InitializeQueue()
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Starting draw queue timer!")
local isInInstance, instanceType = IsInInstance()
if (not isInInstance) or instanceType ~= "raid" then
isDrawQueueDisabled = false
if not drawTimer then
drawTimer = C_Timer.NewTicker(0.2, QuestieMap.ProcessQueue)
fadeLogicTimerShown = C_Timer.NewTicker(0.1, function()
if fadeLogicCoroutine and coroutine.status(fadeLogicCoroutine) == "suspended" then
local success, errorMsg = coroutine.resume(fadeLogicCoroutine)
if (not success) then
Questie:Error("Please report on Github or Discord. Minimap pins fade logic coroutine stopped:", errorMsg)
fadeLogicCoroutine = nil
end
end
end)
end
if not fadeLogicCoroutine then
fadeLogicCoroutine = coroutine.create(QuestieMap.ProcessShownMinimapIcons)
end
else
if drawTimer then
drawTimer:Cancel()
drawTimer = nil
fadeLogicTimerShown:Cancel()
fadeLogicTimerShown = nil
end
isDrawQueueDisabled = true
end
end
---@return number @A scale value that is based of the map currently open, smaller icons for World and Continent
function QuestieMap.GetScaleValue()
if not HBDPins or not HBDPins.worldmapProvider then return 1 end
local mapId = HBDPins.worldmapProvider:GetMap():GetMapID();
local scaling = 1;
if C_Map and C_Map.GetAreaInfo then
local mapInfo = C_Map.GetMapInfo(mapId)
if mapInfo then
if (mapInfo.mapType == 0) then
scaling = 0.85
elseif (mapInfo.mapType == 1) then
scaling = 0.85
elseif (mapInfo.mapType == 2) then
scaling = 0.9
end
end
end
return scaling
end
function QuestieMap:ProcessShownMinimapIcons()
local getTime, cYield, getWorldPos = GetTime, coroutine.yield, HBD.GetPlayerWorldPosition
local maxCount = 50
local doEdgeUpdate = true
local playerX, playerY
local count
local lastUpdate = getTime()
local xd, yd
local totalDistance = 0
while true do
count = 0
playerX, playerY = getWorldPos()
xd = (playerX or 0) - (QuestieMap.playerX or 0)
yd = (playerY or 0) - (QuestieMap.playerY or 0)
totalDistance = totalDistance + (xd * xd + yd * yd)
QuestieMap.playerX = playerX
QuestieMap.playerY = playerY
if totalDistance > 3 or getTime() - lastUpdate >= 1 then
doEdgeUpdate = true
lastUpdate = getTime()
totalDistance = 0
end
if HBDPins and HBDPins.activeMinimapPins then
for minimapFrame, data in pairs(HBDPins.activeMinimapPins) do
if minimapFrame.miniMapIcon and ((data.distanceFromMinimapCenter < 1.1) or doEdgeUpdate) then
if minimapFrame.FadeLogic then
minimapFrame:FadeLogic()
end
if minimapFrame.GlowUpdate then
minimapFrame:GlowUpdate()
end
end
if count > maxCount then
cYield()
if not HBDPins.activeMinimapPins[minimapFrame] then
totalDistance = 9000
break
end
count = 0
else
count = count + 1
end
end
end
cYield()
doEdgeUpdate = false
end
end
function QuestieMap:RefreshMinimapIconVisibility()
if not HBDPins or not HBDPins.activeMinimapPins then
return
end
local minimapVisibilityCutoff = Questie.db.profile.minimapIconRangeCutoff or 100
if HBDPins.SetMinimapVisibilityCutoff then
HBDPins:SetMinimapVisibilityCutoff(minimapVisibilityCutoff)
end
if HBDPins.RefreshMinimap then
HBDPins:RefreshMinimap()
end
for minimapFrame, data in pairs(HBDPins.activeMinimapPins) do
if minimapFrame and minimapFrame.miniMapIcon and minimapFrame.FadeLogic then
minimapFrame.minimapVisibilityCutoff = minimapVisibilityCutoff
minimapFrame._lastShouldBeHiddenCheck = nil
minimapFrame:FadeLogic()
if minimapFrame.GlowUpdate then
minimapFrame:GlowUpdate()
end
end
end
end
function QuestieMap:QueueDraw(drawType, ...)
if (not isDrawQueueDisabled) then
if _G.QuestieDebugPins then
local args = { ... }
local frame = args[2]
local data = frame and frame.data
local label = data and (data.Name or data.name or data.Title)
local questId = data and (data.Id or data.id)
if label == "Mana Wyrm" or questId == 8325 then
print(string.format(
"[QD] QueueDraw type=%s frame=%s label=%s quest=%s mini=%s ui=%s area=%s",
tostring(drawType),
tostring(frame and frame:GetName()),
tostring(label),
tostring(questId),
tostring(frame and frame.miniMapIcon),
tostring(frame and frame.UiMapID),
tostring(frame and frame.AreaID)
))
end
end
if (drawType == QuestieMap.ICON_MAP_TYPE) then
tinsert(mapDrawQueue, { ... });
elseif (drawType == QuestieMap.ICON_MINIMAP_TYPE) then
tinsert(minimapDrawQueue, { ... });
end
end
end
function QuestieMap.ProcessQueue()
if (not next(mapDrawQueue) and (not next(minimapDrawQueue))) then
return
end
local scaleValue = QuestieMap.GetScaleValue()
for _ = 1, math.min(24, math.max(#mapDrawQueue, #minimapDrawQueue)) do
local mapDrawCall = tremove(mapDrawQueue, 1);
if mapDrawCall then
local frame = mapDrawCall[2];
HBDPins:AddWorldMapIconMap(tunpack(mapDrawCall));
local size = (16 * (frame.data.IconScale or 1) * (Questie.db.profile.globalScale or 0.7)) * scaleValue;
frame:SetSize(size, size)
QuestieMap.utils:SetDrawOrder(frame);
end
local minimapDrawCall = tremove(minimapDrawQueue, 1);
if minimapDrawCall then
local frame = minimapDrawCall[2];
HBDPins:AddMinimapIconMap(tunpack(minimapDrawCall));
if _G.QuestieDebugPins then
local data = frame and frame.data
local label = data and (data.Name or data.name or data.Title)
local questId = data and (data.Id or data.id)
if label == "Mana Wyrm" or questId == 8325 then
local point1, rel1, point2, xOff, yOff = frame:GetPoint()
print(string.format(
"[QD] ProcessQueue minimap frame=%s label=%s quest=%s shown=%s hidden=%s parent=%s point=(%s,%s,%s,%s,%s)",
tostring(frame:GetName()),
tostring(label),
tostring(questId),
tostring(frame:IsShown()),
tostring(frame.hidden),
tostring(frame:GetParent() and frame:GetParent():GetName() or frame:GetParent()),
tostring(point1),
tostring(rel1 and rel1.GetName and rel1:GetName() or rel1),
tostring(point2),
tostring(xOff),
tostring(yOff)
))
end
end
QuestieMap.utils:SetDrawOrder(frame);
end
if mapDrawCall then
mapDrawCall[2]._loaded = true
if mapDrawCall[2]._needsUnload then
mapDrawCall[2]:Unload()
end
end
if minimapDrawCall then
minimapDrawCall[2]._loaded = true
if minimapDrawCall[2]._needsUnload then
minimapDrawCall[2]:Unload()
end
end
end
end
-- Show NPC on map
---@param npcID number @The ID of the NPC
function QuestieMap:ShowNPC(npcID, icon, scale, title, body, disableShiftToRemove, typ, excludeDungeon)
if type(npcID) ~= "number" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:ShowNPC] Got <" .. type(npcID) .. "> instead of <number>")
return
end
local npc = QuestieDB:GetNPC(npcID)
if (not npc) or (not npc.spawns) then return end
local data = {}
data.id = npc.id
data.Icon = icon or "Interface\\WorldMap\\WorldMapPartyIcon"
data.GetIconScale = function() return scale or Questie.db.profile.manualScale or 0.7 end
data.IconScale = data:GetIconScale()
data.Type = "manual"
data.spawnType = "monster"
data.npcData = npc
data.Name = npc.name
data.IsObjectiveNote = false
data.ManualTooltipData = {}
local baseTitle = title or (npc.name .. " (" .. l10n("NPC") .. ")")
data.ManualTooltipData.Title = WeaponMasterSkills and WeaponMasterSkills.AppendSkillsToTitle(baseTitle, data.id) or baseTitle
local level = tostring(npc.minLevel)
local health = tostring(npc.minLevelHealth)
if npc.minLevel ~= npc.maxLevel then
level = level .. '-' .. tostring(npc.maxLevel)
health = health .. '-' .. tostring(npc.maxLevelHealth)
end
data.ManualTooltipData.Body = body or {
{ 'ID:', tostring(npc.id) },
{ 'Level:', level },
{ 'Health:', health },
}
data.ManualTooltipData.disableShiftToRemove = disableShiftToRemove
local manualIcons = {}
for zone, spawns in pairs(npc.spawns) do
if (zone ~= nil and spawns ~= nil) and ((not excludeDungeon) or (not ZoneDB.IsDungeonZone(zone))) then
for _, coords in ipairs(spawns) do
local dungeonLocation = ZoneDB:GetDungeonLocation(zone)
if dungeonLocation ~= nil then
for _, value in ipairs(dungeonLocation) do
QuestieMap:DrawManualIcon(data, value[1], value[2], value[3], typ)
end
else
manualIcons[zone] = QuestieMap:DrawManualIcon(data, zone, coords[1], coords[2], typ)
end
end
end
end
if npc.waypoints then
for zone, waypoints in pairs(npc.waypoints) do
if not ZoneDB:GetDungeonLocation(zone) and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
if not manualIcons[zone] then
manualIcons[zone] = QuestieMap:DrawManualIcon(data, zone, waypoints[1][1][1], waypoints[1][1][2])
end
QuestieMap:DrawWaypoints(manualIcons[zone], waypoints, zone)
end
end
end
end
-- Show object on map
---@param objectID number
function QuestieMap:ShowObject(objectID, icon, scale, title, body, disableShiftToRemove, typ)
if type(objectID) ~= "number" then return end
local object = QuestieDB:GetObject(objectID)
if not object or not object.spawns then return end
local data = {}
if typ then
data.id = object.id
else
data.id = -object.id
end
data.Icon = icon or "Interface\\WorldMap\\WorldMapPartyIcon"
data.GetIconScale = function() return scale or Questie.db.profile.manualScale or 0.7 end
data.IconScale = data:GetIconScale()
data.Type = "manual"
data.spawnType = "object"
data.objectData = object
data.Name = object.name
data.IsObjectiveNote = false
data.ManualTooltipData = {}
data.ManualTooltipData.Title = title or (object.name .. " (object)")
data.ManualTooltipData.Body = body or {
{ 'ID:', tostring(object.id) },
}
data.ManualTooltipData.disableShiftToRemove = disableShiftToRemove
for zone, spawns in pairs(object.spawns) do
if (zone ~= nil and spawns ~= nil) then
for _, coords in ipairs(spawns) do
local dungeonLocation = ZoneDB:GetDungeonLocation(zone)
if dungeonLocation ~= nil then
for _, value in ipairs(dungeonLocation) do
QuestieMap:DrawManualIcon(data, value[1], value[2], value[3], typ)
end
else
QuestieMap:DrawManualIcon(data, zone, coords[1], coords[2], typ)
end
end
end
end
end
function QuestieMap:DrawLineIcon(lineFrame, areaID, x, y)
if type(areaID) ~= "number" or type(x) ~= "number" or type(y) ~= "number" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawLineIcon] 'AreaID', 'x' and 'y' must be numbers:", areaID, x, y)
return nil, nil
end
local uiMapId = ZoneDB:GetUiMapIdByAreaId(areaID)
if not uiMapId then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap:DrawLineIcon] No UiMapID for areaId:", areaID)
return nil, nil
end
HBDPins:AddWorldMapIconMap(Questie, lineFrame, uiMapId, x, y, HBD_PINS_WORLDMAP_SHOW_CURRENT)
end
-- Draw manually added NPC/object notes
function QuestieMap:DrawManualIcon(data, areaID, x, y, typ)
if type(data) ~= "table" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawManualIcon] must have some data")
return nil, nil
end
if type(areaID) ~= "number" or type(x) ~= "number" or type(y) ~= "number" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawManualIcon] 'AreaID', 'x' and 'y' must be numbers:", areaID, x, y)
return nil, nil
end
if type(data.id) ~= "number" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawManualIcon] Data.id must be set to the NPC or object ID!")
return nil, nil
end
data.Id = data.id
-- Manual notes are placed by the Townsfolk / menu system. Tag them so the source
-- tooltip line can report it (only if a caller hasn't already set a more specific source).
if data.DataSource == nil then
data.DataSource = "Townsfolk"
end
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(areaID), x, y)
if (not uiMapId) then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawManualIcon] No UiMapID for areaId:", areaID, tostring(data.Name))
return nil, nil
end
local texture = data.Icon or "Interface\\WorldMap\\WorldMapPartyIcon"
typ = typ or "any"
if not QuestieMap.manualFrames[typ] then
QuestieMap.manualFrames[typ] = {}
end
if not QuestieMap.manualFrames[typ][data.id] then
QuestieMap.manualFrames[typ][data.id] = {}
end
local icon = QuestieFramePool:GetFrame()
icon.data = data
icon.x = x
icon.y = y
icon.AreaID = areaID
icon.UiMapID = uiMapId
icon.miniMapIcon = false;
icon.texture:SetTexture(texture)
icon:SetWidth(16 * (data:GetIconScale() or 0.7))
icon:SetHeight(16 * (data:GetIconScale() or 0.7))
QuestieMap:QueueDraw(QuestieMap.ICON_MAP_TYPE, Questie, icon, icon.UiMapID, x / 100, y / 100, 3)
tinsert(QuestieMap.manualFrames[typ][data.id], icon:GetName())
local iconMinimap = QuestieFramePool:GetFrame()
local colorsMinimap = { 1, 1, 1 }
if data.IconColor ~= nil and Questie.db.profile.questMinimapObjectiveColors then
colorsMinimap = data.IconColor
end
iconMinimap:SetWidth(16 * ((data:GetIconScale() or 1) * (Questie.db.profile.globalMiniMapScale or 0.7)))
iconMinimap:SetHeight(16 * ((data:GetIconScale() or 1) * (Questie.db.profile.globalMiniMapScale or 0.7)))
iconMinimap.data = data
iconMinimap.x = x
iconMinimap.y = y
iconMinimap.AreaID = areaID
iconMinimap.UiMapID = uiMapId
iconMinimap.texture:SetTexture(texture)
iconMinimap.texture:SetVertexColor(colorsMinimap[1], colorsMinimap[2], colorsMinimap[3], 1);
iconMinimap.miniMapIcon = true;
iconMinimap.minimapVisibilityCutoff = Questie.db.profile.minimapIconRangeCutoff or 100
if (not iconMinimap.FadeLogic) then
function iconMinimap:SetFade(value)
if self.lastGlowFade ~= value then
self.lastGlowFade = value
if self.glowTexture then
local r, g, b = self.glowTexture:GetVertexColor()
self.glowTexture:SetVertexColor(r, g, b, value)
end
self.texture:SetVertexColor(colorsMinimap[1], colorsMinimap[2], colorsMinimap[3], value)
end
end
function iconMinimap:FadeLogic()
local profile = Questie.db.profile
if self.miniMapIcon and self.x and self.y and self.texture and self.UiMapID and self.texture.SetVertexColor and HBD and HBD.GetPlayerZonePosition then
if (QuestieMap.playerX and QuestieMap.playerY) then
local x, y
if not self.worldX then
x, y = HBD:GetWorldCoordinatesFromZone(self.x / 100, self.y / 100, self.UiMapID)
self.worldX = x
self.worldY = y
else
x = self.worldX
y = self.worldY
end
if (x and y) then
-- Proactively hide any minimap icon that should be filtered out, even if it
-- is currently shown. The quest-type / map filters (showDungeonQuests,
-- enableMiniMapIcons, hideUntracked, hide-repeatable-below-60, etc.) can be
-- true when the icon was first drawn, or HBD's pin renderer may have shown it
-- on coming into range. Previously FadeLogic only re-checked ShouldBeHidden
-- when deciding whether to RE-SHOW an already-hidden icon, so a filtered icon
-- that was already visible (e.g. a dungeon quest) lingered on the minimap
-- while the world map correctly hid it. Hiding here keeps both maps in sync. (#11)
if _ShouldMinimapIconBeHidden(self) then
if not self.hidden then
self:FakeHide()
end
return
end
local xd = QuestieMap.playerX - x
local yd = QuestieMap.playerY - y
local distance = math.sqrt(xd * xd + yd * yd)
local minimapVisibilityCutoff = self.minimapVisibilityCutoff or profile.minimapIconRangeCutoff or 100
-- Only let the range cutoff hide icons that fall OUTSIDE the minimap's
-- visible radius. Icons within the visible minimap circle must always show,
-- otherwise a cutoff smaller than the current view radius (133-466 yd by
-- zoom) hides quest icons that are clearly on the minimap (#17). The cutoff
-- then only limits how far edge-floating icons reach.
local minimapRadius = (HBDPins and HBDPins.GetMinimapRadius and HBDPins:GetMinimapRadius()) or 0
local effectiveCutoff = minimapVisibilityCutoff
if minimapRadius and minimapRadius > effectiveCutoff then
effectiveCutoff = minimapRadius
end
if (distance > effectiveCutoff) then
self:FakeHide()
return
elseif self.hidden then
-- Only re-show icons that aren't filtered out (quest-type options
-- like showDungeonQuests, enableMiniMapIcons, hideUntracked, etc.).
-- Previously FadeLogic re-showed ANY FakeHidden minimap icon once in
-- range, so the world-map quest-type filters never applied to the
-- minimap. ShouldBeHidden mirrors the world-map draw-time check.
if not _ShouldMinimapIconBeHidden(self, true) then
self:FakeShow()
end
elseif (distance > profile.fadeLevel) then
self:SetFade(_GetMinimapDistanceFade(distance, profile))
elseif (distance < profile.fadeOverPlayerDistance) and profile.fadeOverPlayer then
local fadeAmount = profile.fadeOverPlayerLevel + distance * (1 - profile.fadeOverPlayerLevel) / profile.fadeOverPlayerDistance
if self.faded and fadeAmount > profile.iconFadeLevel then
fadeAmount = profile.iconFadeLevel
end
self:SetFade(fadeAmount)
else
if self.faded then
self:SetFade(profile.iconFadeLevel)
else
self:SetFade(1)
end
end
end
else
if self.faded then
self:SetFade(profile.iconFadeLevel)
else
self:SetFade(1)
end
end
end
end
end
QuestieMap:QueueDraw(QuestieMap.ICON_MINIMAP_TYPE, Questie, iconMinimap, iconMinimap.UiMapID, x / 100, y / 100, true, true);
tinsert(QuestieMap.manualFrames[typ][data.id], iconMinimap:GetName())
if (not Questie.db.profile.enabled) then
icon:FakeHide()
iconMinimap:FakeHide()
else
if (not Questie.db.profile.enableMapIcons) then
icon:FakeHide()
end
if (not Questie.db.profile.enableMiniMapIcons) then
iconMinimap:FakeHide()
end
end
if QuestieMap.utils and QuestieMap.utils.RescaleIcon then
QuestieMap.utils:RescaleIcon(icon)
end
return icon, iconMinimap;
end
--A layer to keep the area conversion away from the other parts of the code
--coordinates need to be 0-1 instead of 0-100
---@return IconFrame, IconFrame
function QuestieMap:DrawWorldIcon(data, areaID, x, y, showFlag)
if type(data) ~= "table" then
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieMap:DrawWorldIcon] must have some data")
return nil, nil
end
-- Tag data-source provenance for the source tooltip line. Objective pins set this
-- explicitly (per-spawn). Available/finisher pins are quest-scoped, so derive from the
-- quest. Leave nil for anything else so the tooltip shows no (potentially wrong) source.
if data.DataSource == nil and (data.Type == "available" or data.Type == "complete") then
data.DataSource = QuestieDB.GetPinDataSource("QUEST", data.Id)
end
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(areaID), x, y)
if (not uiMapId) then
local parentMapId
local mapInfo = C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(areaID)
if mapInfo then
parentMapId = mapInfo.parentMapID
else
parentMapId = ZoneDB:GetParentZoneId(areaID)
end
if (not parentMapId) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap:DrawWorldIcon] No UiMapID or fitting parentAreaId for areaId:", areaID, tostring(data.Name))
return nil, nil
else
areaID = parentMapId
uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(areaID), x, y)
end
end
if (not showFlag) then
showFlag = HBD_PINS_WORLDMAP_SHOW_WORLD
end
if not uiMapId then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap:DrawWorldIcon] No UiMapID or fitting uiMapId for areaId:", areaID, tostring(data.Name))
return nil, nil
end
local floatOnEdge = true
---@type IconFrame
local iconMap = QuestieFramePool:GetFrame()
iconMap.data = data
iconMap.x = x
iconMap.y = y
iconMap.AreaID = areaID
iconMap.UiMapID = uiMapId
iconMap.miniMapIcon = false;
iconMap:UpdateTexture(Questie.usedIcons[data.Icon]);
---@type IconFrame
local iconMinimap = QuestieFramePool:GetFrame()
iconMinimap.data = data
iconMinimap.x = x
iconMinimap.y = y
iconMinimap.AreaID = areaID
iconMinimap.UiMapID = uiMapId
iconMinimap.miniMapIcon = true;
iconMinimap.minimapVisibilityCutoff = Questie.db.profile.minimapIconRangeCutoff or 100
iconMinimap:UpdateTexture(Questie.usedIcons[data.Icon]);
if (not iconMinimap.FadeLogic) then
function iconMinimap:SetFade(value)
if self.lastGlowFade ~= value then
self.lastGlowFade = value
if self.glowTexture then
local r, g, b = self.glowTexture:GetVertexColor()
self.glowTexture:SetVertexColor(r, g, b, value)
end
self.texture:SetVertexColor(self.texture.r, self.texture.g, self.texture.b, value)
end
end
function iconMinimap:FadeLogic()
local profile = Questie.db.profile
if self.miniMapIcon and self.x and self.y and self.texture and self.UiMapID and self.texture.SetVertexColor and HBD and HBD.GetPlayerZonePosition then
if (QuestieMap.playerX and QuestieMap.playerY) then
local x, y
if not self.worldX then
x, y = HBD:GetWorldCoordinatesFromZone(self.x / 100, self.y / 100, self.UiMapID)
self.worldX = x
self.worldY = y
else
x = self.worldX
y = self.worldY
end
if (x and y) then
-- Proactively hide any filtered minimap icon even if currently shown,
-- keeping the minimap in sync with the world-map filters. (#11)
if _ShouldMinimapIconBeHidden(self) then
if not self.hidden then
self:FakeHide()
end
return
end
local xd = QuestieMap.playerX - x
local yd = QuestieMap.playerY - y
local distance = math.sqrt(xd * xd + yd * yd);
local minimapVisibilityCutoff = self.minimapVisibilityCutoff or profile.minimapIconRangeCutoff or 100;
-- Only clip icons OUTSIDE the minimap's visible radius, so a cutoff smaller
-- than the current view radius doesn't hide icons that are on the minimap (#17).
local minimapRadius = (HBDPins and HBDPins.GetMinimapRadius and HBDPins:GetMinimapRadius()) or 0
local effectiveCutoff = minimapVisibilityCutoff
if minimapRadius and minimapRadius > effectiveCutoff then
effectiveCutoff = minimapRadius
end
-- Hard stop: keep minimap pins from remaining visible beyond the cutoff.
if (distance > effectiveCutoff) then
self:FakeHide()
return
elseif self.hidden then
-- Only re-show icons that aren't filtered out (quest-type options
-- like showDungeonQuests, enableMiniMapIcons, hideUntracked, etc.).
-- Previously FadeLogic re-showed ANY FakeHidden minimap icon once in
-- range, so the world-map quest-type filters never applied to the
-- minimap. ShouldBeHidden mirrors the world-map draw-time check.
if not _ShouldMinimapIconBeHidden(self, true) then
self:FakeShow()
end
elseif (distance > profile.fadeLevel) then
self:SetFade(_GetMinimapDistanceFade(distance, profile))
elseif (distance < profile.fadeOverPlayerDistance) and profile.fadeOverPlayer then
local fadeAmount = profile.fadeOverPlayerLevel + distance * (1 - profile.fadeOverPlayerLevel) / profile.fadeOverPlayerDistance
if self.faded and fadeAmount > profile.iconFadeLevel then
fadeAmount = profile.iconFadeLevel
end
self:SetFade(fadeAmount)
else
if self.faded then
self:SetFade(profile.iconFadeLevel)
else
self:SetFade(1)
end
end
end
else
if self.faded then
self:SetFade(profile.iconFadeLevel)
else
self:SetFade(1)
end
end
end
end
-- We do not want to hook the OnUpdate again!
-- iconMinimap:SetScript("OnUpdate", )
end
QuestieMap:QueueDraw(QuestieMap.ICON_MINIMAP_TYPE, Questie, iconMinimap, uiMapId, x / 100, y / 100, true, floatOnEdge)
QuestieMap:QueueDraw(QuestieMap.ICON_MAP_TYPE, Questie, iconMap, uiMapId, x / 100, y / 100, showFlag)
local r, g, b = iconMinimap.texture:GetVertexColor()
if QuestieDBMIntegration.RegisterHudQuestIcon then
QuestieDBMIntegration:RegisterHudQuestIcon(tostring(iconMap), data.Icon, uiMapId, x, y, r, g, b)
end
if not QuestieMap.questIdFrames[data.Id] then
QuestieMap.questIdFrames[data.Id] = {}
end
QuestieMap.questIdFrames[data.Id][iconMap:GetName()] = iconMap:GetName()
QuestieMap.questIdFrames[data.Id][iconMinimap:GetName()] = iconMinimap:GetName()
if iconMap:ShouldBeHidden() then
iconMap:FakeHide()
end
if iconMinimap:ShouldBeHidden() then
iconMinimap:FakeHide()
end
return iconMap, iconMinimap;
end
--- The return type also contains, distance, zone and type but we never really use it.
---@type table<QuestId, {x:X, y:Y}>
local closestStarter = {}
function QuestieMap:FindClosestStarter()
local playerX, playerY, _ = HBD:GetPlayerWorldPosition();
local playerZone = QuestiePlayer:GetCurrentUiMapId();
for questId in pairs(QuestiePlayer.currentQuestlog) do
if (not closestStarter[questId]) then
local quest = QuestieDB.GetQuest(questId);
if quest then
closestStarter[questId] = {
distance = 999999,
x = -1,
y = -1,
zone = -1,
type = "",
}
for starterType, starters in pairs(quest.Starts) do
if (starterType == "GameObject") then
for _, ObjectID in ipairs(starters or {}) do
local obj = QuestieDB:GetObject(ObjectID)
if (obj ~= nil and obj.spawns ~= nil) then
for Zone, Spawns in pairs(obj.spawns) do
if (Zone ~= nil and Spawns ~= nil) then
for _, coords in ipairs(Spawns) do
if (coords[1] == -1 or coords[2] == -1) then
local dungeonLocation = ZoneDB:GetDungeonLocation(Zone)
if dungeonLocation ~= nil then
for _, value in ipairs(dungeonLocation) do
if (value[1] and value[2]) then
local x, y, _ = HBD:GetWorldCoordinatesFromZone(value[1] / 100, value[2] / 100, _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(value[3]), value[1], value[2]))
if (x and y) then
local distance = QuestieLib:Euclid(playerX or 0, playerY or 0, x, y);
if (closestStarter[questId].distance > distance) then
closestStarter[questId].distance = distance;
closestStarter[questId].x = x;
closestStarter[questId].y = y;
closestStarter[questId].zone = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(Zone), value[1], value[2])
closestStarter[questId].type = "GameObject - " .. obj.name;
end
end
end
end
end
else
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(Zone), coords[1], coords[2])
local x, y, _ = HBD:GetWorldCoordinatesFromZone(coords[1] / 100, coords[2] / 100, uiMapId)
if (x and y) then
local distance = QuestieLib:Euclid(playerX or 0, playerY or 0, x, y);
if (closestStarter[questId].distance > distance) then
closestStarter[questId].distance = distance;
closestStarter[questId].x = x;
closestStarter[questId].y = y;
closestStarter[questId].zone = uiMapId
closestStarter[questId].type = "GameObject - " .. obj.name;
end
end
end
end
end
end
end
end
elseif (starterType == "NPC") then
for _, NPCID in ipairs(starters or {}) do
local NPC = QuestieDB:GetNPC(NPCID)
if (NPC ~= nil and NPC.spawns ~= nil and NPC.friendly) then
for Zone, Spawns in pairs(NPC.spawns) do
if (Zone ~= nil and Spawns ~= nil) then
for _, coords in ipairs(Spawns) do
if (coords[1] == -1 or coords[2] == -1) then
local dungeonLocation = ZoneDB:GetDungeonLocation(Zone)
if dungeonLocation ~= nil then
for _, value in ipairs(dungeonLocation) do
if (value[1] and value[2]) then
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(value[3]), value[1], value[2])
local x, y, _ = HBD:GetWorldCoordinatesFromZone(value[1] / 100, value[2] / 100, uiMapId)
if (x and y) then
local distance = QuestieLib:Euclid(playerX or 0, playerY or 0, x, y);
if (closestStarter[questId].distance > distance) then
closestStarter[questId].distance = distance;
closestStarter[questId].x = x;
closestStarter[questId].y = y;
closestStarter[questId].zone = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(Zone), value[1], value[2])
closestStarter[questId].type = "NPC - " .. NPC.name;
end
end
end
end
end
elseif (coords[1] and coords[2]) then
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(Zone), coords[1], coords[2])
local x, y, _ = HBD:GetWorldCoordinatesFromZone(coords[1] / 100, coords[2] / 100, uiMapId)
if (x and y) then
local distance = QuestieLib:Euclid(playerX or 0, playerY or 0, x, y);
if (closestStarter[questId].distance > distance) then
closestStarter[questId].distance = distance;
closestStarter[questId].x = x;
closestStarter[questId].y = y;
closestStarter[questId].zone = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(Zone), coords[1], coords[2])
closestStarter[questId].type = "NPC - " .. NPC.name;
end
end
end
end
end
end
end
end
end
end
if (closestStarter[questId].x == -1) then
closestStarter[questId].distance = 0;
closestStarter[questId].x = playerX;
closestStarter[questId].y = playerY;
closestStarter[questId].zone = playerZone;
closestStarter[questId].type = "player";
end
end
end
end
return closestStarter;
end
function QuestieMap:GetNearestSpawn(objective)
if not objective then
return nil
end
local playerX, playerY, playerI = HBD:GetPlayerWorldPosition()
local bestDistance = 999999999
local bestSpawn, bestSpawnZone, bestSpawnId, bestSpawnType, bestSpawnName
if objective and objective.spawnList and next(objective.spawnList) then
for id, spawnData in pairs(objective.spawnList) do
for zone, spawns in pairs(spawnData.Spawns) do
for _, spawn in pairs(spawns) do
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(zone), spawn[1], spawn[2])
local dX, dY, dInstance = HBD:GetWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, uiMapId)
local dist = HBD:GetWorldDistance(dInstance, playerX, playerY, dX, dY)
if dist then
if dInstance ~= playerI then
dist = 500000 + dist * 100
end
if dist < bestDistance then
bestDistance = dist
bestSpawn = spawn
bestSpawnZone = zone
bestSpawnId = id
bestSpawnType = spawnData.Type
bestSpawnName = spawnData.Name
end
end
end
end
end
end
return bestSpawn, bestSpawnZone, bestSpawnName, bestSpawnId, bestSpawnType, bestDistance
end
---@param quest Quest
function QuestieMap:GetNearestQuestSpawn(quest)
if not quest then
return nil
end
if quest:IsComplete() == 1 then
local finisherSpawns
local finisherName
if quest.Finisher ~= nil then
if quest.Finisher.Type == "monster" then
finisherSpawns, finisherName = QuestieDB.QueryNPCSingle(quest.Finisher.Id, "spawns"), QuestieDB.QueryNPCSingle(quest.Finisher.Id, "name")
elseif quest.Finisher.Type == "object" then
finisherSpawns, finisherName = QuestieDB.QueryObjectSingle(quest.Finisher.Id, "spawns"), QuestieDB.QueryObjectSingle(quest.Finisher.Id, "name")
end
end
if finisherSpawns then
local bestDistance = 999999999
local playerX, playerY, playerI = HBD:GetPlayerWorldPosition()
local bestSpawn, bestSpawnZone, bestSpawnType, bestSpawnName
for zone, spawns in pairs(finisherSpawns) do
for _, spawn in pairs(spawns) do
local uiMapId = _ResolveMapUiMapId(ZoneDB:GetUiMapIdByAreaId(zone), spawn[1], spawn[2])
local dX, dY, dInstance = HBD:GetWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, uiMapId)
local dist = HBD:GetWorldDistance(dInstance, playerX, playerY, dX, dY)
if dist then
if dInstance ~= playerI then
dist = 500000 + dist * 100
end
if dist < bestDistance then
bestDistance = dist
bestSpawn = spawn
bestSpawnZone = zone
bestSpawnType = quest.Finisher.Type
bestSpawnName = finisherName
end
end
end
end
return bestSpawn, bestSpawnZone, bestSpawnName, bestSpawnType, bestDistance
end
return nil
end
local bestDistance = 999999999
local bestSpawn, bestSpawnZone, bestSpawnId, bestSpawnType, bestSpawnName
for _, objective in pairs(quest.Objectives) do
local spawn, zone, Name, id, Type, dist = QuestieMap:GetNearestSpawn(objective)
if spawn and dist < bestDistance and ((not objective.Needed) or objective.Needed ~= objective.Collected) then
bestDistance = dist
bestSpawn = spawn
bestSpawnZone = zone
bestSpawnId = id
bestSpawnType = Type
bestSpawnName = Name
end
end
for _, objective in pairs(quest.SpecialObjectives) do
local spawn, zone, Name, id, Type, dist = QuestieMap:GetNearestSpawn(objective)
if spawn and dist < bestDistance and ((not objective.Needed) or objective.Needed ~= objective.Collected) then
bestDistance = dist
bestSpawn = spawn
bestSpawnZone = zone
bestSpawnId = id
bestSpawnType = Type
bestSpawnName = Name
end
end
return bestSpawn, bestSpawnZone, bestSpawnName, bestSpawnId, bestSpawnType, bestDistance
end
QuestieMap.zoneWaypointColorOverrides = {
-- [14] = {0,0.1,0.9,0.7}, -- durotar
-- [38] = {0,0.1,0.9,0.7} -- loch modan
}
QuestieMap.zoneWaypointHoverColorOverrides = {
-- [14] = {0,0.6,1,1}, -- durotar
-- [38] = {0,0.6,1,1} -- loch modan
}
function QuestieMap:DrawWaypoints(icon, waypoints, zone, color)
if waypoints and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
local lineFrames = QuestieFramePool:CreateWaypoints(icon, waypoints, nil, color or QuestieMap.zoneWaypointColorOverrides[zone], zone)
for _, lineFrame in ipairs(lineFrames) do
QuestieMap:DrawLineIcon(lineFrame, zone, waypoints[1][1][1], waypoints[1][1][2])
end
end
end
return QuestieMap