Repository navigation
Expand file tree
/
Copy pathMageFocusMagic.lua
More file actions
940 lines (850 loc) · 32.2 KB
/
Copy pathMageFocusMagic.lua
File metadata and controls
940 lines (850 loc) · 32.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
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
--[[ MageFocusMagic - who trades Focus Magic with whom (WoW 3.3.5a)
Every mage in the group is put into a trade: pairs where possible, and a
ring of three for the odd one out. The plan is built from the mage names
sorted alphabetically, so every mage running this addon sees exactly the
same plan without anyone having to agree on anything. Raid indices are not
used for that: they differ between a party and a raid, and they shift as
people join and leave.
A Focus Magic button sits on screen; clicking it opens the panel with one
row per trade. In the panel, clicking a name whispers that mage the order,
clicking an icon casts Focus Magic on them. ]]
MageFocusMagic = {}
local MFM = MageFocusMagic
local FOCUS_MAGIC = 54646
local FM_NAME = GetSpellInfo(FOCUS_MAGIC) or "Focus Magic"
local FM_ICON = select(3, GetSpellInfo(FOCUS_MAGIC))
or "Interface\\Icons\\Spell_Arcane_StudentOfMagic"
-- The tag names the addon in the one message that asks for something; the
-- order itself goes out plain, so it reads like a line somebody typed.
local TAG = "[MageFocusMagic]"
local ORDER = "Focus Magic order: "
local FLAT = "Interface\\Buttons\\WHITE8X8"
local MAGE_COLOR = { 0.41, 0.80, 0.94 }
local SEP_W = 18 -- the "<>" or ">" between two blocks
local ROW_GAP = 5
local PAD = 8
-- A block is its icon plus room for a name. Every block in the panel is the
-- same width, so the rows line up into columns, and a name longer than that is
-- cut short with "..." rather than widening its row.
local function blockH() return MFM.db.iconSize end
local function nameW() return MFM.db.iconSize * 4 end
-- One whisper per mage per 10 seconds, counted for each of them on their own,
-- so three mages in a row all get the whole thing. The raid announce has its
-- own cooldown: whispering somebody does not hold the announce back, and the
-- other way round.
local WHISPER_COOLDOWN = 10
local ANNOUNCE_COOLDOWN = 10
MFM.defaults = {
point = { "CENTER", "CENTER", 0, 0 }, -- the button
locked = false,
buttonSize = 32,
iconSize = 22, -- a block in the panel is this tall
anchor = "auto", -- which way the panel opens, see ANCHORS
}
-- Where the panel opens from the button, the same choices RaidLeadKit offers.
-- "auto" picks the corner that keeps the panel away from the screen edges.
MFM.ANCHORS = {
{ key = "auto", label = "Automatic (away from the screen edges)" },
{ key = "below", label = "Below, growing right", "TOPLEFT", "BOTTOMLEFT", 0, -2 },
{ key = "belowLeft", label = "Below, growing left", "TOPRIGHT", "BOTTOMRIGHT", 0, -2 },
{ key = "above", label = "Above, growing right", "BOTTOMLEFT", "TOPLEFT", 0, 2 },
{ key = "aboveLeft", label = "Above, growing left", "BOTTOMRIGHT", "TOPRIGHT", 0, 2 },
{ key = "right", label = "To the right", "TOPLEFT", "TOPRIGHT", 2, 0 },
{ key = "left", label = "To the left", "TOPRIGHT", "TOPLEFT", -2, 0 },
}
MFM.mages = {} -- sorted list of { name, unit, buffed, mine }
MFM.groups = {} -- list of { kind = "pair"|"chain", [1..n] = name }
MFM.byName = {} -- name -> its entry in mages, built with the plan
MFM.giver = {} -- name -> the mage the plan has casting on them
MFM.sampleByName = {} -- the same two for the stand-ins, built where they are drawn
MFM.sampleGiver = {}
MFM.lastWhisper = {} -- name -> when we last whispered them
MFM.lastAnnounce = 0 -- the announce, on its own cooldown
local function print_(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff69ccf0MageFocusMagic|r: " .. tostring(msg))
end
-- ---------------------------------------------------------------------------
-- the plan
-- ---------------------------------------------------------------------------
-- every token string is built once here, so a scan allocates none of them
local RAID_TOKENS, PARTY_TOKENS = {}, { "player" }
for i = 1, 40 do RAID_TOKENS[i] = "raid" .. i end
for i = 1, 4 do PARTY_TOKENS[i + 1] = "party" .. i end
local function byName(a, b) return a.name < b.name end
-- Offline mages are left out: they cannot cast anything, and including them
-- would shift everyone else's partner the moment they log back in.
--
-- The entry tables are reused between scans. This runs on every roster change
-- for the whole raid, and there is no reason to hand the collector a fresh set
-- of tables each time.
local function collectMages(out)
local raid = GetNumRaidMembers()
local tokens = raid > 0 and RAID_TOKENS or PARTY_TOKENS
local n = raid > 0 and raid or (GetNumPartyMembers() + 1)
local found = 0
for i = 1, n do
local unit = tokens[i]
local _, class = UnitClass(unit)
if class == "MAGE" and UnitIsConnected(unit) then
local name = UnitName(unit)
if name then
found = found + 1
local e = out[found]
if not e then e = {} out[found] = e end
e.name, e.unit, e.sample = name, unit, nil
e.buffed, e.mine, e.caster = nil, nil, nil
end
end
end
for i = #out, found + 1, -1 do out[i] = nil end
table.sort(out, byName)
return out
end
-- Pairs are the best trade: both mages feed each other. An odd mage out cannot
-- be paired, so the last three form a ring instead - A gives to B, B to C,
-- C back to A. Three is the smallest ring, so it is the only one ever needed.
-- The group tables are reused too; a pair has to clear the third slot a ring
-- may have left there.
local function putGroup(out, at, kind, a, b, c)
local g = out[at]
if not g then g = {} out[at] = g end
g.kind, g[1], g[2], g[3] = kind, a, b, c
return g
end
local function buildGroups(mages, out)
local n = #mages
local made = 0
if n >= 2 then
local lastPair = (n % 2 == 0) and n or (n - 3)
local i = 1
while i <= lastPair do
made = made + 1
putGroup(out, made, "pair", mages[i].name, mages[i + 1].name, nil)
i = i + 2
end
if i <= n then
made = made + 1
putGroup(out, made, "chain", mages[i].name, mages[i + 1].name, mages[i + 2].name)
end
end
for i = #out, made + 1, -1 do out[i] = nil end
return out
end
local function groupOf(groups, name)
for _, g in ipairs(groups) do
for _, member in ipairs(g) do
if member == name then return g end
end
end
return nil
end
---One group as a line of text: "A <> B", or "A > B > C > A" for a ring.
local function groupText(g)
if g.kind == "pair" then
return g[1] .. " <> " .. g[2]
end
return table.concat(g, " > ") .. " > " .. g[1]
end
-- Who is in the group only changes when the roster does, and the roster says
-- so with an event. Between those, a refresh is just the buff read below: one
-- UnitBuff per mage, a handful of calls even in a full raid.
---Every mage is read, not only your own: the button speaks for all of them.
function MFM:Rebuild()
if self.rosterDirty then
self.rosterDirty = false
collectMages(self.mages)
buildGroups(self.mages, self.groups)
self:MapPlan()
end
for _, m in ipairs(self.mages) do
local name, _, _, _, _, _, _, caster = UnitBuff(m.unit, FM_NAME)
m.buffed = name ~= nil
-- the border is about the one Focus Magic that is yours to keep up,
-- so whose it is matters, not just that there is one
m.mine = caster == "player"
-- The game only names the caster while it can see them, so this is
-- nil for a Focus Magic cast by somebody out of range. Unknown is
-- not the same as wrong, and is never drawn as a mistake.
m.caster = caster and UnitName(caster) or nil
end
end
-- What a name stands for, and who the plan has casting on whom. Both change
-- only with the roster, so they are built there instead of once per draw.
function MFM:MapPlan()
local byName, giver = self.byName, self.giver
wipe(byName)
wipe(giver)
for _, m in ipairs(self.mages) do byName[m.name] = m end
for _, g in ipairs(self.groups) do
for i, name in ipairs(g) do
giver[g[i % #g + 1]] = name
end
end
end
---Focus Magic reaches 30 yards; a mage the client cannot even see is too far.
function MFM:InRange(unit)
if not unit then return true end
-- there is nothing to fall back on but sight if the client has no such call
if not IsSpellInRange then return UnitIsVisible(unit) and true or false end
local r = IsSpellInRange(FM_NAME, unit)
if r == 1 then return true end
if r == 0 then return false end
-- nil: the client will not judge a unit it cannot see
return UnitIsVisible(unit) and true or false
end
-- A mage is off the plan with no Focus Magic at all, or with one from a mage
-- the plan did not name. A caster the client cannot see is unknown, not wrong,
-- the same way the panel's blue border treats it.
function MFM:OffPlan(m)
if not m.buffed then return true end
local giver = self.giver[m.name]
return (m.caster and giver and m.caster ~= giver) or false
end
---Your own Focus Magic first, whether you can reach it, then everyone else's.
function MFM:Status()
-- one mage in the group is no plan, and nothing anybody does can be off it
if #self.groups == 0 then return false, true, false end
local target = self:MyTarget()
local mine = target and self.byName[target]
local owed = (mine and not mine.mine) or false
local reachable = (not owed) or self:InRange(mine.unit)
local offPlan = false
for _, m in ipairs(self.mages) do
-- your own target is the yellow border's business, not the white one's
if m.name ~= target and self:OffPlan(m) then
offPlan = true
break
end
end
return owed, reachable, offPlan
end
-- Opened unlocked with nobody to show, the panel fills itself with a raid of
-- seven: two pairs and a ring, three rows, so its size and place can be judged
-- for what a raid will look like. Nothing here is clickable, and none of it
-- ever reaches chat.
local SAMPLE = { "Mage1", "Mage2", "Mage3", "Mage4", "Mage5", "Mage6", "Mage7" }
-- Built once and kept aside: the stand-ins never touch the real roster, so
-- nothing has to be undone when a mage finally shows up.
function MFM:SampleData()
if not self.sampleMages then
self.sampleMages, self.sampleGroups = {}, {}
for i, name in ipairs(SAMPLE) do
-- a couple of them without the buff, to show the borders
self.sampleMages[i] = { name = name, sample = true,
buffed = (i % 3) ~= 0, mine = false }
end
buildGroups(self.sampleMages, self.sampleGroups)
end
return self.sampleMages, self.sampleGroups
end
---The mage the plan has you casting Focus Magic on: the next one in your group.
function MFM:MyTarget()
local me = UnitName("player")
local g = groupOf(self.groups, me)
if not g then return nil end
for i, name in ipairs(g) do
if name == me then return g[i % #g + 1] end
end
return nil
end
-- ---------------------------------------------------------------------------
-- chat
-- ---------------------------------------------------------------------------
-- Two whispers that only make sense together: what is being asked of them,
-- and the order itself. They always go out as a pair, so nobody is left with
-- a bare list of names and no idea who sent it or why.
function MFM:Whisper(name)
if name == UnitName("player") or self.sample then return end
local g = groupOf(self.groups, name)
if not g then return end
local t = GetTime()
local last = self.lastWhisper[name]
if last and t - last < WHISPER_COOLDOWN then
print_(string.format("%s was whispered already, %ds left",
name, math.ceil(WHISPER_COOLDOWN - (t - last))))
return
end
self.lastWhisper[name] = t
SendChatMessage(TAG .. ": please use this Focus Magic order.", "WHISPER", nil, name)
SendChatMessage(ORDER .. groupText(g), "WHISPER", nil, name)
end
---Every group in one line, for the announce.
local planParts = {}
function MFM:PlanText()
local parts = planParts
wipe(parts)
for i, g in ipairs(self.groups) do
parts[i] = groupText(g)
end
-- "||" prints as one "|": a single one starts an escape code and the game
-- refuses the whole message
return table.concat(parts, " || ")
end
function MFM:Announce()
if #self.groups == 0 then return end
if self.sample then
print_("these are placeholders - nothing to announce yet")
return
end
local t = GetTime()
if t - self.lastAnnounce < ANNOUNCE_COOLDOWN then
print_(string.format("announce is on cooldown, %ds left",
math.ceil(ANNOUNCE_COOLDOWN - (t - self.lastAnnounce))))
return
end
self.lastAnnounce = t
SendChatMessage(ORDER .. self:PlanText(),
GetNumRaidMembers() > 0 and "RAID" or "PARTY")
end
-- ---------------------------------------------------------------------------
-- player blocks
-- ---------------------------------------------------------------------------
-- A block is an icon and a name side by side. The icon is a secure button, the
-- only way an addon may cast: the game itself reads the macro on the click.
-- The name next to it is a plain button that whispers. They never overlap, so
-- a click always goes where it looks like it goes.
function MFM:GetBlock(i)
local b = self.blocks[i]
if b then return b end
-- A block carries a secure button, and the game lets nobody build, size or
-- place one of those in combat. The pool is grown between fights instead
-- (see Prewarm), so a panel opened mid-fight finds its blocks ready.
if InCombatLockdown() then return nil end
b = CreateFrame("Frame", nil, self.panel)
b:SetBackdrop({ bgFile = FLAT, edgeFile = FLAT, edgeSize = 1 })
b:SetBackdropBorderColor(0, 0, 0, 1)
local icon = CreateFrame("Button", "MageFocusMagicIcon" .. i, b, "SecureActionButtonTemplate")
icon:SetPoint("TOPLEFT", 0, 0)
icon:RegisterForClicks("AnyUp")
icon:SetAttribute("type", "macro")
icon.tex = icon:CreateTexture(nil, "ARTWORK")
icon.tex:SetPoint("TOPLEFT", 1, -1)
icon.tex:SetPoint("BOTTOMRIGHT", -1, 1)
icon.tex:SetTexture(FM_ICON)
icon.tex:SetTexCoord(0.07, 0.93, 0.07, 0.93)
icon:SetScript("OnEnter", function(s)
if not s.mage then return end
GameTooltip:SetOwner(s, "ANCHOR_RIGHT")
GameTooltip:SetText("Cast " .. FM_NAME .. " on " .. s.mage)
if s.far then GameTooltip:AddLine("Out of range", 1, 0.3, 0.3) end
GameTooltip:Show()
end)
icon:SetScript("OnLeave", function() GameTooltip:Hide() end)
b.icon = icon
-- bright frame around an icon whose Focus Magic is missing; the colour says
-- whose job it is (see BORDER_MINE / BORDER_ANY)
b.border = CreateFrame("Frame", nil, b)
b.border:SetPoint("TOPLEFT", icon, "TOPLEFT", -1, 1)
b.border:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 1, -1)
b.border:SetBackdrop({ edgeFile = FLAT, edgeSize = 2 })
b.border:SetFrameLevel(icon:GetFrameLevel() + 1)
b.border:Hide()
local nameBtn = CreateFrame("Button", nil, b)
nameBtn:SetPoint("TOPLEFT", icon, "TOPRIGHT", 0, 0)
nameBtn:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 0, 0)
nameBtn:RegisterForClicks("LeftButtonUp")
nameBtn.text = nameBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
-- pinned to both edges and one line high: a long name is cut short with
-- "..." instead of spilling out of the block
nameBtn.text:SetPoint("LEFT", 4, 0)
nameBtn.text:SetPoint("RIGHT", -4, 0)
nameBtn.text:SetHeight(10)
nameBtn.text:SetJustifyH("CENTER")
nameBtn:SetScript("OnClick", function(s)
if s.mage then MFM:Whisper(s.mage) end
end)
nameBtn:SetScript("OnEnter", function(s) if s.mage then s.hl:Show() end end)
nameBtn:SetScript("OnLeave", function(s) s.hl:Hide() end)
nameBtn.hl = nameBtn:CreateTexture(nil, "BACKGROUND")
nameBtn.hl:SetAllPoints()
nameBtn.hl:SetTexture(FLAT)
nameBtn.hl:SetVertexColor(1, 1, 1, 0.10)
nameBtn.hl:Hide()
b.nameBtn = nameBtn
self.blocks[i] = b
self:SizeBlock(b)
return b
end
---The icon size option reaches a block here, and only when it changed.
function MFM:SizeBlock(b)
local h = blockH()
if b.sized == h then return end
-- the icon inside is secure: a new row height has to wait for the fight to
-- end, and the next draw after it applies this on its own
if InCombatLockdown() then return end
b.sized = h
b:SetHeight(h)
b:SetWidth(h + nameW())
b.icon:SetWidth(h)
b.icon:SetHeight(h)
end
-- Your own block sits a shade lighter than the others - enough to find
-- yourself in the row without a label saying so.
local BG_OTHER = { 0.07, 0.07, 0.09, 0.85 }
local BG_SELF = { 0.16, 0.17, 0.22, 0.9 }
local BORDER_MINE = { 1, 0.85, 0.1 } -- your Focus Magic is missing on your own target
local BORDER_ANY = { 1, 1, 1 } -- somebody else's is missing
local BORDER_WRONG = { 0.3, 0.6, 1 } -- buffed, but not by the mage the plan named
-- Red on the icon of a mage you owe and cannot reach, the way the game's own
-- action bars say out of range.
local TINT_FAR = { 1, 0.35, 0.35 }
local TINT_NONE = { 1, 1, 1 }
-- A block is drawn several times a second while the panel is open, and almost
-- nothing about it changes between draws. Every setter below is guarded by the
-- value the block already shows.
function MFM:SetBlock(b, m, isMe, owed, giver, unreached)
self:SizeBlock(b)
-- a stand-in is a picture of a mage: no whisper, no tooltip, no cast
local real = (not isMe) and (not m.sample) and m.name or nil
b.nameBtn.mage = real
if b.cName ~= m.name then
b.cName = m.name
b.nameBtn.text:SetText(m.name)
b.nameBtn.text:SetTextColor(MAGE_COLOR[1], MAGE_COLOR[2], MAGE_COLOR[3])
b.nameBtn.hl:Hide()
end
local bg = isMe and BG_SELF or BG_OTHER
if b.cBg ~= bg then
b.cBg = bg
b:SetBackdropColor(bg[1], bg[2], bg[3], bg[4])
end
-- The border carries the whole status; the icon itself stays as it is.
-- Yellow is the one you can do something about right now: your Focus Magic
-- is not on the mage the plan gave you. White is any other mage standing
-- there without one, which is their partner's job, not yours.
local c
if owed then
c = BORDER_MINE
elseif not m.buffed then
c = BORDER_ANY
elseif m.caster and giver and m.caster ~= giver then
-- a Focus Magic is up, but from the wrong mage: the trade has drifted
-- out of the plan, and somebody is now covering two people
c = BORDER_WRONG
end
if b.cBorder ~= c then
b.cBorder = c
if c then
b.border:SetBackdropBorderColor(c[1], c[2], c[3], 1)
b.border:Show()
else
b.border:Hide()
end
end
-- Yellow says you owe it, red says you cannot reach them from here. The
-- tooltip spells it out, for when a red icon is not enough.
local tint = unreached and TINT_FAR or TINT_NONE
if b.cTint ~= tint then
b.cTint = tint
b.icon.tex:SetVertexColor(tint[1], tint[2], tint[3])
end
b.icon.far = unreached
-- Casting is protected: the macro may only be written out of combat, so in
-- a fight a block keeps the aim it had when the fight started. Aiming at
-- yourself is pointless, so your own icon is left doing nothing.
local aim = real or ""
if b.icon.aimed ~= aim and not InCombatLockdown() then
b.icon.aimed = aim
b.icon:SetAttribute("macrotext",
real and ("/cast [target=" .. real .. "] " .. FM_NAME) or "")
end
b.icon.mage = real
b:Show()
end
-- A separator is a pooled font string, and the ring's trailing one is wider
-- than the rest, so its width and text are set per use - but only when they
-- differ from what it already shows.
function MFM:SetSep(i, text, wide)
local s = self.seps[i]
if not s then
s = self.panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
self.seps[i] = s
end
local w = wide and (SEP_W + nameW()) or SEP_W
if s.cW ~= w then
s.cW = w
s:SetWidth(w)
s:SetHeight(blockH())
s:SetJustifyH(wide and "LEFT" or "CENTER")
end
if s.cText ~= text then
s.cText = text
s:SetText(text)
end
s:Show()
return s
end
-- ---------------------------------------------------------------------------
-- button and panel
-- ---------------------------------------------------------------------------
-- The panel opens the way the options say. On "auto" it takes the side of the
-- button that has room, so a button parked near an edge does not push its
-- panel off screen.
local function anchorPanel(button, panel)
local a
for _, x in ipairs(MFM.ANCHORS) do
if x.key == MFM.db.anchor then a = x end
end
panel:ClearAllPoints()
if a and a[1] then
panel:SetPoint(a[1], button, a[2], a[3], a[4])
return
end
local x, y = button:GetCenter()
local right = x and x > UIParent:GetWidth() / 2
local up = y and y < UIParent:GetHeight() / 2
local h = right and "RIGHT" or "LEFT"
panel:SetPoint((up and "BOTTOM" or "TOP") .. h, button,
(up and "TOP" or "BOTTOM") .. h, 0, up and 2 or -2)
end
function MFM:CreateDisplay()
-- the button
local b = CreateFrame("Button", "MageFocusMagicButton", UIParent)
-- LOW: the game's own windows (character, bags, spellbook) open over it
b:SetFrameStrata("LOW")
b:SetClampedToScreen(true)
b:SetMovable(true)
b:SetWidth(self.db.buttonSize)
b:SetHeight(self.db.buttonSize)
b:RegisterForDrag("LeftButton")
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
b:SetScript("OnDragStart", function(s)
if not MFM.db.locked then
MFM.panel:Hide()
s:StartMoving()
end
end)
b:SetScript("OnDragStop", function(s)
s:StopMovingOrSizing()
local pt, _, rp, x, y = s:GetPoint()
MFM.db.point = { pt, rp, x, y }
end)
local icon = b:CreateTexture(nil, "ARTWORK")
icon:SetAllPoints()
icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
icon:SetTexture(FM_ICON)
b.tex = icon
-- the same borders the panel puts on a block, so the state of the plan is
-- readable with the panel closed: yellow for the one you owe, white for
-- anybody else's
b.border = CreateFrame("Frame", nil, b)
b.border:SetPoint("TOPLEFT", -2, 2)
b.border:SetPoint("BOTTOMRIGHT", 2, -2)
b.border:SetBackdrop({ edgeFile = FLAT, edgeSize = 2 })
b.border:Hide()
b:SetScript("OnEnter", function(s)
GameTooltip:SetOwner(s, "ANCHOR_RIGHT")
GameTooltip:SetText("MageFocusMagic")
if MFM.cFar then
GameTooltip:AddLine("Out of range of your " .. FM_NAME .. " target", 1, 0.3, 0.3)
end
GameTooltip:AddLine("Left click: the Focus Magic order", 1, 1, 1)
GameTooltip:AddLine("Right click: options", 1, 1, 1)
GameTooltip:AddLine(MFM.db.locked and "Locked in place"
or "Drag to move", 0.7, 0.7, 0.7)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function() GameTooltip:Hide() end)
local p = self.db.point
b:SetPoint(p[1], UIParent, p[2], p[3], p[4])
self.button = b
-- the panel
local panel = CreateFrame("Frame", "MageFocusMagicPanel", UIParent)
panel:SetFrameStrata("LOW")
panel:SetClampedToScreen(true)
panel:EnableMouse(true)
panel:SetBackdrop({ bgFile = FLAT, edgeFile = FLAT, edgeSize = 1 })
panel:SetBackdropColor(0.04, 0.04, 0.05, 0.92)
panel:SetBackdropBorderColor(0, 0, 0, 1)
panel:Hide()
self.panel = panel
panel.empty = panel:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
panel.empty:SetPoint("CENTER")
-- the announce, in the same flat look as a player block
local btn = CreateFrame("Button", nil, panel)
btn:SetHeight(blockH())
btn:SetBackdrop({ bgFile = FLAT, edgeFile = FLAT, edgeSize = 1 })
btn:SetBackdropColor(0.07, 0.07, 0.09, 0.85)
btn:SetBackdropBorderColor(0, 0, 0, 1)
btn:RegisterForClicks("LeftButtonUp")
local label = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
label:SetPoint("CENTER")
label:SetText("Announce to raid")
btn:SetWidth(label:GetStringWidth() + 20)
local hl = btn:CreateTexture(nil, "BACKGROUND")
hl:SetAllPoints()
hl:SetTexture(FLAT)
hl:SetVertexColor(1, 1, 1, 0.10)
hl:Hide()
btn:SetScript("OnEnter", function() hl:Show() end)
btn:SetScript("OnLeave", function() hl:Hide() end)
btn:SetScript("OnClick", function() MFM:Announce() end)
panel.announce = btn
-- Escape closes it, like the game's own windows
tinsert(UISpecialFrames, panel:GetName())
panel:SetScript("OnShow", function() MFM:Tick() end)
-- the stand-ins only exist while they are on screen
panel:SetScript("OnHide", function() MFM.sample = false end)
end
-- With the panel closed there is still the button's own border to keep right,
-- so the plan is rebuilt anyway - but only in a group, and without drawing
-- anything.
function MFM:Tick()
if self.panel:IsShown() then
self:UpdatePanel()
elseif GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 then
self.sample = false
self:Rebuild() -- closed: only the button's borders depend on this
elseif #self.mages > 0 then
-- left the group: drop what is left of the plan, once
self.sample = false
self.rosterDirty = true
self:Rebuild()
end
self:Prewarm()
self:UpdateButton()
end
-- The pool is grown while there is no fight on, because a secure button can
-- only be built then. What it has to cover is one block per mage, or the
-- stand-ins when an unlocked panel will be showing those instead.
function MFM:Prewarm()
if not self.blocks or InCombatLockdown() then return end
local n = #self.mages
if n == 0 and self.panel:IsShown() and not self.db.locked then n = #SAMPLE end
for i = #self.blocks + 1, n do self:GetBlock(i) end
end
-- The button carries both of the panel's borders, so the plan is readable with
-- the panel closed: yellow when your own Focus Magic is not on the mage the
-- plan gave you, white when anybody else is off it. Yellow wins, being the one
-- you can do something about. On top of that the icon goes red while that mage
-- is out of range, so a yellow border you cannot act on says so.
-- Stand-ins never light any of it.
function MFM:UpdateButton()
local owed, reachable, offPlan = false, true, false
if not self.sample then
owed, reachable, offPlan = self:Status()
end
local c = owed and BORDER_MINE or (offPlan and BORDER_ANY or nil)
if self.cBorder ~= c then
self.cBorder = c
if c then
self.button.border:SetBackdropBorderColor(c[1], c[2], c[3], 1)
self.button.border:Show()
else
self.button.border:Hide()
end
end
local far = owed and not reachable
if self.cFar ~= far then
self.cFar = far
local t = far and TINT_FAR or TINT_NONE
self.button.tex:SetVertexColor(t[1], t[2], t[3])
end
end
function MFM:TogglePanel()
local p = self.panel
if p:IsShown() then
p:Hide()
else
anchorPanel(self.button, p)
p:Show()
end
end
---After a change in the options.
function MFM:ApplySettings()
local b, p = self.button, self.panel
if not b then return end
b:SetWidth(self.db.buttonSize)
b:SetHeight(self.db.buttonSize)
-- a new row height means every cached size and position is stale
p.cW, p.cH = nil, nil
p.announce:SetHeight(blockH())
for _, s in ipairs(self.seps) do s.cW = nil end
if p:IsShown() then
anchorPanel(b, p)
self:UpdatePanel()
end
end
function MFM:ResetPosition()
self.db.point = { "CENTER", "CENTER", 0, 0 }
self.button:ClearAllPoints()
self.button:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
if self.panel:IsShown() then anchorPanel(self.button, self.panel) end
end
-- ---------------------------------------------------------------------------
-- drawing
-- ---------------------------------------------------------------------------
-- One row per trade: the mages of that group side by side. The game's fonts
-- have no arrow glyphs, so the direction is plain ASCII - "<>" between two
-- mages who trade, ">" along a ring.
local SEP_PAIR = "<>"
local SEP_CHAIN = ">"
-- the one line a panel with no rows in it shows, set where it is shown
local function setEmpty(p, text)
if p.cEmpty ~= text then
p.cEmpty = text
p.empty:SetText(text)
end
p.empty:Show()
end
-- "> Name" for the end of a ring, built once per name it has ever shown
local ringText = setmetatable({}, { __index = function(t, name)
local s = SEP_CHAIN .. " |cff777777" .. name .. "|r"
t[name] = s
return s
end })
function MFM:UpdatePanel()
local p = self.panel
if not p or not p:IsShown() then return end
self:Rebuild()
local mages, groups = self.mages, self.groups
local me = UnitName("player")
self.sample = false
if #groups == 0 then
if self.db.locked then
for _, b in ipairs(self.blocks) do b:Hide() end
for _, s in ipairs(self.seps) do s:Hide() end
p.announce:Hide()
setEmpty(p, "no other mage in the group")
p.cW, p.cH = nil, nil -- the size below is not the one rows produce
p:SetWidth(200)
p:SetHeight(blockH() + PAD * 2)
return
end
-- unlocked: stand-ins, so there is something to size the panel by
self.sample = true
mages, groups = self:SampleData()
me = SAMPLE[1]
end
p.empty:Hide()
-- The plan's two lookups are built with the plan itself, in MapPlan. The
-- stand-ins get their own pair, so a panel full of them never writes
-- anything into the real ones.
local byName, giver = self.byName, self.giver
if self.sample then
byName, giver = self.sampleByName, self.sampleGiver
wipe(byName)
wipe(giver)
for _, m in ipairs(mages) do byName[m.name] = m end
for _, g in ipairs(groups) do
for i, name in ipairs(g) do
giver[g[i % #g + 1]] = name
end
end
end
-- the one mage the plan makes your job, lit until your Focus Magic is on
-- them. Another mage's Focus Magic on them is not yours and does not count.
local myTarget = self.sample and SAMPLE[2] or self:MyTarget()
local owed = (myTarget and not byName[myTarget].mine) and myTarget or nil
local unreached = owed and not self:InRange(byName[owed].unit) or false
local nBlocks, nSeps = 0, 0
local widest, y = 0, -PAD
for _, g in ipairs(groups) do
-- In combat the pool cannot grow, so a row that has no blocks left is
-- left out whole rather than drawn in half. Prewarm keeps that from
-- happening at all unless the raid gained a mage mid-fight, and the
-- draw right after the fight puts the row back.
if #self.blocks < nBlocks + #g and InCombatLockdown() then break end
local x = PAD
for mi, name in ipairs(g) do
if mi > 1 then
nSeps = nSeps + 1
local s = self:SetSep(nSeps, g.kind == "pair" and SEP_PAIR or SEP_CHAIN, false)
s:ClearAllPoints()
s:SetPoint("TOPLEFT", p, "TOPLEFT", x, y)
x = x + SEP_W
end
nBlocks = nBlocks + 1
local b = self:GetBlock(nBlocks)
b:ClearAllPoints()
b:SetPoint("TOPLEFT", p, "TOPLEFT", x, y)
self:SetBlock(b, byName[name], name == me, name == owed, giver[name],
name == owed and unreached)
x = x + b:GetWidth()
end
-- a ring hands the buff back to the mage it started with
if g.kind == "chain" then
nSeps = nSeps + 1
local s = self:SetSep(nSeps, ringText[g[1]], true)
s:ClearAllPoints()
-- left aligned, so it needs the gap the centred ones get for free
s:SetPoint("TOPLEFT", p, "TOPLEFT", x + 3, y)
x = x + SEP_W + s:GetStringWidth()
end
if x + PAD > widest then widest = x + PAD end
y = y - blockH() - ROW_GAP
end
for i = nBlocks + 1, #self.blocks do self.blocks[i]:Hide() end
for i = nSeps + 1, #self.seps do self.seps[i]:Hide() end
-- Not one row fit: the pool was still empty when the fight started, which
-- only happens after logging in mid-fight. Say so instead of showing an
-- empty box of no particular width.
if nBlocks == 0 then
setEmpty(p, "the rows are built when the fight ends")
widest = 220
end
p.announce:ClearAllPoints()
p.announce:SetPoint("TOPLEFT", p, "TOPLEFT", PAD, y)
p.announce:Show()
local h = -y + blockH() + PAD
if p.cW ~= widest or p.cH ~= h then
p.cW, p.cH = widest, h
p:SetWidth(widest)
p:SetHeight(h)
end
end
-- the slash command and the options panel live in Config.lua
function MFM:Print(msg) print_(msg) end
MFM.FM_NAME = FM_NAME
-- ---------------------------------------------------------------------------
-- events
-- ---------------------------------------------------------------------------
local driver = CreateFrame("Frame")
-- Half a second is fast enough for a buff going up and down, and it picks up
-- roster changes without a single event registration.
local since = 0
driver:SetScript("OnUpdate", function(_, dt)
if not MFM.db then return end
since = since + dt
if since < 0.5 then return end
since = 0
MFM:Tick()
end)
driver:RegisterEvent("ADDON_LOADED")
driver:SetScript("OnEvent", function(self, event, arg1)
if event ~= "ADDON_LOADED" then
-- who is in the group, and therefore the whole plan, only changes here
MFM.rosterDirty = true
return
end
if arg1 ~= "MageFocusMagic" then return end
self:UnregisterEvent("ADDON_LOADED")
-- nobody but a mage has a Focus Magic to trade
local _, class = UnitClass("player")
if class ~= "MAGE" then return end
MageFocusMagicDB = MageFocusMagicDB or {}
for k, v in pairs(MFM.defaults) do
if MageFocusMagicDB[k] == nil then
MageFocusMagicDB[k] = (type(v) == "table") and { v[1], v[2], v[3], v[4] } or v
end
end
MFM.db = MageFocusMagicDB
MFM.blocks, MFM.seps = {}, {}
MFM:CreateDisplay()
-- a left click opens and closes the panel, a right click the options
MFM.button:SetScript("OnClick", function(_, mouse)
if mouse == "RightButton" then
if MFM.OpenOptions then MFM:OpenOptions() end
else
MFM:TogglePanel()
end
end)
if MFM.InitConfig then MFM:InitConfig() end
MFM.rosterDirty = true
self:RegisterEvent("RAID_ROSTER_UPDATE")
self:RegisterEvent("PARTY_MEMBERS_CHANGED")
self:RegisterEvent("PLAYER_ENTERING_WORLD")
-- out of combat the secure parts of a block can be touched again: the pool
-- grows, the icons take the row height, and the aims are rewritten
self:RegisterEvent("PLAYER_REGEN_ENABLED")
end)