Files
Questie-X/Modules/Arrow/QuestieArrow.lua
T
Xurkon 7aaf4d3495 Fix Arrow failing to detect quest completion fallback
When QuestLogCache parses incomplete tracker data upon reload,
it leaves quest.isComplete undefined or false. This allows the
Arrow to process SpecialObjectives (like required source item drops)
which do not have Completed/Needed/Collected stats on them, bypassing
our recent arrow logic fix.

Added an explicit check for QuestieDB.IsComplete(quest.Id) == 1
at the very top of the track loop to guarantee the Arrow immediately
stops processing objectives and exclusively targets the finisher.
2026-02-21 12:32:36 -06:00

803 lines
30 KiB
Lua

---@class QuestieArrow
local QuestieArrow = QuestieLoader:CreateModule("QuestieArrow")
---@type ZoneDB
local ZoneDB = QuestieLoader:ImportModule("ZoneDB")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
---@type QuestieMap
local QuestieMap = QuestieLoader:ImportModule("QuestieMap")
---@type QuestieTracker
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer")
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
local HBD = QuestieCompat.HBD or LibStub("HereBeDragonsQuestie-2.0")
local atan2 = math.atan2
local pi = math.pi
local floor = math.floor
local abs = math.abs
local max = math.max
local min = math.min
local ARROW_SHEET_SIZE = 512
local ARROW_CELL_W = 56
local ARROW_CELL_H = 42
local ARROW_SHEET_COLS = 9
local ARROW_SHEET_ROWS = 12
local ARROW_TOTAL_CELLS = ARROW_SHEET_COLS * ARROW_SHEET_ROWS
local UPDATE_THROTTLE_SECONDS = 0.05
local RECALC_NEAREST_SECONDS = 1.0
local TRACKER_REFRESH_THROTTLE_SECONDS = 0.5
---@type Frame?
local arrowFrame = nil
---@type Frame?
local driverFrame = nil
-- Current auto-tracked targets sorted by distance
local sortedTargets = {}
local hasManualTarget = false
local lastPopulateByQuestId = {}
local function _IsArrowEnabled()
if not Questie or not Questie.db or not Questie.db.profile then
return true
end
return Questie.db.profile.arrowEnabled ~= false
end
local function _GetArrowScale()
if not Questie or not Questie.db or not Questie.db.profile then
return 1
end
return Questie.db.profile.arrowScale or 1
end
local function _SetArrowScale(scale)
if not Questie or not Questie.db or not Questie.db.profile then
return
end
Questie.db.profile.arrowScale = scale
end
local function _GetProfilePosition()
if not Questie or not Questie.db or not Questie.db.profile then
return nil
end
return Questie.db.profile.arrowPosition
end
local function _SaveProfilePosition(point, relativePoint, x, y)
if not Questie or not Questie.db or not Questie.db.profile then
return
end
Questie.db.profile.arrowPosition = {
point = point,
relativePoint = relativePoint,
x = x,
y = y,
}
end
local function modulo(val, by)
return val - floor(val / by) * by
end
local function GetColorGradient(perc)
if perc <= 0.5 then
return 1, perc * 2, 0
else
return 2 - perc * 2, 1, 0
end
end
local function ResolveIconTexture(icon)
if not icon then
return nil
end
if type(icon) == "string" then
return icon
end
if type(icon) == "number" then
if Questie and Questie.usedIcons then
return Questie.usedIcons[icon]
end
return nil
end
return nil
end
local function _ApplyOutline(fontString)
if not fontString or not fontString.GetFont or not fontString.SetFont then
return
end
local font, size, flags = fontString:GetFont()
if not font then
return
end
flags = flags or ""
if not string.find(flags, "OUTLINE", 1, true) then
if flags ~= "" then
flags = flags .. ",OUTLINE"
else
flags = "OUTLINE"
end
end
fontString:SetFont(font, size, flags)
end
local function EnsureArrowFrame()
if arrowFrame then
return
end
arrowFrame = CreateFrame("Frame", "QuestieArrowFrame", UIParent)
local pos = _GetProfilePosition()
if pos and pos.point and pos.relativePoint and pos.x and pos.y then
arrowFrame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.x, pos.y)
else
arrowFrame:SetPoint("CENTER", 0, -100)
end
-- Make room for the objective icon below the arrow (no overlap)
arrowFrame:SetWidth(56)
arrowFrame:SetHeight(64)
arrowFrame:SetScale(_GetArrowScale())
arrowFrame:SetClampedToScreen(true)
arrowFrame:SetMovable(true)
arrowFrame:EnableMouse(true)
arrowFrame:EnableMouseWheel(true)
arrowFrame:RegisterForDrag("LeftButton")
arrowFrame:SetScript("OnDragStart", function(self)
if IsShiftKeyDown() then
self:StartMoving()
end
end)
arrowFrame:SetScript("OnDragStop", function(self)
self:StopMovingOrSizing()
local point, _, relativePoint, x, y = self:GetPoint(1)
if point and relativePoint and x and y then
_SaveProfilePosition(point, relativePoint, x, y)
end
end)
arrowFrame:SetScript("OnMouseWheel", function(self, delta)
if not IsShiftKeyDown() then
return
end
local scale = _GetArrowScale() or 1
local step = 0.05
if delta and delta > 0 then
scale = scale + step
else
scale = scale - step
end
if scale < 0.5 then scale = 0.5 end
if scale > 2.0 then scale = 2.0 end
_SetArrowScale(scale)
self:SetScale(scale)
end)
-- Arrow sprite sheet texture (108 cells: 9 columns, 12 rows)
arrowFrame.arrow = arrowFrame:CreateTexture(nil, "MEDIUM")
arrowFrame.arrow:SetTexture(QuestieLib.AddonPath .. "Icons\\arrow.tga")
-- Render at native cell size; use frame scaling if you want it larger.
arrowFrame.arrow:SetWidth(ARROW_CELL_W)
arrowFrame.arrow:SetHeight(ARROW_CELL_H)
arrowFrame.arrow:SetPoint("TOP", arrowFrame, "TOP", 0, 0)
arrowFrame.arrow:SetTexCoord(0, 0.109375, 0, 0.08203125) -- First cell
-- Quest icon texture at bottom (pfQuest style)
arrowFrame.icon = arrowFrame:CreateTexture(nil, "OVERLAY")
arrowFrame.icon:SetWidth(28)
arrowFrame.icon:SetHeight(28)
arrowFrame.icon:SetPoint("BOTTOM", arrowFrame.arrow, "BOTTOM", 0, -20)
arrowFrame.title = arrowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
arrowFrame.title:SetPoint("TOP", arrowFrame.icon, "BOTTOM", 0, -2)
arrowFrame.title:SetJustifyH("CENTER")
_ApplyOutline(arrowFrame.title)
arrowFrame.distance = arrowFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
arrowFrame.distance:SetPoint("TOP", arrowFrame.title, "BOTTOM", 0, -2)
arrowFrame.distance:SetJustifyH("CENTER")
arrowFrame.distance:SetTextColor(1, 1, 1, 1)
_ApplyOutline(arrowFrame.distance)
arrowFrame._lastUpdate = 0
arrowFrame._lastRecalc = 0
arrowFrame._lastTarget = nil
-- Right-click to clear manual target and resume auto-tracking
arrowFrame:EnableMouse(true)
arrowFrame:SetScript("OnMouseUp", function(self, button)
if button == "RightButton" then
hasManualTarget = false
sortedTargets = {}
QuestieArrow:Refresh()
end
end)
arrowFrame:SetScript("OnUpdate", function(self)
local now = GetTime()
local target = sortedTargets[1]
if not target then
self:Hide()
return
end
if not self:IsShown() then
self:Show()
end
if (self._lastUpdate or 0) + UPDATE_THROTTLE_SECONDS > now then
return
end
self._lastUpdate = now
local playerX, playerY, playerInstance = HBD:GetPlayerWorldPosition()
if not playerX or not playerY or not playerInstance then
self.distance:SetText("Distance: --")
return
end
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0,
target.uiMapId)
if not targetX or not targetY or not targetInstance then
self.distance:SetText("Distance: --")
return
end
if targetInstance ~= playerInstance then
self:Hide()
return
end
-- Calculate arrow direction using pfQuest's method, but in world coordinates
-- (map coordinates break when the target is in a different zone)
local xDelta = (playerX - targetX) * 1.5
local yDelta = (playerY - targetY)
local angle = atan2(xDelta, -(yDelta))
angle = angle > 0 and (pi * 2) - angle or -angle
if angle < 0 then angle = angle + (pi * 2) end
local player = GetPlayerFacing and GetPlayerFacing() or 0
angle = angle - player
-- Calculate color gradient based on direction
local perc = abs(((pi - abs(angle)) / pi))
local r, g, b = GetColorGradient(perc)
-- Select sprite sheet cell
local cell = modulo(floor(angle / (pi * 2) * ARROW_TOTAL_CELLS + 0.5), ARROW_TOTAL_CELLS)
local column = modulo(cell, ARROW_SHEET_COLS)
local row = floor(cell / ARROW_SHEET_COLS)
local xstart = (column * ARROW_CELL_W) / ARROW_SHEET_SIZE
local ystart = (row * ARROW_CELL_H) / ARROW_SHEET_SIZE
local xend = ((column + 1) * ARROW_CELL_W) / ARROW_SHEET_SIZE
local yend = ((row + 1) * ARROW_CELL_H) / ARROW_SHEET_SIZE
-- Avoid bleeding from neighboring cells when texture filtering is enabled.
local padX = 0.5 / ARROW_SHEET_SIZE
local padY = 0.5 / ARROW_SHEET_SIZE
xstart = xstart + padX
ystart = ystart + padY
xend = xend - padX
yend = yend - padY
-- Calculate distance and alpha
local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY, targetX, targetY)
if dist then
local area = 1
local alpha = dist - area
alpha = alpha > 1 and 1 or alpha
alpha = alpha < 0.5 and 0.5 or alpha
local texalpha = (1 - alpha) * 2
texalpha = texalpha > 1 and 1 or texalpha
texalpha = texalpha < 0 and 0 or texalpha
r, g, b = r + texalpha, g + texalpha, b + texalpha
self.arrow:SetTexCoord(xstart, xend, ystart, yend)
self.arrow:SetVertexColor(r, g, b)
self.arrow:SetAlpha(alpha)
local distText = string.format("%.1f", dist)
self.distance:SetText("Distance: " .. distText)
end
-- Update title and icon when target changes
if target ~= self._lastTarget then
self._lastTarget = target
local title = target.title or ""
if target.questLevel then
title = "[" .. target.questLevel .. "] " .. title
end
self.title:SetText(Questie:Colorize(title, "gold"))
if target.iconPath then
self.icon:SetTexture(target.iconPath)
self.icon:Show()
else
self.icon:Hide()
end
end
end)
arrowFrame:Hide()
end
local function EnsureDriverFrame()
if driverFrame then
return
end
driverFrame = CreateFrame("Frame", "QuestieArrowDriverFrame", UIParent)
driverFrame:Show()
driverFrame._lastRecalc = 0
driverFrame:SetScript("OnUpdate", function(self)
local now = GetTime()
if (self._lastRecalc or 0) + RECALC_NEAREST_SECONDS < now then
self._lastRecalc = now
if not _IsArrowEnabled() then
if arrowFrame then
arrowFrame:Hide()
end
return
end
QuestieArrow:Refresh()
end
end)
end
-- Gather all objectives from tracked quests and sort by distance
function QuestieArrow:UpdateNearestTargets()
-- Don't override manual targets with auto-updates
if hasManualTarget then
return
end
sortedTargets = {}
if not Questie.db or not Questie.db.char then
return
end
local playerX, playerY, playerInstance = HBD:GetPlayerWorldPosition()
if not playerX or not playerY or not playerInstance then
return
end
local tracked = Questie.db.char.TrackedQuests or {}
local hasTracked = next(tracked) ~= nil
-- Auto mode logic: If autoTrack is on OR NOTHING is tracked
local usingAutoLogic = Questie.db.profile.autoTrackQuests or not hasTracked
local playerZoneId = QuestiePlayer:GetCurrentZoneId()
local function _CollectQuestTargets(quest)
if not quest then
return
end
-- Avoid spamming QuestieQuest:PopulateQuestLogInfo (it can trigger marker rebuilds and flicker).
-- Only populate when objective completion flags are missing, and throttle per quest id.
if QuestieQuest and QuestieQuest.PopulateQuestLogInfo and quest.Id then
local needsPopulate = false
if not quest.Objectives and not quest.SpecialObjectives then
needsPopulate = true
else
local function _HasMissingCompletedFlag(list)
if not list then return false end
for _, obj in pairs(list) do
if obj and obj.Completed == nil then
return true
end
end
return false
end
if _HasMissingCompletedFlag(quest.Objectives) or _HasMissingCompletedFlag(quest.SpecialObjectives) then
needsPopulate = true
end
end
if needsPopulate then
local now = GetTime()
local last = lastPopulateByQuestId[quest.Id] or 0
if (last + 2.0) < now then
lastPopulateByQuestId[quest.Id] = now
QuestieQuest:PopulateQuestLogInfo(quest)
end
end
end
-- If the quest is complete, track the finisher/turn-in location
if quest.isComplete or QuestieDB.IsComplete(quest.Id) == 1 then
quest.isComplete = true
local function _GetCompleteIconType()
local iconType = Questie.ICON_TYPE_COMPLETE
if QuestieDB and QuestieDB.IsActiveEventQuest and QuestieDB.IsActiveEventQuest(quest.Id) then
iconType = Questie.ICON_TYPE_EVENTQUEST_COMPLETE
elseif QuestieDB and QuestieDB.IsPvPQuest and QuestieDB.IsPvPQuest(quest.Id) then
iconType = Questie.ICON_TYPE_PVPQUEST_COMPLETE
elseif quest.IsRepeatable then
iconType = Questie.ICON_TYPE_REPEATABLE_COMPLETE
end
return iconType
end
local function _CollectFinisherSpawns(finisher)
if not finisher then
return
end
local iconPath = ResolveIconTexture(_GetCompleteIconType())
for finisherZone, spawns in pairs(finisher.spawns or {}) do
if finisherZone and spawns then
for _, coords in ipairs(spawns) do
if coords and coords[1] and coords[2] then
if coords[1] == -1 or coords[2] == -1 then
local dungeonLocation = ZoneDB:GetDungeonLocation(finisherZone)
if dungeonLocation then
for _, value in ipairs(dungeonLocation) do
local zone = value[1]
local x = value[2]
local y = value[3]
-- Auto Logic: Hide distant quests (different zone)
if usingAutoLogic and zone ~= playerZoneId then
-- continue
else
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then
local targetX, targetY, targetInstance = HBD
:GetWorldCoordinatesFromZone(
x / 100.0, y / 100.0, uiMapId)
if targetX and targetY and targetInstance then
local dist = HBD:GetWorldDistance(targetInstance, playerX,
playerY,
targetX, targetY)
if dist then
if targetInstance ~= playerInstance then
dist = 500000 + dist * 100
end
table.insert(sortedTargets, {
x = x,
y = y,
uiMapId = uiMapId,
title = quest.name,
questLevel = quest.level,
iconPath = iconPath,
distance = dist,
})
end
end
end
end
end
end
else
-- Auto Logic: Hide distant quests (different zone)
if usingAutoLogic and finisherZone ~= playerZoneId then
-- continue
else
local x = coords[1]
local y = coords[2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone)
if uiMapId then
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(
x / 100.0, y / 100.0, uiMapId)
if targetX and targetY and targetInstance then
local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY,
targetX,
targetY)
if dist then
if targetInstance ~= playerInstance then
dist = 500000 + dist * 100
end
table.insert(sortedTargets, {
x = x,
y = y,
uiMapId = uiMapId,
title = quest.name,
questLevel = quest.level,
iconPath = iconPath,
distance = dist,
})
end
end
end
end
end
end
end
end
if finisher.waypoints then
for zone, waypoints in pairs(finisher.waypoints) do
-- Auto Logic: Hide distant quests (different zone)
if usingAutoLogic and zone ~= playerZoneId then
-- continue
elseif waypoints and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
local x = waypoints[1][1][1]
local y = waypoints[1][1][2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(x / 100.0,
y / 100.0, uiMapId)
if targetX and targetY and targetInstance then
local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY, targetX,
targetY)
if dist then
if targetInstance ~= playerInstance then
dist = 500000 + dist * 100
end
table.insert(sortedTargets, {
x = x,
y = y,
uiMapId = uiMapId,
title = quest.name,
questLevel = quest.level,
iconPath = iconPath,
distance = dist,
})
end
end
end
end
end
end
end
if quest.Finisher and quest.Finisher.Id and quest.Finisher.Type then
local finisher
if quest.Finisher.Type == "monster" and QuestieDB and QuestieDB.GetNPC then
finisher = QuestieDB:GetNPC(quest.Finisher.Id)
elseif quest.Finisher.Type == "object" and QuestieDB and QuestieDB.GetObject then
finisher = QuestieDB:GetObject(quest.Finisher.Id)
end
_CollectFinisherSpawns(finisher)
end
return
end
end
local function _CollectObjective(objective)
if not objective or not objective.spawnList then
return
end
if objective.Completed == true or objective.Completed == 1 then
return
end
-- If the objective is numerically fulfilled but the server hasn't sent the Completed flag yet
if objective.Needed and objective.Collected and type(objective.Needed) == "number" and type(objective.Collected) == "number" then
if objective.Collected >= objective.Needed then
return
end
end
for _, spawnData in pairs(objective.spawnList) do
if spawnData and spawnData.Spawns then
for zone, spawns in pairs(spawnData.Spawns) do
-- Auto Logic: Hide distant quests (different zone)
if usingAutoLogic and zone ~= playerZoneId then
-- continue
else
for _, spawn in pairs(spawns) do
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId then
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(
spawn[1] / 100.0, spawn[2] / 100.0, uiMapId)
if targetX and targetY and targetInstance then
local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY, targetX,
targetY)
if dist then
if targetInstance ~= playerInstance then
dist = 500000 + dist * 100
end
table.insert(sortedTargets, {
x = spawn[1],
y = spawn[2],
uiMapId = uiMapId,
title = quest.name,
questLevel = quest.level,
iconPath = ResolveIconTexture(objective.Icon) or
ResolveIconTexture(spawnData and spawnData.Icon),
distance = dist,
})
end
end
end
end
end
end
end
end
end
if quest.Objectives then
for _, objective in pairs(quest.Objectives) do
_CollectObjective(objective)
end
end
if quest.SpecialObjectives then
for _, objective in pairs(quest.SpecialObjectives) do
_CollectObjective(objective)
end
end
end
if QuestiePlayer and QuestiePlayer.currentQuestlog then
for questId, quest in pairs(QuestiePlayer.currentQuestlog) do
if type(quest) == "table" then
local shouldTrack = false
if usingAutoLogic then
-- If using auto logic (implicit or explicit), verify not explicitly hidden
if not Questie.db.char.AutoUntrackedQuests[questId] then
shouldTrack = true
end
else
-- Strict manual tracking
if Questie.db.char.TrackedQuests[questId] then
shouldTrack = true
end
end
if shouldTrack then
_CollectQuestTargets(quest)
end
end
end
end
-- Sort by distance
table.sort(sortedTargets, function(a, b) return a.distance < b.distance end)
end
function QuestieArrow:Refresh()
if not _IsArrowEnabled() then
if arrowFrame then
arrowFrame:Hide()
end
return
end
if hasManualTarget then
EnsureArrowFrame()
arrowFrame:Show()
return
end
QuestieArrow:UpdateNearestTargets()
EnsureArrowFrame()
if sortedTargets[1] then
arrowFrame:Show()
else
arrowFrame:Hide()
end
end
-- Manual target setting (called from tracker TomTom bind)
---@param title string
---@param zoneOrUiMapId number
---@param x number
---@param y number
function QuestieArrow:SetTarget(title, zoneOrUiMapId, x, y)
if not _IsArrowEnabled() then
return
end
-- For manual targets, insert at front of sorted list
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zoneOrUiMapId) or zoneOrUiMapId
hasManualTarget = true
sortedTargets = { {
x = x,
y = y,
uiMapId = uiMapId,
title = title,
distance = 0, -- Manual targets always go first
} }
EnsureArrowFrame()
arrowFrame:Show()
end
function QuestieArrow:ClearTarget()
hasManualTarget = false
sortedTargets = {}
if arrowFrame then
arrowFrame:Hide()
end
end
function QuestieArrow:Initialize()
EnsureArrowFrame()
EnsureDriverFrame()
-- Refresh immediately on tracker updates (quest progress, objective completion, etc.)
if QuestieTracker and QuestieTracker.Update and hooksecurefunc then
local lastTrackerRefresh = 0
hooksecurefunc(QuestieTracker, "Update", function()
if hasManualTarget or not _IsArrowEnabled() then
return
end
local now = GetTime()
if (lastTrackerRefresh + TRACKER_REFRESH_THROTTLE_SECONDS) > now then
return
end
lastTrackerRefresh = now
QuestieArrow:Refresh()
end)
end
QuestieArrow:Refresh()
end
-- Debug function to show current arrow target coordinates
function QuestieArrow:PrintTargetCoords()
if not sortedTargets or not sortedTargets[1] then
print("Questie Arrow: No target currently set!")
return
end
local target = sortedTargets[1]
print("Questie Arrow Target:")
print(" Quest: " .. tostring(target.title))
print(" Level: " .. tostring(target.questLevel))
print(" Zone Coords: " .. string.format("%.1f, %.1f", target.x, target.y))
print(" UI Map ID: " .. tostring(target.uiMapId))
print(" Distance: " .. string.format("%.0f", target.distance))
end
-- Also expose sortedTargets for external access
function QuestieArrow:GetTargets()
return sortedTargets
end
-- Hook into Refresh to show debug info when arrow updates
local _OriginalRefresh = QuestieArrow.Refresh
QuestieArrow.Refresh = function(self, ...)
_OriginalRefresh(self, ...)
-- You can uncomment the line below to see debug output every refresh:
-- QuestieArrow:PrintTargetCoords()
end