Repository navigation
Expand file tree
/
Copy pathKickAssist.lua
More file actions
2525 lines (2311 loc) · 106 KB
/
Copy pathKickAssist.lua
File metadata and controls
2525 lines (2311 loc) · 106 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
local ADDON = ...
-- Forward-declared: the options-panel checkbox setter (~line 689) references this
-- lexically ABOVE the definition further down, so without this declaration the name
-- there binds to a nil global and throws "attempt to call a nil value" on click.
-- The definition below assigns to this local instead of creating a new one.
local SyncInterruptAlert
--------------------------------------------------------------------------------
-- Kick Assist
-- Pick your interrupt (kick) raid marker, announce it to the group, and keep
-- your interrupt macro's marker number in sync with your pick. The popup
-- auto-shows on ready check and / or Mythic+ start.
--
-- Marking note: SetRaidTarget is a PROTECTED function (addons may not call it).
-- The game already ships a secure /tm command (Blizzard's TARGET_MARKER slash)
-- that does the marking. This addon never calls SetRaidTarget and never registers
-- /tm; it only rewrites the marker NUMBER inside your macro so the built-in /tm
-- marks with whatever you picked.
--
-- Announce note: a message sent from a button click is always allowed. Automated
-- announce on the trigger works outside instanced content; inside an instance it
-- can be blocked, in which case the button still works.
--------------------------------------------------------------------------------
-- {interrupt} = your spec's interrupt, {marker} = your marker. Marking is always on
-- @focus, and the ~ before {marker} marks only if your focus has no marker yet, so the
-- marker is placed when you set your focus and then stays put: re-pressing kicks the
-- focus and never moves the marker onto whatever you happen to be targeting. The
-- default Focus+Kick casts before setting focus, so the first press sets focus and the
-- next press kicks it (no modifier, no mouseover).
local DEFAULT_MACRO =
"#showtooltip {interrupt}\n" ..
"/cast [@focus,harm,nodead] {interrupt}\n" ..
"/focus [@focus,noexists] target\n" ..
"/tm [@focus] ~{marker}"
-- Set focus + mark; no #showtooltip so it keeps the targeting icon.
local SET_FOCUS_MACRO =
"/focus target\n" ..
"/tm [@focus] ~{marker}"
-- Auto tab kick (default): tab to the nearest enemy, interrupt, return to your target.
local AUTOTAB_MACRO =
"#showtooltip {interrupt}\n" ..
"/cleartarget\n" ..
"/targetenemy\n" ..
"/cast {interrupt}\n" ..
"/targetlasttarget"
-- Auto tab kick (focus first): kick your focus if you have one, else tab-interrupt
-- a casting mob without losing your current target.
local AUTOTAB_FOCUS_MACRO =
"#showtooltip {interrupt}\n" ..
"/cast [@focus,exists,nodead,harm] {interrupt}\n" ..
"/stopmacro [@focus,exists,nodead,harm]\n" ..
"/focus target\n" ..
"/cleartarget\n" ..
"/targetenemy\n" ..
"/cast {interrupt}\n" ..
"/target focus\n" ..
"/clearfocus\n" ..
"/startattack"
-- Auto tab kick (mouseover override): kick your mouseover or focus if valid, else
-- tab-interrupt without losing your current target.
local AUTOTAB_MOUSEOVER_MACRO =
"#showtooltip\n" ..
"/cast [@mouseover,harm,nodead][@focus,harm,nodead,exists] {interrupt}\n" ..
"/stopmacro [@mouseover,harm,nodead][@focus,harm,nodead,exists]\n" ..
"/focus target\n" ..
"/cleartarget\n" ..
"/targetenemy\n" ..
"/cast {interrupt}\n" ..
"/target focus\n" ..
"/clearfocus"
-- Templates per macro slot: the editor shows the set for whichever macro is selected.
local TEMPLATES = {
kick = {
{ name = "Focus + Kick (default, re-press to kick)", body = DEFAULT_MACRO },
{ name = "Focus + Kick (Ctrl to kick your target)",
body = "#showtooltip {interrupt}\n/cast [nomod:ctrl,@focus,harm,nodead][] {interrupt}\n/focus [@focus,noexists] target\n/tm [@focus] ~{marker}" },
{ name = "Focus + Kick (mouseover)",
body = "#showtooltip {interrupt}\n/cast [@focus,harm,nodead] {interrupt}\n/focus [@mouseover,harm,nodead,exists] mouseover\n/tm [@focus] ~{marker}" },
},
focus = {
{ name = "Set focus (target)", body = SET_FOCUS_MACRO },
{ name = "Set focus (mouseover)",
body = "/focus [@mouseover,exists] mouseover\n/tm [@focus] ~{marker}" },
},
autotab = {
{ name = "Auto Tab Kick (tab to nearest)", body = AUTOTAB_MACRO },
{ name = "Auto Tab Kick (focus first, else tab)", body = AUTOTAB_FOCUS_MACRO },
{ name = "Auto Tab Kick (mouseover or focus, else tab)", body = AUTOTAB_MOUSEOVER_MACRO },
},
}
-- The three macro slots the editor can edit (Focus+Kick, Set Focus, Auto Tab Kick).
local SLOT_CFG = {
kick = { label = "Focus + Kick", nameKey = "macroName", tmplKey = "macroTemplate", defName = "FocusKick", defBody = DEFAULT_MACRO },
focus = { label = "Set Focus", nameKey = "setFocusName", tmplKey = "setFocusTemplate", defName = "SetFocus", defBody = SET_FOCUS_MACRO },
autotab = { label = "Auto Tab Kick", nameKey = "autoTabName", tmplKey = "autoTabTemplate", defName = "AutoTabKick", defBody = AUTOTAB_MACRO },
}
local SLOT_ORDER = { "kick", "focus", "autotab" }
local DEFAULTS = {
marker = 8, -- 1..8 raid target index, 0 = no marker (skull default)
showOnReadyCheck = true, -- show on ready check while in a Mythic+ dungeon
announceOnReadyCheck = true, -- post your kick to chat on a ready check (sends before a key; auto-skips once chat is locked)
-- Which instances the ready-check popup/announce fires in (default: Mythic dungeons only).
contexts = { mplus = true, mythic = true, heroic = false, normal = false, raid = false },
smartOpen = false, -- on a ready check, wait and open only if someone else calls your marker
classicLook = false, -- use the old Blizzard-styled marker window instead of the Arc look
customKick = "", -- blank = use the class/spec default; a spell NAME or ID overrides it
optW = 460, -- remembered options-window size. Listed here so that
optH = 540, -- enabling account-wide SEEDS it (the seed walks DEFAULTS)
-- instead of snapping the window back to its default size.
toggleStyle = "checkbox", -- the Arc theme toggle is the WoW-style square checkbox
interruptAlert = false, -- sound/TTS when your FOCUS starts casting and your interrupt is ready
interruptAlertTTS = false, -- alert via spoken text (TTS) instead of a sound
interruptAlertText = "Kick", -- the TTS phrase
interruptAlertSound = "Default", -- sound to play (non-TTS): "Default", "None", a built-in name, or a LibSharedMedia sound
interruptAlertChannel = "Master", -- sound channel
message = "My Focus Kick is %MARKER%",
point = { "CENTER", "CENTER", 0, 140 },
macroEnabled = false, -- opt-in: do not touch macros until the user enables it
macroName = "FocusKick", -- set-focus-and-kick macro
macroTemplate = DEFAULT_MACRO,
setFocusName = "SetFocus", -- set-focus-and-mark macro
setFocusTemplate = SET_FOCUS_MACRO,
autoTabName = "AutoTabKick",-- auto tab-interrupt macro
autoTabTemplate = AUTOTAB_MACRO,
macroPoint = { "CENTER", "CENTER", 0, 0 },
minimap = { angle = 214, hide = false },
}
local MARKER_NAMES = {
[0] = "No Marker",
"Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull",
}
local PREFIX = "|cff33ff99Kick Assist|r: "
local QUESTION_ICON = "INV_Misc_QuestionMark"
local FOCUS_ICON = 132212 -- set-focus macro icon (fileID)
local DB -- active settings store (per-character or account-wide), resolved at ADDON_LOADED
local CDB -- per-character store (KickAssistDB)
local ADB -- account store (KickAssistAccountDB): the account-wide flag + shared settings
local RefreshKAOptions -- assigned in the options-window block; re-reads DB into the open panel
local OpenKAOptions -- assigned in the options-window block; opens the branded settings window
local arcPopup -- the branded (Arc look) marker-picker popup
local CreateArcPopup -- assigned in the options block; builds the branded popup
local RefreshArcPopup -- assigned in the options block; re-reads DB into the branded popup
local frame -- main popup, created lazily
local macroFrame -- macro editor, created lazily
local settingsCategory -- Blizzard settings category (set in CreateSettingsPanel)
local myName -- our character name, cached while readable (UnitName is secret inside M+)
local smartOpenExpire = 0 -- GetTime() until which Smart Open watches party chat
-- Cache our own name while it is readable. UnitName("player") is secret inside instances,
-- so we grab it on login / zoning (out in the world it is not secret) and reuse that string
-- to recognize our own chat echo during a key.
local function RememberMyName()
local n = UnitName("player")
if n and not issecretvalue(n) then myName = n end
end
--------------------------------------------------------------------------------
-- Account-wide vs per-character storage
--
-- Default is per-character: each toon has its own settings and its own macros.
-- Turning on "account-wide" points everything at a shared account store and makes the
-- kick macros account (general) macros, so one setup covers every character and the macro
-- auto-updates to each class on login. Switching modes never deletes data: the per-character
-- store is kept intact and just goes inactive while account-wide is on.
--------------------------------------------------------------------------------
local function FillDefaults(store)
for k, v in pairs(DEFAULTS) do
if store[k] == nil then
store[k] = (type(v) == "table") and CopyTable(v) or v
end
end
end
-- Point DB at the active store and make sure it has every default key.
local function ResolveActiveDB()
DB = (ADB and ADB.accountWide) and ADB or CDB
FillDefaults(DB)
end
local function AccountWideMacros()
return (ADB and ADB.accountWide) and true or false
end
-- Find a macro by name in a specific bucket (account macros 1..numAccount, character macros
-- at MAX_ACCOUNT_MACROS+1..). GetMacroIndexByName searches account-first and can't target a
-- bucket, which misfires when the same name exists in both; this looks in exactly the bucket
-- the current mode uses, so enabling/disabling account mode stays clean and reversible.
-- Macro-layout constants live in Blizzard_UIParent on live but are nil early and on the 12.1
-- PTR (they moved to the load-on-demand macro UI), so fall back to the fixed game values.
local ACCT_MACRO_CAP = MAX_ACCOUNT_MACROS or 120
local CHAR_MACRO_CAP = MAX_CHARACTER_MACROS or 30
local function FindMacroInBucket(name, perChar)
local numAccount, numChar = GetNumMacros()
if perChar then
for i = 1, (numChar or 0) do
local gi = ACCT_MACRO_CAP + i
if GetMacroInfo(gi) == name then return gi end
end
else
for i = 1, (numAccount or 0) do
if GetMacroInfo(i) == name then return i end
end
end
return nil
end
local function FindManagedMacroIndex(name)
return FindMacroInBucket(name, not AccountWideMacros())
end
-- Create the managed macro in the bucket for the current mode, guarding that bucket's slots.
local function CreateManagedMacro(name, icon, body)
local perChar = not AccountWideMacros()
local numAccount, numChar = GetNumMacros()
local used = perChar and numChar or numAccount
local cap = perChar and CHAR_MACRO_CAP or ACCT_MACRO_CAP
if used and used >= cap then
print(PREFIX .. "no free " .. (perChar and "character" or "account") .. " macro slots for '" .. name .. "'.")
return
end
CreateMacro(name, icon, body, perChar)
end
-- Marker index -> spoken token names, so Smart Open also catches manual callouts.
local MARKER_TOKENS = {
[1] = { "star" }, [2] = { "circle", "coin" }, [3] = { "diamond" }, [4] = { "triangle" },
[5] = { "moon" }, [6] = { "square" }, [7] = { "cross", "x" }, [8] = { "skull" },
}
-- Does an incoming chat message call out YOUR marker? Matches {rtN}, the named token
-- ({skull} etc.) and the rendered icon escape. Pure string parsing, so taint-safe in M+.
local function MessageCallsMyMarker(text)
local m = DB and DB.marker
if not text or issecretvalue(text) or not m or m < 1 or m > 8 then return false end
text = text:lower()
if text:find("{rt" .. m .. "}", 1, true) then return true end
if text:find("raidtargetingicon_" .. m, 1, true) then return true end
for _, name in ipairs(MARKER_TOKENS[m]) do
if text:find("{" .. name .. "}", 1, true) then return true end
end
return false
end
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
-- Chat substitution token for a raid marker; the chat system renders the icon.
local function ChatToken(index)
if index and index >= 1 and index <= 8 then
return "{rt" .. index .. "}"
end
return "no marker"
end
-- Set a texture to a single cell of the 4x4 raid-target sprite sheet.
local function SetMarkerTexture(tex, index)
tex:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
local col = (index - 1) % 4
local row = math.floor((index - 1) / 4)
local left = col * 0.25
local top = row * 0.25
tex:SetTexCoord(left, left + 0.25, top, top + 0.25)
end
-- Where group chat should go right now (nil = not grouped).
local function GroupChannel()
if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then return "INSTANCE_CHAT" end
if IsInRaid() then return "RAID" end
if IsInGroup() then return "PARTY" end
return nil
end
-- Trigger contexts the popup/announce can fire in. Difficulty IDs (DifficultyUtil):
-- 1 Normal, 2 Heroic, 23 Mythic, 8 Mythic Keystone (M+); raids via instanceType.
local CONTEXT_DEFAULTS = { mplus = true, mythic = true, heroic = false, normal = false, raid = false }
local CONTEXT_ORDER = { "mplus", "mythic", "heroic", "normal", "raid" }
local CONTEXT_LABELS = {
mplus = "Mythic+ (keystone)",
mythic = "Mythic dungeon",
heroic = "Heroic dungeon",
normal = "Normal dungeon",
raid = "Raids",
}
-- Which trigger context (if any) the player is currently in.
local function CurrentContextKey()
local inInstance, instanceType = IsInInstance()
if not inInstance then return nil end
if instanceType == "raid" then return "raid" end
if instanceType == "party" then
local diff = select(3, GetInstanceInfo())
if diff == 8 then return "mplus" end
if diff == 23 then return "mythic" end
if diff == 2 then return "heroic" end
if diff == 1 then return "normal" end
end
return nil
end
-- Is this context enabled? Falls back to the default when the player hasn't set it.
local function ContextEnabled(key)
local v = DB.contexts and DB.contexts[key]
if v == nil then v = CONTEXT_DEFAULTS[key] end
return v and true or false
end
-- True when the current instance is one the player enabled for the popup/announce.
local function ShouldTriggerHere()
local key = CurrentContextKey()
return key ~= nil and ContextEnabled(key)
end
-- True when addons may post to chat right now. The Chat restriction
-- (Enum.AddOnRestrictionType.Chat) is OFF before a Mythic+ key starts and flips
-- ON once the key is active, where an addon SendChatMessage is blocked. Checking
-- it lets the ready-check announce go out before the key and stand down after it,
-- with no blocked-action error. C_RestrictedActions is 12.0+; guarded for safety.
local function ChatAllowed()
local C = C_RestrictedActions
if C and C.IsAddOnRestrictionActive and Enum and Enum.AddOnRestrictionType then
return not C.IsAddOnRestrictionActive(Enum.AddOnRestrictionType.Chat)
end
return true
end
-- Interrupt ("kick") spell per class, with spec overrides keyed by specialization
-- ID. Verified against warcraft.wiki.gg/wiki/Interrupt. Missing = spec has no kick.
local INTERRUPTS = {
DEATHKNIGHT = { default = 47528 }, -- Mind Freeze
DEMONHUNTER = { default = 183752 }, -- Disrupt
DRUID = { default = 106839, [102] = 78675 }, -- Skull Bash; Balance = Solar Beam
EVOKER = { default = 351338 }, -- Quell
HUNTER = { default = 147362, [255] = 187707 }, -- Counter Shot; Survival = Muzzle
MAGE = { default = 2139 }, -- Counterspell
MONK = { default = 116705 }, -- Spear Hand Strike
PALADIN = { default = 96231 }, -- Rebuke (all specs)
PRIEST = { [258] = 15487 }, -- Shadow = Silence; Disc/Holy none
ROGUE = { default = 1766 }, -- Kick
SHAMAN = { default = 57994 }, -- Wind Shear
-- Spell Lock is the Felhunter's. Demonology runs a Felguard instead, whose stop
-- is Axe Toss (a stun, which is why the wiki's interrupt list omits it -- no
-- school lockout -- but it is what Demo uses to stop a cast). Cast via Command
-- Demon in the spellbook; "/cast Axe Toss" is the macro form the class guides use.
WARLOCK = { default = 19647, [266] = 89766 }, -- Spell Lock; Demonology = Axe Toss
WARRIOR = { default = 6552 }, -- Pummel
}
-- A kick spell the user typed in beats the table above -- for a pet ability, a
-- spec the defaults get wrong, or anything new Blizzard adds mid-patch. Accepts a
-- spell NAME or a numeric spell ID; blank (the default) means "use my class".
-- ALWAYS per-character (CDB), never the account store, even in account-wide mode:
-- a kick spell belongs to one class, so sharing it would put a warlock's Axe Toss
-- on every alt -- exactly the "flip it back for other toons" chore this replaces.
local function CustomKick()
local raw = CDB and CDB.customKick
if type(raw) ~= "string" then return nil end
raw = raw:match("^%s*(.-)%s*$") or ""
if raw == "" then return nil end
return raw
end
-- The current character's interrupt spell ID (nil if this spec has none).
local function GetMyInterruptID()
local custom = CustomKick()
if custom then
local id = tonumber(custom)
if id then return id end
-- a typed name: resolve it so the ready-check cooldown has an ID to read
return (C_Spell.GetSpellIDForSpellIdentifier and C_Spell.GetSpellIDForSpellIdentifier(custom)) or nil
end
local _, classToken = UnitClass("player")
local data = classToken and INTERRUPTS[classToken]
if not data then return nil end
local specIndex = GetSpecialization()
local specID = specIndex and GetSpecializationInfo(specIndex)
return (specID and data[specID]) or data.default
end
local function GetMyInterruptName()
local custom = CustomKick()
if custom then
local id = tonumber(custom)
-- an ID resolves to its real name; a typed name goes into the macro as typed
if id then return C_Spell.GetSpellName(id) or custom end
return custom
end
local id = GetMyInterruptID()
return id and C_Spell.GetSpellName(id) or nil
end
-- Post the kick marker to group chat. `fromTrigger` = fired by an event (e.g. the
-- ready check) rather than a click; on a trigger we stay silent when we can't send.
-- We never send while chat is locked (an active Mythic+ key), so this never throws
-- a blocked-action error -- whether called from a click or the ready-check trigger.
local function Announce(fromTrigger)
local token = ChatToken(DB.marker)
local msg = (tostring(DB.message or DEFAULTS.message):gsub("%%MARKER%%", token))
local channel = GroupChannel()
if not channel then
if not fromTrigger then print(PREFIX .. msg .. " (not in a group, shown locally)") end
return
end
if not ChatAllowed() then
if not fromTrigger then print(PREFIX .. "chat is locked right now (Mythic+ in progress); announce skipped.") end
return
end
SendChatMessage(msg, channel)
end
--------------------------------------------------------------------------------
-- Macro management (text only; the built-in /tm does the marking)
--------------------------------------------------------------------------------
-- Swap marker numbers (0-8) that are real /tm arguments for a replacement, while
-- leaving any digits inside [condition] brackets alone. Returns the new string and
-- the number of markers replaced.
local function ReplaceTmMarkers(s, replacement)
local out, depth, count = {}, 0, 0
for i = 1, #s do
local c = s:sub(i, i)
if c == "[" then
depth = depth + 1; out[#out + 1] = c
elseif c == "]" then
if depth > 0 then depth = depth - 1 end
out[#out + 1] = c
elseif depth == 0 and c >= "0" and c <= "8" then
out[#out + 1] = replacement; count = count + 1
else
out[#out + 1] = c
end
end
return table.concat(out), count
end
-- Make an existing macro body marker-managed for the "pick existing macro" flow:
-- in its first /tm line swap the marker number(s) for {marker} (adding ~{marker} if that
-- line has no number yet); if there is no /tm line at all, append one. Everything
-- else in the macro is left exactly as the player wrote it.
local function ManageMarkerInBody(body)
body = tostring(body or ""):gsub("[\r\n]+$", "")
if body == "" then return "/tm [@focus] ~{marker}" end
local lines, handled = {}, false
for line in (body .. "\n"):gmatch("(.-)\n") do
if not handled then
local prefix, rest = line:match("^(%s*/[tT][mM])(.*)$")
if prefix and (rest == "" or rest:match("^[%s%[~!0-8]")) then
local newRest, n = ReplaceTmMarkers(rest, "{marker}")
line = prefix .. newRest
if n == 0 then line = line .. " ~{marker}" end
handled = true
end
end
lines[#lines + 1] = line
end
if not handled then
lines[#lines + 1] = "/tm [@focus] ~{marker}"
end
return table.concat(lines, "\n")
end
-- Rewrite the managed macro so {marker} matches the chosen marker. Out of combat
-- only (EditMacro / CreateMacro are blocked in combat).
local function UpdateManagedMacro()
if not (DB and DB.macroEnabled) then return end
if InCombatLockdown() then return end
local name = DB.macroName ~= "" and DB.macroName or DEFAULTS.macroName
local interrupt = GetMyInterruptName() or ""
local body = tostring(DB.macroTemplate or DEFAULT_MACRO)
body = body:gsub("{interrupt}", interrupt):gsub("{marker}", tostring(DB.marker)):gsub("{kick}", tostring(DB.marker))
if body == "" then return end
local idx = FindManagedMacroIndex(name)
if idx then
EditMacro(idx, name, QUESTION_ICON, body)
else
CreateManagedMacro(name, QUESTION_ICON, body)
end
end
-- The fixed "set focus + mark" macro. Synced if it exists; created when create=true.
local function UpdateSetFocusMacro(create)
if InCombatLockdown() then return end
local name = DB.setFocusName ~= "" and DB.setFocusName or DEFAULTS.setFocusName
local interrupt = GetMyInterruptName() or ""
local body = tostring(DB.setFocusTemplate or SET_FOCUS_MACRO):gsub("{interrupt}", interrupt):gsub("{marker}", tostring(DB.marker)):gsub("{kick}", tostring(DB.marker))
local idx = FindManagedMacroIndex(name)
if idx then
EditMacro(idx, name, FOCUS_ICON, body)
elseif create then
CreateManagedMacro(name, FOCUS_ICON, body)
end
end
-- The auto-tab-interrupt macro. Synced if it exists; created when create=true.
local function UpdateAutoTabMacro(create)
if InCombatLockdown() then return end
local name = DB.autoTabName ~= "" and DB.autoTabName or DEFAULTS.autoTabName
local interrupt = GetMyInterruptName() or ""
local body = tostring(DB.autoTabTemplate or AUTOTAB_MACRO):gsub("{interrupt}", interrupt):gsub("{marker}", tostring(DB.marker)):gsub("{kick}", tostring(DB.marker))
local idx = FindManagedMacroIndex(name)
if idx then
EditMacro(idx, name, QUESTION_ICON, body)
elseif create then
CreateManagedMacro(name, QUESTION_ICON, body)
end
end
-- Keep all managed macros in sync with the chosen marker; only edits ones that exist.
local function SyncMacros()
UpdateManagedMacro()
UpdateSetFocusMacro(false)
UpdateAutoTabMacro(false)
end
--------------------------------------------------------------------------------
-- Main popup UI
--------------------------------------------------------------------------------
local function UpdateSelection()
if not frame then return end
for i = 1, 8 do
frame.markerButtons[i].sel:SetShown(DB.marker == i)
end
frame.noneButton.sel:SetShown(DB.marker == 0)
end
local function MakeSelTexture(parent)
local t = parent:CreateTexture(nil, "OVERLAY")
t:SetTexture("Interface\\Buttons\\CheckButtonHilight")
t:SetBlendMode("ADD")
t:SetPoint("TOPLEFT", -3, 3)
t:SetPoint("BOTTOMRIGHT", 3, -3)
t:Hide()
return t
end
local function MakeCheck(parent, label, x, y, get, set)
local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
cb:SetPoint("TOPLEFT", x, y)
cb:SetScript("OnClick", function(self) set(self:GetChecked() and true or false) end)
local fs = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
fs:SetPoint("LEFT", cb, "RIGHT", 2, 0)
fs:SetText(label)
cb.Refresh = function(self) self:SetChecked(get() and true or false) end
return cb
end
-- Refresh a popup's drag-box icons. `target` defaults to the classic popup `frame`.
local function RefreshDragIconsFor(target)
target = target or frame
if not target or not target.dragIcons then return end
local id = GetMyInterruptID()
local spellTex = (id and C_Spell.GetSpellTexture(id)) or 134400
if target.dragIcons.kick then
target.dragIcons.kick:SetTexture(spellTex)
target.dragIcons.kick:SetTexCoord(0.08, 0.92, 0.08, 0.92)
end
if target.dragIcons.autotab then
target.dragIcons.autotab:SetTexture(spellTex)
target.dragIcons.autotab:SetTexCoord(0.08, 0.92, 0.08, 0.92)
end
if target.dragIcons.focus then
if type(FOCUS_ICON) == "number" then
target.dragIcons.focus:SetTexture(FOCUS_ICON)
else
target.dragIcons.focus:SetTexture("Interface\\Icons\\" .. FOCUS_ICON)
end
target.dragIcons.focus:SetTexCoord(0.08, 0.92, 0.08, 0.92)
end
end
local function RefreshDragIcons() RefreshDragIconsFor(frame) end
-- Pick up a managed macro onto the cursor (creates/syncs it first). File-level so both
-- the classic and the branded popups can drop macros onto the action bars.
local function KAPickupSlot(nameKey, defName, updateFn)
if InCombatLockdown() then return end
local name = (DB[nameKey] and DB[nameKey] ~= "") and DB[nameKey] or defName
updateFn(true)
local idx = FindManagedMacroIndex(name)
if idx then PickupMacro(idx) end
end
local function CreateUI()
if frame then return frame end
frame = CreateFrame("Frame", "KickAssistFrame", UIParent, "BackdropTemplate")
frame:SetSize(300, 560)
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
frame:SetClampedToScreen(true)
frame:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = false, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetBackdropColor(0.04, 0.04, 0.04, 0.9)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", function(self)
self:StopMovingOrSizing()
local p, _, rp, x, y = self:GetPoint()
DB.point = { p, rp, x, y }
end)
tinsert(UISpecialFrames, "KickAssistFrame")
local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -4, -4)
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
title:SetPoint("TOP", 0, -16)
title:SetText("Kick Assist")
local instr = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
instr:SetPoint("TOP", title, "BOTTOM", 0, -6)
instr:SetText("Pick your kick marker")
-- 8 marker buttons, 4 per row.
frame.markerButtons = {}
for i = 1, 8 do
local btn = CreateFrame("Button", nil, frame)
btn:SetSize(40, 40)
local col = (i - 1) % 4
local row = math.floor((i - 1) / 4)
btn:SetPoint("TOPLEFT", 55 + col * 50, -58 - row * 50)
local icon = btn:CreateTexture(nil, "ARTWORK")
icon:SetAllPoints()
SetMarkerTexture(icon, i)
btn.sel = MakeSelTexture(btn)
btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
btn:SetScript("OnClick", function()
DB.marker = i
UpdateSelection()
RefreshDragIcons()
SyncMacros()
Announce()
end)
btn:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(MARKER_NAMES[i])
GameTooltip:Show()
end)
btn:SetScript("OnLeave", GameTooltip_Hide)
frame.markerButtons[i] = btn
end
-- "No Marker" choice.
local none = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
none:SetSize(100, 22)
none:SetPoint("TOP", 0, -162)
none:SetText("No Marker")
none.sel = MakeSelTexture(none)
none:SetScript("OnClick", function()
DB.marker = 0
UpdateSelection()
RefreshDragIcons()
SyncMacros()
end)
frame.noneButton = none
-- Toggles.
frame.readyCB = MakeCheck(frame, "Show on ready check", 22, -196,
function() return DB.showOnReadyCheck end,
function(v) DB.showOnReadyCheck = v end)
frame.smartCB = MakeCheck(frame, "Smart open (only on a marker clash)", 22, -220,
function() return DB.smartOpen end,
function(v) DB.smartOpen = v end)
frame.announceCB = MakeCheck(frame, "Announce on ready check", 22, -244,
function() return DB.announceOnReadyCheck end,
function(v) DB.announceOnReadyCheck = v end)
-- Editable announce message. %MARKER% is replaced with your marker icon.
local msgLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
msgLabel:SetPoint("TOPLEFT", 22, -274)
msgLabel:SetText("Message (%MARKER% = your icon):")
local msgBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
msgBox:SetSize(246, 20)
msgBox:SetPoint("TOPLEFT", 28, -292)
msgBox:SetAutoFocus(false)
msgBox:SetText(DB.message or DEFAULTS.message)
msgBox:SetScript("OnEscapePressed", msgBox.ClearFocus)
msgBox:SetScript("OnEnterPressed", function(self)
DB.message = self:GetText()
self:ClearFocus()
end)
msgBox:SetScript("OnEditFocusLost", function(self) DB.message = self:GetText() end)
frame.msgBox = msgBox
-- Announce: always allowed from a click.
local announce = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
announce:SetSize(170, 26)
announce:SetPoint("TOP", 0, -324)
announce:SetText("Announce to Group")
announce:SetScript("OnClick", function()
DB.message = msgBox:GetText()
Announce()
end)
-- Edit Macro + Options sit side by side on one row.
local macroBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
macroBtn:SetSize(140, 24)
macroBtn:SetPoint("TOP", -73, -358)
macroBtn:SetText("Edit Macro...")
macroBtn:SetScript("OnClick", function() KickAssist_ShowMacroEditor() end)
-- Opens the branded Kick Assist settings window.
local optionsBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
optionsBtn:SetSize(140, 24)
optionsBtn:SetPoint("TOP", 73, -358)
optionsBtn:SetText("Options...")
optionsBtn:SetScript("OnClick", function() OpenKAOptions() end)
-- Drag-to-bars: two ready macros new users can drop straight onto their bars.
local dragHeader = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
dragHeader:SetPoint("TOP", 0, -384)
dragHeader:SetText("New? Drag a macro to your action bar:")
frame.dragIcons = {}
local function PickupSlot(nameKey, defName, updateFn)
if InCombatLockdown() then return end
local name = (DB[nameKey] and DB[nameKey] ~= "") and DB[nameKey] or defName
updateFn(true)
local idx = FindManagedMacroIndex(name)
if idx then PickupMacro(idx) end
end
local function MakeDragBox(xOff, labelText, desc, key, pickup)
local box = CreateFrame("Button", nil, frame, "BackdropTemplate")
box:SetSize(40, 40)
box:SetPoint("TOP", xOff, -402)
box:RegisterForDrag("LeftButton")
box:RegisterForClicks("LeftButtonUp")
box:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 2 })
box:SetBackdropBorderColor(1, 0.82, 0, 0.9)
local ic = box:CreateTexture(nil, "ARTWORK")
ic:SetPoint("TOPLEFT", 2, -2)
ic:SetPoint("BOTTOMRIGHT", -2, 2)
box:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
frame.dragIcons[key] = ic
box:SetScript("OnDragStart", pickup)
box:SetScript("OnClick", pickup)
box:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(labelText)
GameTooltip:AddLine(desc, 1, 1, 1, true)
GameTooltip:AddLine("Drag onto an action bar, or click then a bar slot.", 0.6, 0.6, 0.6, true)
GameTooltip:Show()
end)
box:SetScript("OnLeave", GameTooltip_Hide)
local lbl = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
lbl:SetPoint("TOP", box, "BOTTOM", 0, -4)
lbl:SetText(labelText)
return box
end
MakeDragBox(-78, "Focus + Kick", "Interrupts your focus. First press focuses your target, then re-press to kick.", "kick", function()
DB.macroEnabled = true
PickupSlot("macroName", DEFAULTS.macroName, UpdateManagedMacro)
end)
MakeDragBox(0, "Set Focus", "Sets your current target as your focus and marks it.", "focus", function()
PickupSlot("setFocusName", DEFAULTS.setFocusName, UpdateSetFocusMacro)
end)
MakeDragBox(78, "Tab Kick", "Interrupts the nearest casting enemy, then returns to your target.", "autotab", function()
PickupSlot("autoTabName", DEFAULTS.autoTabName, UpdateAutoTabMacro)
end)
RefreshDragIcons()
-- Interrupt Alert: surfaced here so it's discoverable. The enable toggle lives
-- here; the sound/TTS/channel choices are in Options.
local alertHeader = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
alertHeader:SetPoint("TOPLEFT", 22, -464)
alertHeader:SetText("Interrupt Alert")
frame.alertCB = MakeCheck(frame, "Play a sound when your focus casts", 22, -482,
function() return DB.interruptAlert end,
function(v) DB.interruptAlert = v; SyncInterruptAlert() end)
local alertHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
alertHint:SetPoint("TOPLEFT", 26, -508)
alertHint:SetPoint("RIGHT", frame, "RIGHT", -16, 0)
alertHint:SetJustifyH("LEFT")
alertHint:SetText("Fires when your focus casts and your kick is ready. Pick the sound, TTS, and channel in Options.")
local p = DB.point or DEFAULTS.point
frame:ClearAllPoints()
frame:SetPoint(p[1], UIParent, p[2], p[3], p[4])
frame:Hide()
return frame
end
-- Re-read the active DB into the main window's widgets (no-op if it isn't built yet).
local function RefreshMainWindow()
if not frame then return end
UpdateSelection()
frame.readyCB:Refresh()
frame.smartCB:Refresh()
frame.announceCB:Refresh()
frame.alertCB:Refresh()
frame.msgBox:SetText(DB.message or DEFAULTS.message)
RefreshDragIcons()
end
local function ShowUI(fromEvent)
if InCombatLockdown() then
-- Only tell the user when THEY asked (a trigger like a mid-key ready check stays silent).
if not fromEvent then print(PREFIX .. "in combat, not opening (this is an out-of-combat tool).") end
return
end
if DB.classicLook then
CreateUI(); RefreshMainWindow(); SyncMacros(); frame:Show(); frame:Raise()
else
CreateArcPopup(); RefreshArcPopup(); SyncMacros(); arcPopup:Show(); arcPopup:Raise()
end
end
-- Refresh whatever UI is currently built (used after the account-wide toggle swaps the store).
local function RefreshOpenUI()
RefreshMainWindow()
if RefreshArcPopup then RefreshArcPopup() end
if RefreshKAOptions then RefreshKAOptions() end
end
-- Global opener so ArcUI (or any addon) can open the window.
KickAssist_Show = ShowUI
--------------------------------------------------------------------------------
-- Macro editor UI
--------------------------------------------------------------------------------
local editorSlot = "kick" -- which macro the editor edits: "kick" or "focus"
local function MacroNoteText()
return "{interrupt} fills in your interrupt (now: " ..
(GetMyInterruptName() or "none for this spec") .. "); {marker} fills in your marker."
end
function KickAssist_ShowMacroEditor()
if macroFrame then
macroFrame.note:SetText(MacroNoteText())
macroFrame:Show()
macroFrame:Raise()
macroFrame.ReloadFields()
return
end
macroFrame = CreateFrame("Frame", "KickAssistMacroFrame", UIParent, "BackdropTemplate")
macroFrame:SetSize(420, 416)
macroFrame:SetFrameStrata("DIALOG")
macroFrame:SetToplevel(true)
macroFrame:SetClampedToScreen(true)
macroFrame:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = false, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
macroFrame:SetBackdropColor(0.04, 0.04, 0.04, 0.9)
macroFrame:SetMovable(true)
macroFrame:EnableMouse(true)
macroFrame:RegisterForDrag("LeftButton")
macroFrame:SetScript("OnDragStart", macroFrame.StartMoving)
macroFrame:SetScript("OnDragStop", function(self)
self:StopMovingOrSizing()
local pt, _, rp, x, y = self:GetPoint()
DB.macroPoint = { pt, rp, x, y }
end)
tinsert(UISpecialFrames, "KickAssistMacroFrame")
local close = CreateFrame("Button", nil, macroFrame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -4, -4)
local title = macroFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
title:SetPoint("TOP", 0, -16)
title:SetText("Edit Macro")
local nameLabel = macroFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
nameLabel:SetPoint("TOPLEFT", 24, -80)
nameLabel:SetText("Macro name:")
local nameBox = CreateFrame("EditBox", nil, macroFrame, "InputBoxTemplate")
nameBox:SetSize(130, 20)
nameBox:SetPoint("LEFT", nameLabel, "RIGHT", 10, 0)
nameBox:SetAutoFocus(false)
nameBox:SetScript("OnEscapePressed", nameBox.ClearFocus)
nameBox:SetScript("OnEnterPressed", nameBox.ClearFocus)
macroFrame.nameBox = nameBox
local note = macroFrame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
note:SetPoint("TOPLEFT", 24, -106)
note:SetPoint("TOPRIGHT", -24, -106)
note:SetJustifyH("LEFT")
macroFrame.note = note
note:SetText(MacroNoteText())
local bodyLabel = macroFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
bodyLabel:SetPoint("TOPLEFT", 24, -132)
bodyLabel:SetText("Macro body ({interrupt} and {marker} are filled in for you):")
local scroll = CreateFrame("ScrollFrame", "KickAssistMacroScroll", macroFrame, "InputScrollFrameTemplate")
scroll:SetSize(372, 96)
scroll:SetPoint("TOPLEFT", 24, -152)
scroll.EditBox:SetMultiLine(true)
scroll.EditBox:SetMaxLetters(255)
scroll.EditBox:SetWidth(360)
scroll.EditBox:SetFontObject(ChatFontNormal)
if scroll.CharCount then scroll.CharCount:Hide() end
macroFrame.scroll = scroll
-- Frame border around the scroll for clarity.
local border = CreateFrame("Frame", nil, macroFrame, "BackdropTemplate")
border:SetPoint("TOPLEFT", scroll, "TOPLEFT", -6, 6)
border:SetPoint("BOTTOMRIGHT", scroll, "BOTTOMRIGHT", 22, -6)
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
edgeSize = 12,
})
-- Which macro this editor is editing.
local function CurrentName()
local cfg = SLOT_CFG[editorSlot]
local n = DB[cfg.nameKey]
return (n and n ~= "") and n or cfg.defName
end
local function CurrentTemplate()
local cfg = SLOT_CFG[editorSlot]
return DB[cfg.tmplKey] or cfg.defBody
end
local markerBtn -- created below; only shown for the kick/focus slots (Tab Kick has no marker)
local function ReloadFields()
nameBox:SetText(CurrentName())
nameBox:SetCursorPosition(0)
scroll.EditBox:SetText(CurrentTemplate())
scroll.EditBox:SetCursorPosition(0)
scroll:SetVerticalScroll(0)
if markerBtn then markerBtn:SetShown(editorSlot == "kick" or editorSlot == "focus") end
end
local function ApplySlot(name, template)
local cfg = SLOT_CFG[editorSlot]
DB[cfg.nameKey] = name
DB[cfg.tmplKey] = template
if editorSlot == "kick" then
DB.macroEnabled = true
UpdateManagedMacro()
elseif editorSlot == "focus" then
UpdateSetFocusMacro(true)
else
UpdateAutoTabMacro(true)
end
end
macroFrame.ReloadFields = ReloadFields
-- Slot selector: pick which macro to edit.
local editLabel = macroFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
editLabel:SetPoint("TOPLEFT", 24, -50)
editLabel:SetText("Editing:")
local slotDrop = CreateFrame("DropdownButton", nil, macroFrame, "WowStyle1DropdownTemplate")
slotDrop:SetSize(160, 22)
slotDrop:SetPoint("LEFT", editLabel, "RIGHT", 10, 0)
local function SlotIsSelected(sk) return editorSlot == sk end
local function SlotSetSelected(sk) editorSlot = sk; C_Timer.After(0, ReloadFields) end
slotDrop:SetupMenu(function(dropdown, root)
for _, key in ipairs(SLOT_ORDER) do
root:CreateRadio(SLOT_CFG[key].label, SlotIsSelected, SlotSetSelected, key)
end
end)
-- Pick an existing macro to load it. Built as a plain ScrollFrame + buttons rather
-- than a WowStyle1Dropdown: the dropdown's ScrollBox compares a secret content
-- extent inside instances and throws under our taint. A plain ScrollFrame (same