Repository navigation
Expand file tree
/
Copy pathCSCoder_QuestIndicator.lua
More file actions
3215 lines (2982 loc) · 129 KB
/
Copy pathCSCoder_QuestIndicator.lua
File metadata and controls
3215 lines (2982 loc) · 129 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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- CS.Coder - Quest Indicator
-- A floating quest "!" above the nameplate of every mob you still need for an active quest,
-- and over the nearest quest object in front of you (herbs, sprouts, crates...).
--
-- Settings: Options > AddOns > CS.Coder > Quest Indicator, the minimap button, or /cqi.
-- Nameplates must be on (V) for the mob icons.
local ADDON, ns = ...
local TITLE = "CS.Coder - Quest Indicator"
local PARENT_NAME = "CS.Coder"
local SUBTITLE = "Quest Indicator"
-- Per-profile settings.
local DEFAULTS = {
active = true, -- master switch: the whole addon on or off
enabled = true, -- icons over quest mobs
objects = true, -- icon over the soft-targeted quest object in the world
style = "standard/classic_gold", -- icon style: "category/name" (file in icons\)
scale = 1.5, -- mob icon size
offsetX = 0, -- mob icon X
offset = -15, -- mob icon Y (above the nameplate)
anchor = "top", -- mob icon side of the nameplate: "top", "bottom", "left", "right"
objectAnchor = "top", -- object icon side of its nameplate
objectScale = 1.5, -- object icon size
objectOffsetX = 0, -- object icon X
objectOffset = 10, -- object icon Y (above the object)
progress = true, -- objective count ("8/12") on quest mobs
objectProgress = true, -- the same count on the quest item icon
progressAttach = true, -- true: X/Y from the icon (follows it); false: X/Y from the nameplate
progressFloat = true, -- bobs up and down with the icon (when the icon floats)
progressSize = 13, -- its font size
progressFont = "friz", -- key in TEXT_FONTS
progressOutline = "thin", -- "none", "thin", "thick"
progressColor = "white", -- "white", "gold", "green", "kind" (same colour as the icon) or "progress" (red to green)
progressFormat = "slash", -- "slash" 8/12, "spaced" 8 / 12, "both" 8/12 (67%), "percent" 67%, "left" 4 left
progressShadow = false, -- drop shadow behind the text
progressPulse = true, -- the count pulses when it goes up and turns gold with one left
progressX = 36, -- its X
progressY = 0, -- its Y
bounce = true, -- floating up/down animation
bounceHeight = 7, -- how far it floats (pixels)
bounceSpeed = 0.9, -- seconds for one move up (lower = faster)
glow = false, -- glowing star behind the icon
glowAlpha = 0.55, -- glow intensity
objectOwnLook = false, -- quest items use the object* look below instead of the mobs' one
objectStyle = "standard/classic_gold", -- their icon
objectBounce = true, -- their float
objectGlow = false, -- their glow
colorByType = true, -- daily/repeatable in blue, group/elite in red
combat = "show", -- "show", "fade" or "hide" the icons in combat
minimap = true, -- minimap button
minimapAngle = 220, -- its position around the minimap (degrees)
sound = true, -- play a sound when a quest objective completes
hideInInstances = true, -- no icons inside dungeons and raids
doneCheck = true, -- an icon that stops being needed turns into a check mark, then fades
readyAlert = true, -- chat line when every objective of a quest is done
minimapSummary = true, -- the minimap button's tooltip lists the open objectives
soundChoice = "coins", -- key in QUEST_SOUNDS
soundVolume = 100, -- its volume in percent
}
-- Saved layout: CSCoderQuestIndicatorDB = { version, language, profiles = { name = settings },
-- chars = { "Name - Realm" = profile name }, savedCVars }.
local SETTINGS_VERSION = 5
-- The addon's version from the .toc (the packager fills it in; a dev copy shows "dev").
local function AddonVersion()
local getMeta = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata
local version = getMeta and getMeta(ADDON, "Version")
if type(version) ~= "string" or version:find("^@") then version = "dev" end
return version
end
local DEFAULT_PROFILE = "Default"
local ICON_DIR = "Interface\\AddOns\\CSCoder_QuestIndicator\\icons\\"
-- Icon styles, in three categories (one row each in the picker). A style key is
-- "category/name", the file icons\category\name.tga.
local ICON_CATEGORIES = {
{ key = "standard", sample = "standard/classic_gold", icons = {
"classic_gold", "classic_blue", "classic_red", "classic_badge", "classic_pixel", -- the original five
"yellow", "blue", "red", "green", "purple", "orange",
"circle_yellow", "circle_blue", "circle_red", "circle_green", "circle_purple", "shield_yellow", "shield_blue", "shield_red" } },
{ key = "themed", sample = "themed/laurels", icons = { "laurels", "horde", "alliance", "banner", "scroll", "sign",
"gear", "crystal", "leaves", "arcane", "stone" } },
{ key = "states", sample = "states/highlight", icons = { "highlight", "ring", "signal", "orbit", "white", "alert",
"fire", "bright", "inactive", "blocked", "hourglass", "refresh" } },
}
local VALID_STYLE = {}
for _, category in ipairs(ICON_CATEGORIES) do
for _, name in ipairs(category.icons) do VALID_STYLE[category.key .. "/" .. name] = true end
end
local DEFAULT_STYLE = "standard/classic_gold"
-- Style keys from before the categories (v1.0-1.2): the same five icons, now "classic".
local OLD_STYLES = { gold = "standard/classic_gold", blue = "standard/classic_blue", red = "standard/classic_red",
badge = "standard/classic_badge", pixel = "standard/classic_pixel" }
-- "Color by quest type": styles that come in colours swap to their blue (daily) or red (group)
-- sibling; the rest get tinted.
local KIND_SIBLING = {}
for _, family in ipairs({
{ prefix = "standard/classic_", names = { "gold", "blue", "red" } },
{ prefix = "standard/", names = { "yellow", "blue", "red", "green", "purple", "orange" } },
{ prefix = "standard/circle_", names = { "yellow", "blue", "red", "green", "purple" } },
{ prefix = "standard/shield_", names = { "yellow", "blue", "red" } },
}) do
for _, name in ipairs(family.names) do
KIND_SIBLING[family.prefix .. name] = { daily = family.prefix .. "blue", group = family.prefix .. "red" }
end
end
local function IconPath(style)
return ICON_DIR .. (style or DEFAULT_STYLE):gsub("/", "\\")
end
local GLOW_TEXTURE = "Interface\\Cooldown\\star4"
local RECHECK_SECONDS = 1.0 -- tooltip data can arrive a moment after the plate appears
local SOFT = "softinteract" -- the game's interaction soft target (one world object)
local FADE_ALPHA = 0.35
local KIND_TINT = { daily = { 0.55, 0.8, 1 }, group = { 1, 0.5, 0.45 } }
-- Progress text formatting: the fonts every client ships, outlines and colours.
local TEXT_FONTS = { friz = "Fonts\\FRIZQT__.TTF", arial = "Fonts\\ARIALN.TTF", skurri = "Fonts\\skurri.ttf", morpheus = "Fonts\\MORPHEUS.TTF" }
local TEXT_FONT_ORDER = { "friz", "arial", "skurri", "morpheus" }
local PROGRESS_FORMATS = { "slash", "spaced", "both", "percent", "left" }
local TEXT_OUTLINES = { none = "", thin = "OUTLINE", thick = "THICKOUTLINE" }
local TEXT_COLORS = { white = { 1, 1, 1 }, gold = { 1, 0.82, 0 }, green = { 0.3, 1, 0.3 } }
local KIND_TEXT_COLOR = { normal = { 1, 0.82, 0 }, daily = { 0.55, 0.8, 1 }, group = { 1, 0.5, 0.45 } }
local POSITION_RANGE = 200 -- X/Y sliders go from -200 to 200
local SLIDER_WIDTH = 200 -- sliders fit a half-width column with their value box
local root, db, charKey -- saved variables, active profile, "Name - Realm"
local L -- active language table
local icons = {} -- unit token -> icon frame
local pool = {} -- recycled icon frames
local activeTitles = {} -- quest title -> kind ("normal", "daily", "group")
local kindByQuestID = {} -- questID -> kind
local inCombat = false
-- Fonts shared by other addons (Plater, ElvUI, WeakAuras, Details... register theirs with
-- LibSharedMedia). Looked up when needed, since those addons may load after this one; without
-- it the menu just has the four game fonts. A shared font is saved as "lsm:<name>".
local function SharedMedia()
local stub = _G.LibStub
return stub and stub("LibSharedMedia-3.0", true)
end
local function FontPath(key)
if TEXT_FONTS[key] then return TEXT_FONTS[key] end
local name = type(key) == "string" and key:match("^lsm:(.+)$")
local lsm = name and SharedMedia()
return (lsm and lsm:Fetch("font", name, true)) or TEXT_FONTS.friz
end
local function FontOptions()
local list, seen = {}, {}
for _, key in ipairs(TEXT_FONT_ORDER) do
list[#list + 1] = key
seen[TEXT_FONTS[key]:lower()] = true
end
local lsm = SharedMedia()
if lsm then
for _, name in ipairs(lsm:List("font") or {}) do
local path = lsm:Fetch("font", name, true)
if path and not seen[path:lower()] then
seen[path:lower()] = true
list[#list + 1] = "lsm:" .. name
end
end
end
return list
end
local function FontLabel(key)
if TEXT_FONTS[key] then return L["FONT_" .. key] end
return (type(key) == "string" and key:match("^lsm:(.+)$")) or tostring(key)
end
-- The progress as shown, in the chosen format, plus how far along it is (0..1).
-- raw is "8/12" or "45%" (percentage objectives always show as a percentage).
local function FormatProgress(raw)
local have, need = raw:match("^(%d+)/(%d+)$")
if have then
have, need = tonumber(have), tonumber(need)
local fraction = need > 0 and have / need or 0
local percent = math.floor(fraction * 100 + 0.5)
local format = db.progressFormat
if format == "spaced" then return have .. " / " .. need, fraction end
if format == "both" then return string.format("%d/%d (%d%%)", have, need, percent), fraction end
if format == "percent" then return percent .. "%", fraction end
if format == "left" then return string.format(L.FORMAT_LEFT_N, need - have), fraction end
return have .. "/" .. need, fraction
end
local pct = tonumber(raw:match("^(%d+)%%$") or "")
return raw, pct and pct / 100 or 0
end
-- Red at the start, yellow halfway, green when nearly done.
local function ProgressColor(fraction)
fraction = math.max(0, math.min(1, fraction or 0))
if fraction < 0.5 then return { 1, fraction * 2, 0.1 } end
return { 1 - (fraction - 0.5) * 2, 1, 0.1 }
end
-- Quest objects need both the master switch and their own option.
local function ObjectsOn() return db.active and db.objects end
local UsablePlate -- defined with the icons
local RefreshSoftObject, EnableObjectNameplates, RestoreObjectCVars -- soft-target objects
local RefreshControls, UpdatePreview, UpdateMinimapButton, UpdateGates, LayoutTabs -- options UI
local settingsScroll -- the scrolling settings page
---------------------------------------------------------------------------------------------
-- Language
---------------------------------------------------------------------------------------------
local function BuildLocale(code)
if code == nil or code == "auto" then code = GetLocale() end
if code == "enGB" then code = "enUS" end
local base = ns.locales.enUS
local chosen = ns.locales[code] or base
return setmetatable({}, { __index = function(_, key) return chosen[key] or base[key] or key end })
end
---------------------------------------------------------------------------------------------
-- Quest detection
---------------------------------------------------------------------------------------------
local GROUP_TAGS = { [1] = true, [62] = true, [81] = true, [85] = true, [88] = true, [89] = true }
local function KindFromTag(questID)
if not (questID and C_QuestLog and C_QuestLog.GetQuestTagInfo) then return nil end
local ok, tag = pcall(C_QuestLog.GetQuestTagInfo, questID)
if ok and type(tag) == "table" and (tag.isElite or GROUP_TAGS[tag.tagID or 0]) then return "group" end
return nil
end
local function RebuildQuestTitles()
wipe(activeTitles)
wipe(kindByQuestID)
local count = (C_QuestLog and C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetNumQuestLogEntries())
or (GetNumQuestLogEntries and GetNumQuestLogEntries()) or 0
for i = 1, count do
local title, isHeader, questID, kind = nil, nil, nil, "normal"
if C_QuestLog and C_QuestLog.GetInfo then
local info = C_QuestLog.GetInfo(i)
if info then
title, isHeader, questID = info.title, info.isHeader, info.questID
if (tonumber(info.frequency) or 0) > 0 then
kind = "daily"
elseif (tonumber(info.suggestedGroup) or 0) > 1 then
kind = "group"
end
end
elseif GetQuestLogTitle then
local t, _, tag, header, _, _, frequency, id = GetQuestLogTitle(i)
title, isHeader, questID = t, header, id
if frequency == true or (tonumber(frequency) or 0) > 0 then
kind = "daily"
elseif (type(tag) == "number" and tag > 1) or (type(tag) == "string" and tag ~= "") then
kind = "group"
end
end
if title and not isHeader then
if kind == "normal" then kind = KindFromTag(questID) or kind end
activeTitles[title] = kind
if questID then kindByQuestID[questID] = kind end
end
end
end
local function KindOf(title)
return (title and activeTitles[title]) or "normal"
end
-- "Kobold Vermin slain: 3/10" or "3/10 Kobold Vermin": true while the left number is below the right.
local function ProgressUnfinished(text)
local have, need = text:match("(%d+)%s*/%s*(%d+)")
if have and need then return tonumber(have) < tonumber(need) end
return nil
end
-- The count shown next to the icon: "3/10", or "45%" for percentage objectives.
local function ProgressText(text)
local have, need = text:match("(%d+)%s*/%s*(%d+)")
if have then return have .. "/" .. need end
return text:match("(%d+%%)")
end
-- Reads tooltip lines (TooltipInfo data or plain strings) and answers: true = still needed,
-- false = quest-related but finished, nil = not quest-related. Then the quest title and the
-- progress of the first unfinished objective ("3/10"), when there is one.
local function QuestStateFromLines(lines)
local lineTypes = Enum and Enum.TooltipDataLineType
local objectiveType = lineTypes and lineTypes.QuestObjective
local titleType = lineTypes and lineTypes.QuestTitle
local sawQuest, title = false, nil
for _, line in ipairs(lines) do
local text = type(line) == "table" and (line.leftText or "") or line
local lineType = type(line) == "table" and line.type or nil
if objectiveType and lineType == objectiveType then
sawQuest = true
if line.completed == false then return true, title, ProgressText(text) end
if line.completed == nil and ProgressUnfinished(text) then return true, title, ProgressText(text) end
elseif (titleType and lineType == titleType) or activeTitles[text] then
sawQuest, title = true, text
elseif sawQuest and ProgressUnfinished(text) then
return true, title, ProgressText(text)
end
end
if sawQuest then return false, title end
return nil
end
local function ByTooltipInfo(unit)
if not (C_TooltipInfo and C_TooltipInfo.GetUnit) then return nil end
local data = C_TooltipInfo.GetUnit(unit)
if not (data and data.lines) then return nil end
return QuestStateFromLines(data.lines)
end
local scanner = CreateFrame("GameTooltip", ADDON .. "Scanner", nil, "GameTooltipTemplate")
scanner:SetOwner(WorldFrame, "ANCHOR_NONE")
local function ByTooltipScan(unit)
scanner:ClearLines()
scanner:SetUnit(unit)
local lines = {}
for i = 2, scanner:NumLines() do
local fs = _G[ADDON .. "ScannerTextLeft" .. i]
lines[#lines + 1] = fs and fs:GetText() or ""
end
local state, title, progress = QuestStateFromLines(lines)
return state == true, title, progress
end
-- Unfinished objectives from the quest log, rebuilt on QUEST_LOG_UPDATE.
local objectiveTexts = {} -- { text = lower case objective, kind = quest kind }
-- What the last quest log scan found: unfinished objectives per quest (the minimap tooltip
-- summary) and when an objective last finished (icons that stop being needed right after show
-- the check mark).
local questLog = { summary = {}, completedAt = -100, byName = {} }
-- A name found in an unfinished objective ("Kobold Vermin slain: 3/10", "Crate of Supplies:
-- 1/4"): its quest kind and progress.
-- Answers are kept per name until the quest log changes (questLog.byName is emptied with each
-- scan), so a crowded area with a full quest log stays light: one search per mob name, not one
-- per nameplate every second.
local function ObjectiveKindForName(name)
if not name or name == "" then return nil end
name = name:lower()
local known = questLog.byName[name]
if known == nil then
known = false
for _, objective in ipairs(objectiveTexts) do
if objective.text:find(name, 1, true) then
known = { kind = objective.kind, progress = ProgressText(objective.text) }
break
end
end
questLog.byName[name] = known
end
if known then return known.kind, known.progress end
return nil
end
-- Returns: still needed?, quest kind, progress text.
local function IsQuestMob(unit)
if not UnitExists(unit) or UnitIsPlayer(unit) or UnitIsDead(unit) then return false end
if UnitIsFriend("player", unit) and not UnitCanAttack("player", unit) then return false end
if C_QuestLog and C_QuestLog.UnitIsRelatedToActiveQuest then
local ok, related = pcall(C_QuestLog.UnitIsRelatedToActiveQuest, unit)
if ok and related ~= nil then
if not related then return false end
local state, title, progress = ByTooltipInfo(unit)
if state ~= false then return true, KindOf(title), progress end
-- Every quest the tooltip lists is done, yet the game says the mob still matters:
-- another quest needs it without showing in the tooltip (often a drop). Its name in
-- an unfinished objective of the quest log gives that quest's count.
local kind, logProgress = ObjectiveKindForName(UnitName(unit))
if kind then return true, kind, logProgress end
-- No objective names it (an item with another name): trust the game and mark the mob,
-- without a count. Players who collect many quests hit this often; a missing mark
-- is worse than a count we can't know.
return true, "normal", nil
end
end
local state, title, progress = ByTooltipInfo(unit)
if state == true then return true, KindOf(title), progress end
if state == false then
-- The tooltip's quests are done; another quest may still name this mob (see above).
local kind, logProgress = ObjectiveKindForName(UnitName(unit))
return kind ~= nil, kind, logProgress
end
local scanned, scannedTitle, scannedProgress = ByTooltipScan(unit)
if scanned then return true, KindOf(scannedTitle), scannedProgress end
-- Last resort, for clients whose tooltips show no quest lines: the mob's name in an
-- unfinished objective of the quest log.
local kind, logProgress = ObjectiveKindForName(UnitName(unit))
return kind ~= nil, kind, logProgress
end
---------------------------------------------------------------------------------------------
-- Icons (quest mobs, the soft-targeted object and the options preview share one frame type)
---------------------------------------------------------------------------------------------
local function CombatAlpha()
if not inCombat then return 1 end
if db.combat == "hide" then return 0 end
if db.combat == "fade" then return FADE_ALPHA end
return 1
end
-- Places the progress text. Attached: glued to the right of the icon, so it follows the icon's
-- position and size (its X/Y are locked). Detached: X/Y from the nameplate. wave (0..1) is the
-- float phase when it floats with the icon; the icon moves bounceHeight in its own scale, so the
-- text moves bounceHeight * scale.
local ATTACH_GAP = 2
local function PlaceText(f, wave)
local text, label = f.text, f.text.label
local lift = wave * db.bounceHeight * (f.isObject and db.objectScale or db.scale)
text:ClearAllPoints()
label:ClearAllPoints()
if db.progressAttach then
label:SetPoint("LEFT")
text:SetPoint("LEFT", f, "RIGHT", ATTACH_GAP, lift)
else
label:SetPoint("CENTER")
text:SetPoint("CENTER", f.plate or f:GetParent(), "TOP", db.progressX, db.progressY + lift)
end
end
-- "style", "bounce" or "glow" for this icon: quest items with their own look use objectStyle,
-- objectBounce and objectGlow; everything else uses the shared setting.
local function Look(f, key)
if f.isObject and db.objectOwnLook then return db["object" .. key:sub(1, 1):upper() .. key:sub(2)] end
return db[key]
end
local function StyleIcon(f)
-- Texture and tint: with "color by quest type" the coloured styles swap to their blue/red
-- sibling and the others are tinted.
local style, tint = Look(f, "style"), nil
local kind = f.kind or "normal"
if db.colorByType and KIND_TINT[kind] then
local sibling = KIND_SIBLING[style]
if sibling then style = sibling[kind] else tint = KIND_TINT[kind] end
end
f.icon:SetTexture(IconPath(style))
if tint then f.icon:SetVertexColor(tint[1], tint[2], tint[3]) else f.icon:SetVertexColor(1, 1, 1) end
local scale = f.isObject and db.objectScale or db.scale
f:SetScale(scale * (f.previewRatio or 1))
f:SetAlpha(f.isPreview and 1 or CombatAlpha())
-- Progress text ("8/12", on mobs and, if wanted, on the quest item). It has its own size and
-- position: the frame undoes the icon's scale, so its size and X/Y don't change with the icon size.
local text = f.text
local wanted = f.isObject and db.objectProgress or (not f.isObject and db.progress)
local showText = wanted and f.progressValue ~= nil
text:SetShown(showText)
if showText then
local label = text.label
local flags = TEXT_OUTLINES[db.progressOutline] or "OUTLINE"
-- A font that fails to load (e.g. a removed shared font) falls back to the game's own.
if label:SetFont(FontPath(db.progressFont), db.progressSize, flags) == false then
label:SetFont(TEXT_FONTS.friz, db.progressSize, flags)
end
if db.progressShadow then
label:SetShadowColor(0, 0, 0, 1)
label:SetShadowOffset(1, -1)
else
label:SetShadowOffset(0, 0)
end
local shown, fraction = FormatProgress(f.progressValue)
-- "Same as the icon" follows the quest type (gold, blue daily, red group); "By progress"
-- goes from red (just started) through yellow to green (almost done).
local color = TEXT_COLORS[db.progressColor]
if db.progressColor == "kind" then
color = db.colorByType and KIND_TEXT_COLOR[f.kind or "normal"] or KIND_TEXT_COLOR.normal
elseif db.progressColor == "progress" then
color = ProgressColor(fraction)
end
color = color or TEXT_COLORS.white
-- One left: gold, whatever the colour setting (with "Animate the count").
if db.progressPulse then
local have, need = tostring(f.progressValue):match("(%d+)%s*/%s*(%d+)")
if have and tonumber(need) - tonumber(have) == 1 then color = TEXT_COLORS.gold end
end
label:SetTextColor(color[1], color[2], color[3])
label:SetText(shown)
text:SetScale(1 / scale)
PlaceText(f, 0)
end
local glow = Look(f, "glow")
f.glow:SetVertexColor(1, 0.82, 0.2, db.glowAlpha)
f.glow:SetShown(glow)
if glow then
if not f.pulse:IsPlaying() then f.pulse:Play() end
else
f.pulse:Stop()
end
-- The float itself is driven by the bobber below; without it the icon rests at the bottom.
if not Look(f, "bounce") then f.inner:SetPoint("CENTER", f, "CENTER", 0, 0) end
end
-- Where the icon sits around the nameplate; X/Y move it from there. "top" keeps the plate frame
-- as before; the other sides use the health bar (the part you see) when the plate has one.
local ICON_ANCHORS = { top = { "BOTTOM", "TOP" }, bottom = { "TOP", "BOTTOM" }, left = { "RIGHT", "LEFT" }, right = { "LEFT", "RIGHT" } }
local ICON_ANCHOR_ORDER = { "top", "bottom", "left", "right" }
local function PlateBar(plate)
if plate.previewBar then return plate.previewBar end
local frame = plate.UnitFrame
return frame and (frame.healthBar or frame.HealthBar or (frame.HealthBarsContainer and frame.HealthBarsContainer.healthBar))
end
local function AnchorIcon(f, plate)
f:ClearAllPoints()
local anchor, x, y
if f.isObject then
anchor, x, y = db.objectAnchor, db.objectOffsetX, db.objectOffset
else
anchor, x, y = db.anchor, db.offsetX, db.offset
end
local points = ICON_ANCHORS[anchor] or ICON_ANCHORS.top
local target = (anchor ~= "top" and PlateBar(plate)) or plate
f:SetPoint(points[1], target, points[2], x, y)
end
local function CreateIcon()
local f = CreateFrame("Frame", nil, UIParent)
f:SetSize(26, 26)
f:SetFrameStrata("LOW")
-- The textures sit on an inner frame that the bobber moves up and down, so the outer frame
-- keeps its anchor to the nameplate.
local inner = CreateFrame("Frame", nil, f)
inner:SetSize(26, 26)
inner:SetPoint("CENTER")
f.inner = inner
f.phase = math.random() * 10 -- icons don't all float in step
f.previewRatio = 1 -- set by the options preview to match real plates
local glow = inner:CreateTexture(nil, "BACKGROUND")
glow:SetTexture(GLOW_TEXTURE)
glow:SetBlendMode("ADD")
glow:SetPoint("CENTER")
glow:SetSize(40, 40)
f.glow = glow
local icon = inner:CreateTexture(nil, "ARTWORK")
icon:SetAllPoints()
f.icon = icon
local text = CreateFrame("Frame", nil, f)
text:SetSize(60, 20)
text.label = text:CreateFontString(nil, "OVERLAY")
text.label:SetPoint("CENTER")
text.label:SetFont(TEXT_FONTS.friz, DEFAULTS.progressSize, "OUTLINE")
text.label:SetTextColor(1, 1, 1)
text:Hide()
f.text = text
local pulse = glow:CreateAnimationGroup()
pulse:SetLooping("REPEAT")
local spin = pulse:CreateAnimation("Rotation")
spin:SetDegrees(-360)
spin:SetDuration(6)
local fade = pulse:CreateAnimation("Alpha")
fade:SetFromAlpha(0.45)
fade:SetToAlpha(1)
fade:SetDuration(0.9)
fade:SetSmoothing("IN_OUT")
f.pulse = pulse
local pop = f:CreateAnimationGroup()
local grow = pop:CreateAnimation("Scale")
grow:SetScaleFrom(0.3, 0.3)
grow:SetScaleTo(1, 1)
grow:SetDuration(0.18)
grow:SetSmoothing("OUT")
f.pop = pop
-- Done: the check mark swells a little and fades out; then the icon goes back to the pool.
local done = f:CreateAnimationGroup()
local swell = done:CreateAnimation("Scale")
swell:SetScaleFrom(1, 1)
swell:SetScaleTo(1.35, 1.35)
swell:SetDuration(0.25)
swell:SetSmoothing("OUT")
local fadeOut = done:CreateAnimation("Alpha")
fadeOut:SetFromAlpha(1)
fadeOut:SetToAlpha(0)
fadeOut:SetStartDelay(0.6)
fadeOut:SetDuration(0.5)
done:SetScript("OnFinished", function() if f.onDone then f.onDone(f) end end)
f.done = done
-- The count's pulse when it goes up: out to 1.4x and back.
local textPop = text:CreateAnimationGroup()
local out = textPop:CreateAnimation("Scale")
out:SetScale(1.4, 1.4)
out:SetDuration(0.12)
out:SetOrder(1)
local back = textPop:CreateAnimation("Scale")
back:SetScale(1 / 1.4, 1 / 1.4)
back:SetDuration(0.18)
back:SetOrder(2)
f.textPop = textPop
return f
end
local function ShowIcon(unit, plate, kind, progress)
kind = kind or "normal"
local f = icons[unit]
if f then
if f.completing then return end
if f.kind ~= kind or f.progressValue ~= progress then
local before = tonumber(tostring(f.progressValue):match("%d+"))
local now = tonumber(tostring(progress):match("%d+"))
f.kind, f.progressValue = kind, progress
StyleIcon(f)
if db.progressPulse and before and now and now > before and f.text:IsShown() then
f.textPop:Stop()
f.textPop:Play()
end
end
return
end
f = table.remove(pool) or CreateIcon()
icons[unit] = f
f.unit, f.isObject, f.kind, f.progressValue = unit, unit == SOFT, kind, progress
f:SetParent(plate)
f:SetFrameStrata("LOW")
AnchorIcon(f, plate)
f:Show()
StyleIcon(f)
f.pop:Play()
end
local function HideIcon(unit)
local f = icons[unit]
if not f then return end
icons[unit] = nil
f.pulse:Stop()
f.done:Stop()
f.completing, f.onDone = nil, nil
f:Hide()
f:SetParent(UIParent)
f.unit, f.progressValue = nil, nil
f.text:Hide()
table.insert(pool, f)
end
-- An icon that stops being needed right after an objective finished turns into a green check
-- mark, then fades; otherwise it just goes.
local function HideOrComplete(unit)
local f = icons[unit]
if not f or f.completing then return end
if not (db.doneCheck and GetTime() - questLog.completedAt < 3) then -- 3 s after an objective finished
HideIcon(unit)
return
end
f.completing = true
f.icon:SetTexture("Interface\\RaidFrame\\ReadyCheck-Ready")
f.icon:SetVertexColor(1, 1, 1)
f.glow:SetVertexColor(0.3, 1, 0.3, 0.9)
f.glow:Show()
f.text:Hide()
f.onDone = function(self) if icons[unit] == self then HideIcon(unit) end end
f.done:Play()
end
local function UpdateCombatAlpha()
local alpha = CombatAlpha()
for _, f in pairs(icons) do f:SetAlpha(alpha) end
end
-- A unit's nameplate if the addon may use it. In dungeons and raids the game can hand out
-- "forbidden" plates (other addons may not touch them): anchoring to one, or measuring it,
-- raises errors, so those count as no plate.
function UsablePlate(unit)
local plate = C_NamePlate and C_NamePlate.GetNamePlateForUnit and C_NamePlate.GetNamePlateForUnit(unit)
if not plate then return nil end
if plate.IsForbidden then
local ok, forbidden = pcall(plate.IsForbidden, plate)
if not ok or forbidden then return nil end
end
return plate
end
-- Inside a dungeon or raid (party / raid instance), with "Hide in dungeons and raids" on.
local function InstanceHidden()
if not (db.hideInInstances and IsInInstance) then return false end
local inside, kind = IsInInstance()
return inside and (kind == "party" or kind == "raid") or false
end
local function Refresh(unit)
local plate = UsablePlate(unit)
if db.active and db.enabled and plate and not InstanceHidden() then
local needed, kind, progress = IsQuestMob(unit)
if needed then ShowIcon(unit, plate, kind, progress) return end
if not UnitIsDead(unit) then HideOrComplete(unit) return end
end
HideIcon(unit)
end
local function RefreshAll()
if not (C_NamePlate and C_NamePlate.GetNamePlates) then return end
for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
local unit = plate.namePlateUnitToken or (plate.UnitFrame and plate.UnitFrame.unit)
if unit and unit ~= SOFT then Refresh(unit) end
end
RefreshSoftObject()
for unit in pairs(icons) do
if not UsablePlate(unit) then HideIcon(unit) end
end
end
local function ApplySettings()
for _, f in pairs(icons) do
AnchorIcon(f, f:GetParent())
StyleIcon(f)
end
if ObjectsOn() then EnableObjectNameplates() else RestoreObjectCVars() end
if UpdatePreview then UpdatePreview() end
if UpdateGates then UpdateGates() end
if UpdateMinimapButton then UpdateMinimapButton() end
RefreshAll()
end
---------------------------------------------------------------------------------------------
-- Icon over the soft-targeted quest object. World objects only get a nameplate as the game's
-- interaction soft target ("softinteract", one object at a time, the nearest in front of you).
---------------------------------------------------------------------------------------------
local objectiveStates = {} -- questID -> { [objective index] = finished }, to hear one complete
-- Sounds for "objective complete" (sound kit IDs), picked by ear in game on 2026-11-09 from a
-- longer list. The old choices (ping, ready, warning, level up) fall back to the default.
local QUEST_SOUNDS = { coins = 120, auction = 5274, auctionclose = 1204, worldquest = 44295, bell = 6674, questlog = 875, quest = 878 }
local QUEST_SOUND_ORDER = { "coins", "auction", "auctionclose", "worldquest", "bell", "questlog", "quest" }
-- Plays a sound at the chosen volume. The game has no volume per sound, only per channel: at
-- 100% it plays on Master as before; below, it plays on the Dialog channel, whose volume is set
-- for a moment and put back 3 s after the last sound (also at the next login, if the game was
-- closed meanwhile: root.dialogVolume).
local PlaySoundAt
do
local pending -- the latest sound waiting to restore the volume
function PlaySoundAt(id)
if not (PlaySound and id) then return end
local volume = (tonumber(db.soundVolume) or 100) / 100
local canSet = C_CVar and C_CVar.GetCVar and C_CVar.SetCVar
local dialogOn = canSet and tostring(C_CVar.GetCVar("Sound_EnableDialog")) ~= "0"
if volume >= 0.995 or not dialogOn then
pcall(PlaySound, id, "Master")
return
end
if volume <= 0 then return end
if root.dialogVolume == nil then root.dialogVolume = C_CVar.GetCVar("Sound_DialogVolume") end
pcall(C_CVar.SetCVar, "Sound_DialogVolume", volume)
pcall(PlaySound, id, "Dialog")
local token = {}
pending = token
C_Timer.After(3, function()
if pending ~= token or root.dialogVolume == nil then return end
pcall(C_CVar.SetCVar, "Sound_DialogVolume", root.dialogVolume)
root.dialogVolume = nil
end)
end
end
local function PlayQuestSound()
PlaySoundAt(QUEST_SOUNDS[db.soundChoice] or QUEST_SOUNDS[DEFAULTS.soundChoice])
end
local RebuildObjectiveTexts
do -- (own block: the main chunk is near Lua's 200-locals limit)
local function QuestTitle(questID, logIndex)
local title = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(questID)
if not title and C_QuestLog.GetInfo then
local info = C_QuestLog.GetInfo(logIndex)
title = info and info.title
end
if not title and GetQuestLogTitle then title = GetQuestLogTitle(logIndex) end
return title or "?"
end
function RebuildObjectiveTexts()
wipe(objectiveTexts)
wipe(questLog.byName)
if not (C_QuestLog and C_QuestLog.GetQuestObjectives) then return end
-- An objective that was unfinished on the last scan and is finished now plays the sound
-- (once per scan); a quest whose last objective just finished is announced as ready.
-- Quests seen for the first time (login, just accepted) stay silent.
local states, completed, ready, summary = {}, false, {}, {}
local count = (C_QuestLog.GetNumQuestLogEntries and C_QuestLog.GetNumQuestLogEntries()) or 0
for i = 1, count do
local questID = C_QuestLog.GetQuestIDForLogIndex and C_QuestLog.GetQuestIDForLogIndex(i)
if not questID and C_QuestLog.GetInfo then
local info = C_QuestLog.GetInfo(i)
questID = info and not info.isHeader and info.questID
end
local objectives = questID and C_QuestLog.GetQuestObjectives(questID)
local before = objectiveStates[questID]
local allDone, wasAllDone, open = #(objectives or {}) > 0, true, nil
for index, objective in ipairs(objectives or {}) do
local finished = objective.finished and true or false
if objective.text and not finished then
objectiveTexts[#objectiveTexts + 1] = { text = objective.text:lower(), kind = kindByQuestID[questID] or "normal" }
open = open or {}
open[#open + 1] = objective.text
end
states[questID] = states[questID] or {}
states[questID][index] = finished
if not finished then allDone = false end
if before and before[index] == false then
wasAllDone = false
if finished then completed = true end
end
end
if open then summary[#summary + 1] = { title = QuestTitle(questID, i), lines = open } end
if before and allDone and not wasAllDone then ready[#ready + 1] = QuestTitle(questID, i) end
end
objectiveStates = states
questLog.summary = summary
if completed then questLog.completedAt = GetTime() end
if not db.active then return end
if completed and db.sound then PlayQuestSound() end
if db.readyAlert then
for _, title in ipairs(ready) do
print(string.format("|cffffd200%s|r " .. L.READY_TO_TURN_IN, TITLE, "|cff00ff00" .. title .. "|r"))
end
end
end
end
-- Returns: still needed?, quest kind, progress text.
local function IsQuestObject(unit)
if not (UnitIsGameObject and UnitIsGameObject(unit)) then return false end
local state, title, progress = ByTooltipInfo(unit)
if state ~= nil then return state, KindOf(title), progress end
local kind, logProgress = ObjectiveKindForName(UnitName(unit))
return kind ~= nil, kind, logProgress
end
-- Cvars this addon depends on. Other addons (e.g. ForeverLootSparkles) change the same ones and
-- put them back when their own options go off, so they are re-applied on CVAR_UPDATE.
-- Original values are saved and restored when "objects" is switched off here.
local OBJECT_CVARS = {
SoftTargetIconGameObject = 1, -- world objects can get a nameplate
SoftTargetInteractRange = 60, -- find objects further away (the client caps it lower)
SoftTargetInteractOnlyInRange = 0, -- also objects outside the interact range
}
local GATE_CVAR = "SoftTargetNameplateInteract" -- nameplate on the interaction soft target
local WATCHED_CVARS = { softtargetinteract = true, softtargetnameplateinteract = true }
for name in pairs(OBJECT_CVARS) do WATCHED_CVARS[name:lower()] = true end
local pendingCVars -- "apply" or "restore" once combat ends
local gateOpenedByUs = false
local function GetCV(name)
local ok, value = pcall(C_CVar.GetCVar, name)
if ok then return value end
end
-- Writes only when the value changes; remembers the player's original the first time.
local function SetCV(name, value, remember)
local current = GetCV(name)
if current == nil or tostring(current) == tostring(value) then return end
if remember then
root.savedCVars = root.savedCVars or {}
if root.savedCVars[name] == nil then root.savedCVars[name] = current end
end
pcall(C_CVar.SetCVar, name, value)
end
function EnableObjectNameplates()
if not (C_CVar and C_CVar.SetCVar) then return end
if InCombatLockdown() then pendingCVars = "apply"; return end
if tonumber(GetCV("SoftTargetInteract") or 0) == 0 then SetCV("SoftTargetInteract", 3, true) end
for name, value in pairs(OBJECT_CVARS) do
if name == "SoftTargetInteractRange" then
if (tonumber(GetCV(name)) or 0) < value then SetCV(name, value, true) end
else
SetCV(name, value, true)
end
end
end
function RestoreObjectCVars()
if not (C_CVar and C_CVar.SetCVar) then return end
if InCombatLockdown() then pendingCVars = "restore"; return end
if gateOpenedByUs then gateOpenedByUs = false; SetCV(GATE_CVAR, 0) end
for name, value in pairs(root.savedCVars or {}) do pcall(C_CVar.SetCVar, name, value) end
root.savedCVars = nil
end
local function IsLoaded(addon)
if C_AddOns and C_AddOns.IsAddOnLoaded then return C_AddOns.IsAddOnLoaded(addon) end
return IsAddOnLoaded and IsAddOnLoaded(addon)
end
-- Opens the soft target's nameplate for quest objects only, so NPCs and mailboxes don't get one.
-- When ForeverLootSparkles is loaded it manages the plate for ore, herbs and corpses itself.
local function UpdateGate(isQuest)
if InCombatLockdown() or not (C_CVar and C_CVar.SetCVar) then return end
if isQuest then
if tostring(GetCV(GATE_CVAR)) ~= "1" then
SetCV(GATE_CVAR, 1)
gateOpenedByUs = true
end
elseif gateOpenedByUs then
gateOpenedByUs = false
local othersWantIt = IsLoaded("ForeverLootSparkles") and UnitExists(SOFT)
and ((UnitIsGameObject and UnitIsGameObject(SOFT)) or UnitIsDead(SOFT))
if not othersWantIt then SetCV(GATE_CVAR, 0) end
end
end
function RefreshSoftObject()
if not ObjectsOn() or InstanceHidden() then
UpdateGate(false)
HideIcon(SOFT)
return
end
local isQuest, kind, progress = false, nil, nil
if UnitExists(SOFT) then isQuest, kind, progress = IsQuestObject(SOFT) end
UpdateGate(isQuest)
local plate = UsablePlate(SOFT)
if isQuest and plate then
if icons[SOFT] and icons[SOFT]:GetParent() ~= plate then HideIcon(SOFT) end
ShowIcon(SOFT, plate, kind, progress)
else
HideIcon(SOFT) -- the plate follows the gate; NAME_PLATE_UNIT_ADDED shows the icon
end
end
---------------------------------------------------------------------------------------------
-- Profiles
---------------------------------------------------------------------------------------------
local function FillDefaults(profile)
for key, value in pairs(DEFAULTS) do
if profile[key] == nil then profile[key] = value end
end
profile.cursor = nil -- removed option
profile.style = OLD_STYLES[profile.style] or profile.style
if not VALID_STYLE[profile.style] then profile.style = DEFAULT_STYLE end
if not QUEST_SOUNDS[profile.soundChoice] then profile.soundChoice = DEFAULTS.soundChoice end
for _, key in ipairs({ "anchor", "objectAnchor" }) do
if not ICON_ANCHORS[profile[key]] then profile[key] = "top" end
end
return profile
end
local function CopySettings(source)
local copy = {}
for key in pairs(DEFAULTS) do copy[key] = source[key] end
return copy
end
local function ProfileNames()
local names = {}
for name in pairs(root.profiles) do names[#names + 1] = name end
table.sort(names, function(a, b) return a:lower() < b:lower() end)
return names
end
local function ActiveProfileName()
return root.chars[charKey]
end
local function ResolveCharacter()
local name, realm = UnitName("player"), GetRealmName and GetRealmName()
charKey = (name or "?") .. " - " .. (realm or "?")
local profile = root.chars[charKey]
if not (profile and root.profiles[profile]) then
profile = root.profiles[DEFAULT_PROFILE] and DEFAULT_PROFILE or ProfileNames()[1]
root.chars[charKey] = profile
end
db = root.profiles[profile]
end
local function LoadSavedVariables()
local fresh = CSCoderQuestIndicatorDB == nil
CSCoderQuestIndicatorDB = CSCoderQuestIndicatorDB or {}
root = CSCoderQuestIndicatorDB
-- Characters that used the addon before the setup guide existed don't get it on update.
if not root.setupSeen then
root.setupSeen = {}
if not fresh then
for key in pairs(root.chars or {}) do root.setupSeen[key] = true end
end
end
if not root.profiles then
-- Up to version 4 the settings sat directly in the saved table: they become "Default".
local profile = {}
for key in pairs(DEFAULTS) do
profile[key] = root[key]
root[key] = nil