forked from EllesmereGaming/EllesmereUI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEllesmereUI_Range.lua
More file actions
325 lines (307 loc) · 14.2 KB
/
Copy pathEllesmereUI_Range.lua
File metadata and controls
325 lines (307 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
-------------------------------------------------------------------------------
-- EllesmereUI_Range.lua
-- Shared range-check engine for the suite's three range consumers:
-- - Nameplates "Distance to Target Text" (spell-ladder lower bound)
-- - QoL "Target Distance Text" (item bracket or spell lower bound)
-- - QoL crosshair out-of-range recolor (cutoff probes + item fallback)
--
-- One spellbook ladder, one harm-item table, one set of invalidation events.
-- Everything is built lazily and the events are registered only while at
-- least one consumer has declared itself active (Range_SetActive), so the
-- engine costs nothing while every range feature is disabled. When several
-- features are enabled at once they share a single ladder build and a short
-- per-tick result cache, so the same question is never answered twice in
-- the same window.
--
-- No direct distance API exists for enemies, so everything here is inferred
-- from in-range answers: the player's own harmful spellbook spells (checked
-- live, so talent range extensions are reflected automatically) and a ladder
-- of fixed-range harm items.
-------------------------------------------------------------------------------
if not EllesmereUI then return end
if EllesmereUI.Range_SetActive then return end -- already loaded
local GetTime = GetTime
-- Fixed-range harm items for the item bracket. Desired display buckets:
-- 1-10: 1 yd steps -- 10-50: 5 yd -- 50-80: 10 yd -- then 80+.
-- Yards with no reliable item (1, 6, 9) are omitted; neighboring checks form
-- the bracket. Multi-id rungs: any one answering suffices (some items are
-- invalid on some clients).
local RANGE_ITEMS = {
{ range = 2, ids = { 37727, 168948, 194718 } }, -- Ruby Acorn / Dried Kelp / Salamander Feed
{ range = 3, ids = { 42732, 200469 } }, -- Everfrost Razor / Khadgar's Rod
{ range = 4, id = 129055 }, -- Shoe Shine Kit
{ range = 5, ids = { 8149, 136605, 63427 } }, -- Voodoo Charm / Solendra's / Worgsaw
{ range = 7, id = 61323 }, -- Ruby Seeds
{ range = 8, ids = { 34368, 33278 } }, -- Attuned Crystal Cores / Burning Torch
{ range = 10, ids = { 32321, 17626, 10699 } }, -- Sparrowhawk Net / Frostwolf Muzzle / Yeh'kinya's
{ range = 15, ids = { 33069, 31129 } }, -- Sturdy Rope / Blackwhelp Net
{ range = 20, ids = { 10645, 21519 } }, -- Gnomish Death Ray / Mistletoe
{ range = 25, ids = { 13289, 24268, 41509, 31463 } },
{ range = 30, ids = { 17202, 835, 7734, 34191 } },
{ range = 35, ids = { 18904, 24269 } },
{ range = 40, ids = { 28767, 18640 } }, -- Decapitator / Happy Fun Rock
{ range = 45, ids = { 32698, 23836 } }, -- Wrangling Rope / Goblin Rocket Launcher
{ range = 50, id = 116139 }, -- Haunting Memento
{ range = 60, ids = { 32825, 37887 } }, -- Soul Cannon / Seeds of Nature's Wrath
{ range = 70, id = 41265 }, -- Eyesore Blaster
{ range = 80, id = 35278 }, -- Reinforced Net
}
local RG = {
ladder = {}, -- ascending { range = yds, spells = { sid, ... } }
ladderBuilt = false,
dirty = false,
active = {}, -- consumer key -> true
activeCount = 0,
evt = nil, -- invalidation event frame (created on first activation)
probeCutoff = nil, -- cutoff the cached probe list below was built for
probes = {}, -- crosshair probe spells, longest range first
}
-- Some harmful spells answer nil on IsSpellInRange (ground-target only,
-- conditionally castable), so each rung keeps a few spares to stay
-- answerable instead of deduping to a single spell per range.
local MAX_SPELLS_PER_RUNG = 3
local MAX_PROBE_SPELLS = 4
-- Micro result cache: one slot per query shape. The TTL sits under every
-- consumer tick interval (0.15s / 0.2s), so concurrent features share one
-- walk per window while successive ticks of a single feature stay live.
local CACHE_TTL = 0.1
local lbCache = { unit = nil, t = 0, has = false, v = nil }
local brCache = { unit = nil, stop = nil, t = 0, has = false, mn = nil, mx = nil }
local function ResetCaches()
lbCache.has = false
brCache.has = false
RG.probeCutoff = nil
end
local function BuildLadder()
RG.ladderBuilt = true
RG.dirty = false
ResetCaches()
wipe(RG.ladder)
if not (C_SpellBook and C_SpellBook.GetNumSpellBookSkillLines
and C_Spell and C_Spell.GetSpellInfo and Enum and Enum.SpellBookItemType) then
return
end
local bank = Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player
local byRange = {}
for li = 1, C_SpellBook.GetNumSpellBookSkillLines() do
local line = C_SpellBook.GetSpellBookSkillLineInfo(li)
-- offSpecID is 0 (not nil) on active-spec lines.
if line and not (line.offSpecID and line.offSpecID ~= 0)
and not line.shouldHide
and line.itemIndexOffset and line.numSpellBookItems then
for si = line.itemIndexOffset + 1, line.itemIndexOffset + line.numSpellBookItems do
local itemType, actionID, spellID = C_SpellBook.GetSpellBookItemType(si, bank)
local sid = spellID or actionID
if itemType == Enum.SpellBookItemType.Spell and sid
and not (C_Spell.IsSpellPassive and C_Spell.IsSpellPassive(sid))
and (not C_Spell.IsSpellHarmful or C_Spell.IsSpellHarmful(sid)) then
local sinfo = C_Spell.GetSpellInfo(sid)
local maxR = sinfo and sinfo.maxRange
if maxR and maxR > 0 and maxR <= 100 then
local rung = byRange[maxR]
if not rung then
rung = { range = maxR, spells = {} }
byRange[maxR] = rung
RG.ladder[#RG.ladder + 1] = rung
end
if #rung.spells < MAX_SPELLS_PER_RUNG then
rung.spells[#rung.spells + 1] = sid
end
end
end
end
end
end
table.sort(RG.ladder, function(a, b) return a.range < b.range end)
end
local function EnsureLadder()
if RG.dirty or not RG.ladderBuilt then BuildLadder() end
end
-- One rung's answer: first non-nil IsSpellInRange among its spells, checked
-- through the live override (a base id a talent replaced answers nil).
-- Secret results are skipped -- fail-open to nil, never an error.
local function RungInRange(rung, unit)
local FindOvr = C_SpellBook and C_SpellBook.FindSpellOverrideByID
local spells = rung.spells
for i = 1, #spells do
local sid = spells[i]
local live = (FindOvr and FindOvr(sid)) or sid
local res = C_Spell.IsSpellInRange(live, unit)
if not (issecretvalue and issecretvalue(res)) and res ~= nil then
return res
end
end
return nil
end
-- One item rung's answer: true if any id answers in-range, false only on an
-- explicit out-of-range answer, nil when every id is secret/inapplicable.
local function ItemEntryInRange(entry, unit)
local ids = entry.ids
if ids then
local sawFalse = false
for i = 1, #ids do
local res = C_Item.IsItemInRange(ids[i], unit)
if not (issecretvalue and issecretvalue(res)) then
if res == true then return true end
if res == false then sawFalse = true end
end
end
if sawFalse then return false end
return nil
end
local res = C_Item.IsItemInRange(entry.id, unit)
if issecretvalue and issecretvalue(res) then return nil end
return res
end
-- In combat (and in protected instances, where protected-function
-- restrictions persist between pulls) C_Item.IsItemInRange is a PROTECTED
-- call against units the player cannot attack -- calling it there is an
-- ADDON_ACTION_BLOCKED, not a secret result, so it must be gated up front.
-- Hostile checks stay legal. Fails toward skipping the walk: a restricted
-- query degrades to nil (no display) instead of a blocked action.
local function ItemChecksAllowed(unit)
if not (InCombatLockdown()
or (EllesmereUI.InProtectedInstance and EllesmereUI.InProtectedInstance())) then
return true
end
local can = UnitCanAttack("player", unit)
if issecretvalue and issecretvalue(can) then return false end
return can == true
end
-------------------------------------------------------------------------------
-- Queries
-------------------------------------------------------------------------------
-- Largest spell-ladder rung the unit is BEYOND (0 = inside the smallest
-- rung), or nil when no rung produced a usable answer. Displayed as "N+".
function EllesmereUI.Range_LowerBound(unit)
if not unit or not UnitExists(unit) then return nil end
if not (C_Spell and C_Spell.IsSpellInRange) then return nil end
local now = GetTime()
if lbCache.has and lbCache.unit == unit and (now - lbCache.t) < CACHE_TTL then
return lbCache.v
end
EnsureLadder()
local ladder = RG.ladder
local lower, result, answered = 0, nil, false
for i = 1, #ladder do
local res = RungInRange(ladder[i], unit)
if res == true then
answered = true
result = lower
break
elseif res == false then
answered = true
lower = ladder[i].range
end
end
if answered and result == nil then result = lower end
lbCache.unit, lbCache.t, lbCache.has, lbCache.v = unit, now, true, result
return result
end
-- Item-ladder bracket. Ascending walk; returns minYards, maxYards where min
-- is the last rung answered out-of-range and max the first answered in-range
-- (max nil = beyond every checked rung). Returns nil when no rung answered.
-- stopRange (optional) ends the walk after the first rung at or past it when
-- nothing has answered in-range yet -- a beyond/within verdict at stopRange
-- never needs the rungs past it. A cached full walk can serve a stopped
-- query (its verdict at any cutoff is identical), never the other way.
function EllesmereUI.Range_ItemBracket(unit, stopRange)
if not unit or not UnitExists(unit) then return nil end
if not (C_Item and C_Item.IsItemInRange) then return nil end
if not ItemChecksAllowed(unit) then return nil end
local now = GetTime()
if brCache.has and brCache.unit == unit and (now - brCache.t) < CACHE_TTL
and (brCache.stop == stopRange or brCache.stop == nil) then
return brCache.mn, brCache.mx
end
local minY, maxY = 0, nil
local answered = false
for i = 1, #RANGE_ITEMS do
local entry = RANGE_ITEMS[i]
local res = ItemEntryInRange(entry, unit)
if res == true then
answered = true
maxY = entry.range
break
elseif res == false then
answered = true
minY = entry.range
end
if stopRange and entry.range >= stopRange then break end
end
if not answered then
brCache.unit, brCache.stop, brCache.t, brCache.has = unit, stopRange, now, true
brCache.mn, brCache.mx = nil, nil
return nil
end
brCache.unit, brCache.stop, brCache.t, brCache.has = unit, stopRange, now, true
brCache.mn, brCache.mx = minY, maxY
return minY, maxY
end
-- Crosshair support: is the unit beyond `cutoff` yards? Probes the player's
-- own harmful spells with ranges in [cutoff, cutoff+10] (the window keeps
-- outlier long-range utility spells from widening the answer), longest
-- first, first non-nil answer wins. Returns true/false, or nil when no
-- probe answered -- the caller falls back to the item ladder.
function EllesmereUI.Range_BeyondCutoff(unit, cutoff)
if not unit or not UnitExists(unit) then return nil end
if not (C_Spell and C_Spell.IsSpellInRange) then return nil end
EnsureLadder()
if RG.probeCutoff ~= cutoff then
RG.probeCutoff = cutoff
wipe(RG.probes)
local maxWindow = cutoff + 10
local ladder = RG.ladder
for i = #ladder, 1, -1 do -- ascending ladder walked backwards = longest first
local rung = ladder[i]
if rung.range < cutoff then break end
if rung.range <= maxWindow then
local spells = rung.spells
for j = 1, #spells do
if #RG.probes >= MAX_PROBE_SPELLS then break end
RG.probes[#RG.probes + 1] = spells[j]
end
if #RG.probes >= MAX_PROBE_SPELLS then break end
end
end
end
local FindOvr = C_SpellBook and C_SpellBook.FindSpellOverrideByID
for i = 1, #RG.probes do
local sid = RG.probes[i]
local live = (FindOvr and FindOvr(sid)) or sid
local res = C_Spell.IsSpellInRange(live, unit)
if not (issecretvalue and issecretvalue(res)) and res ~= nil then
return res == false
end
end
return nil
end
-------------------------------------------------------------------------------
-- Activation
-------------------------------------------------------------------------------
-- Consumers declare themselves while their feature is enabled. The engine
-- registers its invalidation events only while at least one consumer is
-- active and drops them all at zero, so nothing here ever fires for users
-- with every range feature disabled. Idempotent and cheap on repeat calls.
function EllesmereUI.Range_SetActive(key, on)
on = on and true or nil
if RG.active[key] == on then return end
RG.active[key] = on
RG.activeCount = RG.activeCount + (on and 1 or -1)
if on and RG.activeCount == 1 then
if not RG.evt then
RG.evt = CreateFrame("Frame")
RG.evt:SetScript("OnEvent", function()
RG.dirty = true
end)
end
RG.evt:RegisterEvent("SPELLS_CHANGED")
RG.evt:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
RG.evt:RegisterEvent("TRAIT_CONFIG_UPDATED")
RG.evt:RegisterEvent("PLAYER_ENTERING_WORLD")
-- Events were unregistered until now; anything may have changed.
RG.dirty = true
elseif not on and RG.activeCount == 0 then
if RG.evt then RG.evt:UnregisterAllEvents() end
end
end