Files

709 lines
23 KiB
Lua

-- Performance: cache frequently-used globals
local pairs = pairs
local floor, ceil, sqrt, abs = math.floor, math.ceil, math.sqrt, math.abs
local sin, cos, rad, pi = math.sin, math.cos, math.rad, math.pi
-- WoW's global atan2 returns degrees; fallback for clients without it
local atan2 = atan2 or function(x, y)
return math.deg(math.atan2(x, y))
end
local min, max = math.min, math.max
local getn, insert, sort = table.getn, table.insert, table.sort
local GetTime = GetTime
-- table.getn doesn't return sizes on tables that
-- are using a named index on which setn is not updated
local function tablesize(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
local function modulo(val, by)
return val - floor(val / by) * by
end
local function GetNearest(xstart, ystart, db, blacklist)
local nearest = nil
local best = nil
for id, data in pairs(db) do
if data[1] and data[2] and not blacklist[id] then
local x, y = xstart - data[1], ystart - data[2]
-- Use squared distance for comparison (avoid sqrt)
local distSq = x * x + y * y
local replace = not nearest or distSq < nearest
if distSq == nearest and best then
local previous = db[best]
-- Keep equal-distance choices stable even when db was rebuilt through
-- pairs(). Coordinates are sufficient; the index is a final tie-break.
replace = data[1] < previous[1]
or (data[1] == previous[1] and (data[2] < previous[2] or (data[2] == previous[2] and id < best)))
end
if replace then
nearest = distSq
best = id
end
end
end
if not best then
return
end
blacklist[best] = true
return db[best]
end
-- connection between objectives
local objectivepath = {}
-- connection between player and the first objective
local playerpath = {} -- worldmap
local mplayerpath = {} -- minimap
local function ClearPath(path)
for id, tex in pairs(path) do
tex.enable = nil
tex:Hide()
end
end
local function DrawLine(path, x, y, nx, ny, hl, minimap)
local display = true
local zoom = 1
-- calculate minimap variables
local xplayer, yplayer, xdraw, ydraw
if minimap then
-- player coords
xplayer, yplayer = GetPlayerMapPosition("player")
xplayer, yplayer = xplayer * 100, yplayer * 100
-- query minimap zoom/size data
local mZoom = pfMap.drawlayer:GetZoom()
local mapID = pfMap:GetMapIDByName(GetRealZoneText())
local mapZoom = pfMap.minimap_zoom[pfMap.minimap_indoor()][mZoom]
local mapWidth = pfMap.minimap_sizes[mapID] and pfMap.minimap_sizes[mapID][1] or 0
local mapHeight = pfMap.minimap_sizes[mapID] and pfMap.minimap_sizes[mapID][2] or 0
-- calculate drawlayer size
xdraw = pfMap.drawlayer:GetWidth() / (mapZoom / mapWidth) / 100
ydraw = pfMap.drawlayer:GetHeight() / (mapZoom / mapHeight) / 100
zoom = ((mapZoom / mapWidth) + (mapZoom / mapHeight)) * 3
end
-- general
local dx, dy = x - nx, y - ny
local dots = ceil(math.sqrt(dx * 1.5 * dx * 1.5 + dy * dy)) / zoom
for i = (minimap and 1 or 2), dots - (minimap and 1 or 2) do
local xpos = nx + dx / dots * i
local ypos = ny + dy / dots * i
if minimap then
-- adjust values to minimap
xpos = (xplayer - xpos) * xdraw
ypos = (yplayer - ypos) * ydraw
-- check if dot should be visible
if pfUI.minimap then
display = (abs(xpos) + 1 < pfMap.drawlayer:GetWidth() / 2 and abs(ypos) + 1 < pfMap.drawlayer:GetHeight() / 2)
and true
or nil
else
local distance = sqrt(xpos * xpos + ypos * ypos)
display = (distance + 1 < pfMap.drawlayer:GetWidth() / 2) and true or nil
end
else
-- adjust values to worldmap
xpos = xpos / 100 * WorldMapButton:GetWidth()
ypos = ypos / 100 * WorldMapButton:GetHeight()
end
if display then
local nline = tablesize(path) + 1
for id, tex in pairs(path) do
if not tex.enable then
nline = id
break
end
end
path[nline] = path[nline] or (minimap and pfMap.drawlayer or WorldMapButton.routes):CreateTexture(nil, "OVERLAY")
path[nline]:SetWidth(4)
path[nline]:SetHeight(4)
path[nline]:SetTexture(pfQuestConfig.path .. "\\img\\route")
if hl and minimap then
path[nline]:SetVertexColor(0.6, 0.4, 0.2, 0.5)
elseif hl then
path[nline]:SetVertexColor(1, 0.8, 0.4, 1)
else
path[nline]:SetVertexColor(0.6, 0.4, 0.2, 1)
end
path[nline]:ClearAllPoints()
if minimap then -- draw minimap
path[nline]:SetPoint("CENTER", pfMap.drawlayer, "CENTER", -xpos, ypos)
else -- draw worldmap
path[nline]:SetPoint("CENTER", WorldMapButton, "TOPLEFT", xpos, -ypos)
end
path[nline]:Show()
path[nline].enable = true
end
end
end
pfQuest.route = CreateFrame("Frame", "pfQuestRoute", WorldFrame)
pfQuest.route.firstnode = nil
pfQuest.route.coords = {}
-- Declared here (ahead of its later re-declaration below, which now just
-- reuses this upvalue) so Reset() can drop a stale automatic-target lock.
-- Without this, a target picked before a map/zone change could keep winning
-- the "stay put" tie-break in OnUpdate even after it stopped being the
-- nearest candidate, since Reset() only ever cleared self.coords/firstnode.
local automaticTargetKey = nil
pfQuest.route.Reset = function(self)
self.coords = {}
self.firstnode = nil
self.recalculate = true
self.lastDrawX = nil
self.lastDrawY = nil
self.lastDrawNode = nil
automaticTargetKey = nil
end
pfQuest.route.Clear = function(self)
self:Reset()
-- Raw objective candidates are kept outside coords so movement can select
-- the nearest spawn cheaply. A full clear must discard that cache as well,
-- otherwise OnUpdate immediately restores the route and re-shows the arrow.
self.rawObjectiveCandidates = nil
ClearPath(objectivepath)
ClearPath(playerpath)
ClearPath(mplayerpath)
if self.arrow then
self.arrow:Hide()
end
end
pfQuest.route.AddPoint = function(self, tbl)
table.insert(self.coords, tbl)
self.firstnode = nil
self.recalculate = true
end
-- A party-quest pin never gets a "cluster" pin (that only happens inside
-- pfDatabase:SearchQuestID, tied to the player's own qlogid), so it stays a
-- raw, un-clustered spawn candidate here instead of going through AddPoint.
-- Ported from pfQuest-HDB's route.lua, where the same mechanism handles its
-- closed-map item objectives.
pfQuest.route.SetRawObjectiveCandidates = function(self, candidates)
if candidates and next(candidates) then
self.rawObjectiveCandidates = candidates
return
end
if not self.rawObjectiveCandidates then return end
self.rawObjectiveCandidates = nil
for index = table.getn(self.coords), 1, -1 do
if self.coords[index][5] then table.remove(self.coords, index) end
end
self.firstnode = nil
self.recalculate = true
end
-- Update point distances on demand. The route's OnUpdate normally calls this
-- while the player moves, but consumers such as the tracker can request an
-- immediate, consistent snapshot when changing sort modes.
pfQuest.route.UpdateDistances = function(self)
local xplayer, yplayer = GetPlayerMapPosition("player")
if xplayer == 0 and yplayer == 0 then return nil end
for id, data in ipairs(self.coords) do
if data[1] and data[2] then
local x, y = (xplayer * 100 - data[1]) * 1.5, yplayer * 100 - data[2]
self.coords[id][4] = ceil(math.sqrt(x * x + y * y) * 100) / 100
end
end
return true
end
local targetTitle, targetCluster, targetLayer, targetTexture = nil, nil, nil, nil
pfQuest.route.SetTarget = function(node, default)
if
node
and (
node.title ~= targetTitle
or node.cluster ~= targetCluster
or node.layer ~= targetLayer
or node.texture ~= targetTexture
)
then
pfMap.queue_update = true
end
targetTitle = node and node.title or nil
targetCluster = node and node.cluster or nil
targetLayer = node and node.layer or nil
targetTexture = node and node.texture or nil
-- An explicit target owns the arrow until it is cleared. If that target is
-- not present in the current map's route points, do not silently fall back
-- to the nearest quest objective.
pfQuest.route.targetMissing = node and true or nil
pfQuest.route.recalculate = true
end
pfQuest.route.IsTarget = function(node)
if node then
if
targetTitle
and targetTitle == node.title
and targetCluster == node.cluster
and targetLayer == node.layer
and targetTexture == node.texture
then
return true
end
end
return nil
end
local lastpos, completed = 0, 0
local function TargetKey(data)
if not data then return nil end
local node = data[3]
return tostring(data[1]) .. ":" .. tostring(data[2]) .. ":" .. tostring(node and (node.title or node.spawn) or "")
end
local function sortfunc(a, b)
-- Distances are rounded to two decimals, so ties are common. UpdateNodes
-- rebuilds its list through pairs(), whose order is undefined; without a
-- deterministic tie-breaker the first route target can flip every refresh.
-- Some legacy map pins have no coordinate pair, so UpdateDistances leaves
-- their distance nil. Keep those incomplete points after usable targets.
local adistance = tonumber(a[4]) or math.huge
local bdistance = tonumber(b[4]) or math.huge
if adistance ~= bdistance then
return adistance < bdistance
end
local ax, bx = tonumber(a[1]) or math.huge, tonumber(b[1]) or math.huge
if ax ~= bx then
return ax < bx
end
local ay, by = tonumber(a[2]) or math.huge, tonumber(b[2]) or math.huge
if ay ~= by then
return ay < by
end
local an = a[3] and (a[3].title or a[3].spawn or "") or ""
local bn = b[3] and (b[3].title or b[3].spawn or "") or ""
return tostring(an) < tostring(bn)
end
pfQuest.route:SetScript("OnUpdate", function()
local xplayer, yplayer = GetPlayerMapPosition("player")
local wrongmap = xplayer == 0 and yplayer == 0 and true or nil
local curpos = xplayer + yplayer
-- limit distance and route updates to once per .1 seconds
if (this.tick or 5) > GetTime() and lastpos == curpos then
return
else
this.tick = GetTime() + 1
end
-- limit to a maxium of each .05 seconds even on position change
if (this.throttle or 0.2) > GetTime() then
return
else
this.throttle = GetTime() + 0.05
end
-- save current position
lastpos = curpos
-- update distances to player
this:UpdateDistances()
-- Keep a single raw party-objective route point, but choose it on every
-- movement tick, mirroring pfQuest-HDB's route.lua.
if not targetTitle and this.rawObjectiveCandidates then
local nearest, nearestDistance
for _, candidate in ipairs(this.rawObjectiveCandidates) do
local dx, dy = (xplayer * 100 - candidate[1]) * 1.5, yplayer * 100 - candidate[2]
candidate[4] = ceil(math.sqrt(dx * dx + dy * dy) * 100) / 100
if not nearestDistance or candidate[4] < nearestDistance then
nearest, nearestDistance = candidate, candidate[4]
end
end
if nearest and TargetKey(nearest) ~= automaticTargetKey then
for index = table.getn(this.coords), 1, -1 do
if this.coords[index][5] then table.remove(this.coords, index) end
end
table.insert(this.coords, nearest)
this.firstnode = nil
this.recalculate = true
-- Update the lock immediately: the "stay near current pick" stability
-- check below still reads the pre-swap key on this same tick otherwise.
automaticTargetKey = TargetKey(nearest)
end
end
-- Reorder only when the available route nodes or an explicit target change.
-- Re-sorting every second while the player moves causes route flicker and
-- expensive map redraws without improving the selected objective.
if this.recalculate then
table.sort(this.coords, sortfunc)
-- Map-position updates have slight jitter. Keep the current automatic
-- target while it remains close to the best candidate, otherwise two
-- nearby objectives can continuously trade places and redraw the route.
if not targetTitle and automaticTargetKey and this.coords[1] then
local previousIndex
for id, data in ipairs(this.coords) do
if TargetKey(data) == automaticTargetKey then
previousIndex = id
break
end
end
if previousIndex and previousIndex > 1 then
local previous = this.coords[previousIndex]
if previous[4] and this.coords[1][4] and previous[4] <= this.coords[1][4] + 0.5 then
table.remove(this.coords, previousIndex)
table.insert(this.coords, 1, previous)
end
end
end
-- order list on custom targets
if targetTitle and this.coords[1] and not pfQuest.route.IsTarget(this.coords[1][3]) then
local target = nil
-- check for the old index of the target
for id, data in ipairs(this.coords) do
if pfQuest.route.IsTarget(data[3]) then
target = id
break
end
end
-- rearrange coordinates
if target then
local tmp = {}
table.insert(tmp, this.coords[target])
for id, data in ipairs(this.coords) do
if id ~= target then
table.insert(tmp, this.coords[id])
end
end
this.coords = tmp
end
end
-- SetTarget is also used by waypoint addons. Keep an explicit selection
-- authoritative: while its node is unavailable on the current map, the
-- route and arrow stay hidden instead of switching to an unrelated quest.
this.targetMissing = targetTitle and
(not this.coords[1] or not pfQuest.route.IsTarget(this.coords[1][3])) or nil
automaticTargetKey = not targetTitle and TargetKey(this.coords[1]) or nil
this.recalculate = nil
end
-- show arrow when route exists and is stable
if
not wrongmap
and not this.targetMissing
and this.coords[1]
and this.coords[1][4]
and not this.arrow:IsShown()
and pfQuest_config["arrow"] == "1"
and GetTime() > completed + 1
then
this.arrow:Show()
end
-- abort without any nodes or distances
if this.targetMissing or not this.coords[1] or not this.coords[1][4] or pfQuest_config["routes"] == "0" then
ClearPath(objectivepath)
ClearPath(playerpath)
ClearPath(mplayerpath)
return
end
-- check first node for changes
if this.firstnode ~= tostring(this.coords[1][1] .. this.coords[1][2]) then
this.firstnode = tostring(this.coords[1][1] .. this.coords[1][2])
-- recalculate objective paths
local route = { [1] = this.coords[1] }
local blacklist = { [1] = true }
for i = 2, table.getn(this.coords) do
if route[i - 1] then -- make sure the route was not blacklisted
route[i] = GetNearest(route[i - 1][1], route[i - 1][2], this.coords, blacklist)
end
-- remove other item requirement gameobjects of same type from route
if route[i] and route[i][3] and route[i][3].itemreq then
for id, data in ipairs(this.coords) do
if
not blacklist[id]
and data[1]
and data[2]
and data[3]
and data[3].itemreq
and data[3].itemreq == route[i][3].itemreq
then
blacklist[id] = true
end
end
end
end
ClearPath(objectivepath)
for i, data in pairs(route) do
if i > 1 then
DrawLine(objectivepath, route[i - 1][1], route[i - 1][2], route[i][1], route[i][2])
end
end
-- route calculation timestamp
completed = GetTime()
end
if wrongmap then
-- hide player-to-object path
ClearPath(playerpath)
ClearPath(mplayerpath)
this.lastDrawX = nil
this.lastDrawY = nil
else
-- only redraw player-to-object path when position has changed enough to
-- produce a visible difference — DrawLine places one dot-texture per unit
-- of distance, so sub-threshold redraws are pure waste.
-- also invalidate when the target node changed (firstnode flip).
local px, py = xplayer * 100, yplayer * 100
local dx = (this.lastDrawX or px + 1) - px
local dy = (this.lastDrawY or py + 1) - py
local moved = dx * dx + dy * dy
local targetChanged = this.lastDrawNode ~= this.firstnode
if moved > 0.09 or targetChanged then -- threshold: 0.3 map units squared
this.lastDrawX = px
this.lastDrawY = py
this.lastDrawNode = this.firstnode
-- draw player-to-object path
ClearPath(playerpath)
ClearPath(mplayerpath)
DrawLine(playerpath, px, py, this.coords[1][1], this.coords[1][2], true)
-- also draw minimap path if enabled
if pfQuest_config["routeminimap"] == "1" then
DrawLine(mplayerpath, px, py, this.coords[1][1], this.coords[1][2], true, true)
end
end
end
end)
pfQuest.route.drawlayer = CreateFrame("Frame", "pfQuestRouteDrawLayer", WorldMapButton)
pfQuest.route.drawlayer:SetFrameLevel(113)
pfQuest.route.drawlayer:SetAllPoints()
WorldMapButton.routes = CreateFrame("Frame", "pfQuestRouteDisplay", pfQuest.route.drawlayer)
WorldMapButton.routes:SetAllPoints()
pfQuest.route.arrow = CreateFrame("Frame", "pfQuestRouteArrow", UIParent)
pfQuest.route.arrow:SetPoint("CENTER", 0, -100)
pfQuest.route.arrow:SetWidth(48)
pfQuest.route.arrow:SetHeight(36)
pfQuest.route.arrow:SetClampedToScreen(true)
pfQuest.route.arrow:SetMovable(true)
pfQuest.route.arrow:EnableMouse(true)
pfQuest.route.arrow:RegisterForDrag("LeftButton")
pfQuest.route.arrow:SetScript("OnDragStart", function()
if IsShiftKeyDown() then
this:StartMoving()
end
end)
pfQuest.route.arrow:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
end)
local invalid, lasttarget
local xplayer, yplayer, wrongmap
local xDelta, yDelta, dir, angle
local player, perc, column, row, xstart, ystart, xend, yend
local area, alpha, texalpha, color
local defcolor = "|cffffcc00"
local r, g, b
pfQuest.route.arrow:SetScript("OnUpdate", function()
-- abort if the frame is not initialized yet
if not this.parent then
return
end
-- A disabled arrow must stop before any position, texture, or model work.
-- This also makes configuration changes take effect immediately instead of
-- waiting for the invalid-route debounce below.
if pfQuest_config["arrow"] ~= "1" then
invalid = nil
this:Hide()
return
end
-- Route coordinates update at 20 Hz. Updating the visual arrow more often
-- only repeats the same calculations and creates garbage every render frame.
if (this.tick or 0) > GetTime() then
return
end
this.tick = GetTime() + 0.05
xplayer, yplayer = GetPlayerMapPosition("player")
wrongmap = xplayer == 0 and yplayer == 0 and true or nil
target = not this.parent.targetMissing and this.parent.coords and this.parent.coords[1]
and this.parent.coords[1][4] and this.parent.coords[1] or nil
-- disable arrow on invalid map/route
if not target or wrongmap then
if invalid and invalid < GetTime() then
this:Hide()
elseif not invalid then
invalid = GetTime() + 1
end
return
else
invalid = nil
end
-- arrow positioning stolen from TomTomVanilla.
-- all credits to the original authors:
-- https://github.com/cralor/TomTomVanilla
xDelta = (target[1] - xplayer * 100) * 1.5
yDelta = (target[2] - yplayer * 100)
dir = atan2(xDelta, -yDelta)
dir = dir > 0 and (math.pi * 2) - dir or -dir
if dir < 0 then
dir = dir + 360
end
angle = math.rad(dir)
player = pfQuestCompat.GetPlayerFacing()
angle = angle - player
perc = math.abs(((math.pi - math.abs(angle)) / math.pi))
r, g, b = pfUI.api.GetColorGradient(floor(perc * 100) / 100)
cell = modulo(floor(angle / (math.pi * 2) * 108 + 0.5), 108)
column = modulo(cell, 9)
row = floor(cell / 9)
xstart = (column * 56) / 512
ystart = (row * 42) / 512
xend = ((column + 1) * 56) / 512
yend = ((row + 1) * 42) / 512
-- guess area based on node count
area = target[3].priority and target[3].priority or 1
area = max(1, area)
area = min(20, area)
area = (area / 10) + 1
alpha = target[4] - area
alpha = alpha > 1 and 1 or alpha
alpha = alpha < 0.5 and 0.5 or alpha
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
-- update arrow
this.model:SetTexCoord(xstart, xend, ystart, yend)
this.model:SetVertexColor(r, g, b)
-- recalculate values on target change
if target ~= lasttarget then
-- calculate difficulty color
color = defcolor
if tonumber(target[3]["qlvl"]) then
color = pfMap:HexDifficultyColor(tonumber(target[3]["qlvl"]))
end
-- update node texture
if target[3].texture then
this.texture:SetTexture(target[3].texture)
if target[3].vertex and (target[3].vertex[1] > 0 or target[3].vertex[2] > 0 or target[3].vertex[3] > 0) then
this.texture:SetVertexColor(unpack(target[3].vertex))
else
this.texture:SetVertexColor(1, 1, 1, 1)
end
else
this.texture:SetTexture(pfQuestConfig.path .. "\\img\\node")
this.texture:SetVertexColor(pfMap.str2rgb(target[3].title))
end
-- update arrow texts
local level = target[3].qlvl and "[" .. target[3].qlvl .. "] " or ""
this.title:SetText(color .. level .. target[3].title .. "|r")
local desc = target[3].description or ""
if not pfUI or not pfUI.uf then
this.description:SetTextColor(1, 0.9, 0.7, 1)
desc = string.gsub(desc, "ff33ffcc", "ffffffff")
end
this.description:SetText(desc .. "|r.")
end
-- only refresh distance text on change
local distance = floor(target[4] * 10) / 10
if distance ~= this.distance.number then
this.distance:SetText("|cffaaaaaa" .. pfQuest_Loc["Distance"] .. ": " .. string.format("%.1f", distance))
this.distance.number = distance
end
-- update transparencies
this.texture:SetAlpha(texalpha)
this.model:SetAlpha(alpha)
end)
pfQuest.route.arrow.texture = pfQuest.route.arrow:CreateTexture("pfQuestRouteNodeTexture", "OVERLAY")
pfQuest.route.arrow.texture:SetWidth(28)
pfQuest.route.arrow.texture:SetHeight(28)
pfQuest.route.arrow.texture:SetPoint("BOTTOM", 0, 0)
pfQuest.route.arrow.model = pfQuest.route.arrow:CreateTexture("pfQuestRouteArrow", "MEDIUM")
pfQuest.route.arrow.model:SetTexture(pfQuestConfig.path .. "\\img\\arrow")
pfQuest.route.arrow.model:SetTexCoord(0, 0, 0.109375, 0.08203125)
pfQuest.route.arrow.model:SetAllPoints()
pfQuest.route.arrow.title = pfQuest.route.arrow:CreateFontString("pfQuestRouteText", "HIGH", "GameFontWhite")
pfQuest.route.arrow.title:SetPoint("TOP", pfQuest.route.arrow.model, "BOTTOM", 0, -10)
pfQuest.route.arrow.title:SetFont(pfUI.font_default, pfUI_config.global.font_size + 1, "OUTLINE")
pfQuest.route.arrow.title:SetTextColor(1, 0.8, 0)
pfQuest.route.arrow.title:SetJustifyH("CENTER")
pfQuest.route.arrow.description = pfQuest.route.arrow:CreateFontString("pfQuestRouteText", "HIGH", "GameFontWhite")
pfQuest.route.arrow.description:SetPoint("TOP", pfQuest.route.arrow.title, "BOTTOM", 0, -2)
pfQuest.route.arrow.description:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
pfQuest.route.arrow.description:SetTextColor(1, 1, 1)
pfQuest.route.arrow.description:SetJustifyH("CENTER")
pfQuest.route.arrow.distance = pfQuest.route.arrow:CreateFontString("pfQuestRouteDistance", "HIGH", "GameFontWhite")
pfQuest.route.arrow.distance:SetPoint("TOP", pfQuest.route.arrow.description, "BOTTOM", 0, -2)
pfQuest.route.arrow.distance:SetFont(pfUI.font_default, pfUI_config.global.font_size - 1, "OUTLINE")
pfQuest.route.arrow.distance:SetTextColor(0.8, 0.8, 0.8)
pfQuest.route.arrow.distance:SetJustifyH("CENTER")
pfQuest.route.arrow.parent = pfQuest.route