-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnextauto.lua
More file actions
814 lines (732 loc) · 33 KB
/
Copy pathnextauto.lua
File metadata and controls
814 lines (732 loc) · 33 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
addon.name = 'nextauto'
addon.author = 'TreeFidyDad'
addon.version = '2.0.4'
addon.desc = 'Configurable next-auto-attack swing timer (arc or bar) with color gradient.'
addon.link = 'https://github.com/TreeFidyDad/nextauto'
require('common')
local imgui = require('imgui')
local settings = require('settings')
------------------------------------------------------------
-- Default settings
------------------------------------------------------------
local defaultConfig = T{
-- Geometry
length = 84, -- end-to-end span in pixels
thickness = 5, -- bar/arc thickness (height of the stroke)
segments = 32, -- smoothness (number of segments)
curve = 1.0, -- 0 = straight line, 1 = full semicircle arc
vertical = false, -- run vertically instead of horizontally
reverse = false, -- fill from the opposite end
-- Appearance
color_start = T{ 0.30, 0.90, 1.00 }, -- cyan (just swung)
color_mid = T{ 1.00, 0.90, 0.20 }, -- yellow (mid)
color_end = T{ 1.00, 0.20, 0.15 }, -- red (swing imminent)
show_bob = true, -- pendulum bob at the leading edge
background = false, -- draw a backing window
bg_color = T{ 0.05, 0.05, 0.08, 0.60 },
-- Behavior
hide_out_of_combat = true, -- hide entirely when not engaged
pause_out_of_combat = true, -- (when not hiding) freeze instead of run
-- Window
locked = false,
visible = true,
}
local config = settings.load(defaultConfig)
-- settings.load can leave nested tables shallow; make sure colors are tables.
-- Takes the settings table to normalize so it can be re-run when the profile is
-- swapped on character login (see settings.register below).
local function normalize_colors(cfg)
local function ensure_color(key, def)
if type(cfg[key]) ~= 'table' then cfg[key] = T{ def[1], def[2], def[3], def[4] } end
end
ensure_color('color_start', defaultConfig.color_start)
ensure_color('color_mid', defaultConfig.color_mid)
ensure_color('color_end', defaultConfig.color_end)
ensure_color('bg_color', defaultConfig.bg_color)
end
normalize_colors(config)
-- Load the correct per-character profile automatically on login.
--
-- Ashita loads addons at the boot/login screen, before a character is selected,
-- so the settings.load() above reads the shared "defaults" profile. When you log
-- in and zone, the settings library swaps in that character's settings table and
-- raises this event. Without re-pointing `config` at the new table (every other
-- reference reads this same upvalue live), the addon keeps showing the default
-- profile until a manual `/addon reload nextauto`. Registering here makes the
-- character profile load on its own, the way luashitacast swaps profiles on login.
settings.register('settings', 'nextauto_settings_update', function(s)
if s == nil then return end
config = s
normalize_colors(config)
-- Announce only when a real character profile is active (login/zone-in), not
-- on logout, which reloads the shared defaults profile.
local ok, status = pcall(function()
return AshitaCore:GetMemoryManager():GetPlayer():GetLoginStatus()
end)
if ok and status == 2 then
print('[NextAuto] Character profile loaded.')
end
end)
-- Curated color presets (/nextauto preset <name>). Start = just swung,
-- End = swing ready; End is intentionally the most aggressive color.
local COLOR_PRESETS = {
classic = { name = 'Classic Pulse', s = { 0.20, 1.00, 0.55 }, m = { 1.00, 0.85, 0.10 }, e = { 1.00, 0.15, 0.15 } },
ion = { name = 'Ion', s = { 0.10, 0.65, 1.00 }, m = { 1.00, 0.20, 0.80 }, e = { 1.00, 0.45, 0.05 } },
frost = { name = 'Frost to Fire', s = { 0.30, 0.85, 1.00 }, m = { 0.75, 0.55, 1.00 }, e = { 1.00, 0.30, 0.20 } },
}
------------------------------------------------------------
-- Swing tracking state
------------------------------------------------------------
local swing = {
last_swing = 0,
interval = nil,
samples = {},
action_frozen = false, -- an action (cast/WS/JA/ranged charge) is in progress; arc held
pause_frac = 0, -- arc fraction captured when the freeze/pause began
pause_until = 0, -- os.clock() until which the arc is frozen (post-action lock)
freeze_safety = 0, -- absolute backstop release time for an open-ended freeze
skip_sample = false, -- next melee gap spans an action; don't feed it to the average
was_engaged = nil,
}
-- Post-action animation lock (seconds). Per Nerf's LSB analysis, auto-attack
-- delay is frozen for the action's animation time AFTER the finish packet, then
-- resumes from the same fraction. ~2.0s covers the vast majority of JA/WS/spell
-- animations; the cast/charge portion itself is handled by freezing from the
-- start packet until the finish packet arrives, so it needs no estimate.
local DEFAULT_ANIM_LOCK = 2.0
local PET_EXTRA_LOCK = 1.0 -- extra buffer after pet-command (BST/SMN) finish
local FREEZE_TIMEOUT = 20.0 -- auto-release an open-ended freeze if finish packet never comes
-- Resource-derived post-action lock. Units of CastTime/AnimationTime vary by
-- Ashita build and are unverified here, so we clamp to a sane range and fall
-- back to the 2.0s default. The freeze-until-finish already covers cast time,
-- so this only needs the post-finish animation portion.
local function anim_lock_from(res_obj)
local ok, lock = pcall(function()
if res_obj and res_obj.AnimationTime and res_obj.AnimationTime > 0 then
local s = res_obj.AnimationTime / 1000.0
if s >= 0.5 and s <= 6.0 then return s end
end
return DEFAULT_ANIM_LOCK
end)
return (ok and lock) or DEFAULT_ANIM_LOCK
end
local function get_ability_lock(id)
local ok, r = pcall(function() return AshitaCore:GetResourceManager():GetAbilityById(id) end)
return anim_lock_from(ok and r or nil)
end
local function get_spell_post_lock(id)
local ok, r = pcall(function() return AshitaCore:GetResourceManager():GetSpellById(id) end)
return anim_lock_from(ok and r or nil)
end
local function get_ws_lock(_id)
-- GetWeaponSkillById isn't reliably present across Ashita builds; default.
return DEFAULT_ANIM_LOCK
end
local debug_log = false -- /na debug : print each self 0x28 action packet
local show_config = false
------------------------------------------------------------
-- Martial Arts delay reduction (MNK trait, main job only)
------------------------------------------------------------
local MNK_MARTIAL_ARTS = {
{ lvl = 15, reduction = 50 },
{ lvl = 25, reduction = 75 },
{ lvl = 40, reduction = 100 },
{ lvl = 55, reduction = 125 },
{ lvl = 70, reduction = 150 },
}
local function get_martial_arts_reduction()
local ok, r = pcall(function()
local pl = AshitaCore:GetMemoryManager():GetPlayer()
if pl:GetMainJob() ~= 2 then return 0 end
local mlvl = pl:GetMainJobLevel() or 0
local best = 0
for _, tier in ipairs(MNK_MARTIAL_ARTS) do
if mlvl >= tier.lvl then best = tier.reduction end
end
return best
end)
return (ok and r) or 0
end
------------------------------------------------------------
-- Dual Wield (NIN / DNC main jobs and /NIN, /DNC subjobs)
--
-- With a real weapon in the offhand the attack-round interval is no longer just
-- the main weapon's delay. Per BGWiki "Dual Wield":
-- round delay = (D_main + D_sub) * (1 - DualWield%) / 2
-- The Dual Wield trait % comes from job level (HorizonXI / 75-era tiers below);
-- main vs. sub job take the higher tier (the trait does not stack). Gear/merit
-- Dual Wield and Haste are intentionally left out -- this only seeds the estimate
-- used before real swings are sampled; the rolling average still refines the live
-- value to match actual haste and gear.
------------------------------------------------------------
local DUAL_WIELD_TRAITS = {
[13] = { { lvl = 45, dw = 0.25 }, { lvl = 25, dw = 0.15 }, { lvl = 10, dw = 0.10 } }, -- NIN
[19] = { { lvl = 60, dw = 0.25 }, { lvl = 40, dw = 0.15 }, { lvl = 20, dw = 0.10 } }, -- DNC
}
local function dw_for_job(job, level)
local tiers = DUAL_WIELD_TRAITS[job or 0]
if not tiers then return 0 end
for _, t in ipairs(tiers) do
if (level or 0) >= t.lvl then return t.dw end
end
return 0
end
local function get_dual_wield_pct()
local ok, dw = pcall(function()
local pl = AshitaCore:GetMemoryManager():GetPlayer()
return math.max(
dw_for_job(pl:GetMainJob(), pl:GetMainJobLevel()),
dw_for_job(pl:GetSubJob(), pl:GetSubJobLevel()))
end)
return (ok and dw) or 0
end
-- Returns the item resource in an equipment slot only if it holds an actual
-- weapon (a weapon skill type with a real delay), so a shield or grip in the sub
-- slot is never mistaken for a dual-wielded weapon.
local function equipped_weapon_res(inv, slot)
local eq = inv:GetEquippedItem(slot)
if not eq or eq.Index == 0 then return nil end
local item = inv:GetContainerItem(math.floor(eq.Index / 0x100), eq.Index % 0x100)
if not item or item.Id == 0 then return nil end
local res = AshitaCore:GetResourceManager():GetItemById(item.Id)
if not res or not res.Skill or res.Skill == 0 then return nil end
if not res.Delay or res.Delay <= 0 then return nil end
return res
end
local function get_main_weapon_delay()
local ok, delay = pcall(function()
local inv = AshitaCore:GetMemoryManager():GetInventory()
local eq = inv:GetEquippedItem(0)
local has_main = eq and eq.Index ~= 0
local res = nil
if has_main then
local container = math.floor(eq.Index / 0x100)
local slot = eq.Index % 0x100
local item = inv:GetContainerItem(container, slot)
if item and item.Id ~= 0 then
res = AshitaCore:GetResourceManager():GetItemById(item.Id)
end
end
local pl = AshitaCore:GetMemoryManager():GetPlayer()
local is_mnk_main = pl:GetMainJob() == 2
local is_h2h_weapon = res and res.Skill == 1
local is_barehand = (not has_main) and is_mnk_main
if is_h2h_weapon or is_barehand then
local base = 480
local weapon_delay = (res and res.Delay) or 0
local ma = get_martial_arts_reduction()
local effective = base + weapon_delay - ma
if effective < 96 then effective = 96 end
return effective / 60.0
end
if not res or not res.Delay or res.Delay == 0 then return nil end
-- Dual wield: a real weapon in the offhand changes the attack round.
-- The attack-round interval (time between rounds, both hands swinging)
-- is (D_main + D_sub) * (1 - DW%). BGWiki's "(D1+D2)*(1-DW)/2" is the
-- per-hand delay used for TP; the round itself is twice that, so there
-- is no /2 here.
local sub_res = equipped_weapon_res(inv, 1)
if sub_res then
return (res.Delay + sub_res.Delay) * (1.0 - get_dual_wield_pct()) / 60.0
end
return res.Delay / 60.0
end)
return ok and delay or nil
end
------------------------------------------------------------
-- Combat (engaged) detection
------------------------------------------------------------
local function is_engaged()
local ok, r = pcall(function()
local party = AshitaCore:GetMemoryManager():GetParty()
local idx = party:GetMemberTargetIndex(0)
local ent = AshitaCore:GetMemoryManager():GetEntity()
return ent:GetStatus(idx) == 1 -- 1 = Engaged
end)
return ok and r or false
end
------------------------------------------------------------
-- Swing math
------------------------------------------------------------
local function current_frac()
local interval = swing.interval or get_main_weapon_delay()
if not interval or interval <= 0 or swing.last_swing <= 0 then return 0 end
return math.min((os.clock() - swing.last_swing) / interval, 1.0)
end
local function record_swing()
local t_now = os.clock()
-- Only sample clean melee->melee gaps. After an action (WS/JA/spell/etc.)
-- last_swing holds a synthetic baseline, so the first swing afterward would
-- corrupt the rolling weapon-delay average (e.g. Sneak Attack making autos
-- read far too fast). Skip that one gap and resync the baseline to reality.
if swing.last_swing > 0 and not swing.skip_sample then
local gap = t_now - swing.last_swing
local interval = swing.interval or get_main_weapon_delay()
local ceiling
if swing.interval then
ceiling = swing.interval * 1.5
else
ceiling = interval and (interval * 1.5) or 12
end
if gap > 0.3 and gap < ceiling then
table.insert(swing.samples, gap)
while #swing.samples > 5 do table.remove(swing.samples, 1) end
local s = 0
for _, g in ipairs(swing.samples) do s = s + g end
swing.interval = s / #swing.samples
end
end
swing.last_swing = t_now
swing.action_frozen = false
swing.pause_until = 0
swing.pause_frac = 0
swing.skip_sample = false
end
-- Freeze the arc in place (open-ended) until a matching finish packet or the
-- safety timeout. Used for WS/JA/ranged/spell START packets.
local function freeze_now()
if not swing.action_frozen then
swing.pause_frac = current_frac()
end
swing.action_frozen = true
swing.skip_sample = true
swing.freeze_safety = os.clock() + FREEZE_TIMEOUT
end
-- Apply a post-action animation lock and schedule resume from `frac` (defaults
-- to the current live fraction for instant actions that have no start packet).
local function pause_and_resume(lock, frac)
local interval = swing.interval or get_main_weapon_delay()
local pf = frac or current_frac()
swing.pause_frac = pf
swing.pause_until = os.clock() + lock
swing.action_frozen = false
swing.skip_sample = true
if interval then
-- last_swing placed so elapsed == pf*interval exactly when the lock ends
swing.last_swing = swing.pause_until - pf * interval
end
end
-- Action finished: continue from the frozen fraction if we were holding from a
-- start packet, otherwise from the live fraction (instant WS/JA).
local function finish_action(lock)
local frac = swing.action_frozen and swing.pause_frac or nil
pause_and_resume(lock, frac)
end
------------------------------------------------------------
-- Packet handler: full 0x28 action-category coverage + zone clear
--
-- Auto-attack delay freezes while you perform another action and resumes from
-- the same fraction once the action's animation lock ends. We freeze on START
-- packets (held until the matching finish) and apply a post-action lock on
-- FINISH packets. See AUTO_ATTACK_TIMING.md (Nerf) for the full rationale.
------------------------------------------------------------
ashita.events.register('packet_in', 'nextauto_pkt_cb', function(e)
if e.id == 0x000A or e.id == 0x000B then
swing.last_swing = 0
swing.interval = nil
swing.samples = {}
swing.action_frozen = false
swing.pause_until = 0
swing.pause_frac = 0
swing.skip_sample = false
return
end
if e.id ~= 0x28 then return end
pcall(function()
local me = AshitaCore:GetMemoryManager():GetParty():GetMemberServerId(0)
if not me or me == 0 then return end
local b = e.data:totable()
local actor_id = ashita.bits.unpack_be(b, 40, 32)
if actor_id ~= me then return end
local category = ashita.bits.unpack_be(b, 82, 4)
local param = ashita.bits.unpack_be(b, 86, 32) -- action id (WS/JA/spell)
if debug_log then
print(string.format('[NextAuto] 0x28 cat=%d param=%d frac=%.2f%s',
category, param, current_frac(),
swing.action_frozen and ' (frozen)' or ''))
end
if category == 1 then
-- Melee swing landed (clears any pause state).
record_swing()
elseif category == 7 then
-- Weaponskill START: freeze until the finish packet.
freeze_now()
elseif category == 3 then
-- Weaponskill FINISH: post-WS animation lock.
finish_action(get_ws_lock(param))
elseif category == 10 then
-- Job ability START (cast/ready time): freeze until finish.
freeze_now()
elseif category == 6 then
-- Job ability FINISH: post-JA animation lock.
finish_action(get_ability_lock(param))
elseif category == 8 then
-- Spell cast START: freeze for the whole cast (held until finish).
freeze_now()
elseif category == 4 then
-- Spell cast FINISH: post-cast animation lock.
finish_action(get_spell_post_lock(param))
elseif category == 12 then
-- Ranged START: freeze until the shot resolves.
freeze_now()
elseif category == 2 then
-- Ranged FINISH: post-shot animation lock.
finish_action(DEFAULT_ANIM_LOCK)
elseif category == 13 then
-- Pet ability finished: extra buffer (pet may animate past player lock).
finish_action(DEFAULT_ANIM_LOCK + PET_EXTRA_LOCK)
elseif category == 14 then
-- Dancer step: freeze + lock like a JA.
finish_action(DEFAULT_ANIM_LOCK)
elseif category == 15 then
-- RUN ward/effusion: freeze + lock like a JA.
finish_action(DEFAULT_ANIM_LOCK)
end
end)
end)
------------------------------------------------------------
-- Geometry: morph between a straight line (curve=0) and a
-- semicircle (curve=1) with fixed endpoints. Returns local coords.
------------------------------------------------------------
local function path_at(cfg, t)
local L = cfg.length
local curve = cfg.curve
local arc_along = L * 0.5 - (L * 0.5) * math.cos(math.pi * t)
local arc_bulge = (L * 0.5) * math.sin(math.pi * t)
-- abs(curve) controls the along-axis spacing (so the negative side keeps a
-- true circular profile); the sign of curve flips the bulge direction.
local along = t * L + (arc_along - t * L) * math.abs(curve)
local bulge = arc_bulge * curve
if cfg.vertical then
return bulge, along
else
return along, bulge
end
end
local function build_path(cfg)
local segs = math.max(math.floor(cfg.segments), 2)
local pts = {}
local minx, miny, maxx, maxy = math.huge, math.huge, -math.huge, -math.huge
for i = 0, segs do
local t = i / segs
local x, y = path_at(cfg, t)
pts[i] = { x = x, y = y, t = t }
if x < minx then minx = x end
if x > maxx then maxx = x end
if y < miny then miny = y end
if y > maxy then maxy = y end
end
return pts, segs, minx, miny, maxx, maxy
end
------------------------------------------------------------
-- Color helpers
------------------------------------------------------------
local function lerp(a, b, t) return a + (b - a) * t end
local function grad_color(tprog, cfg)
local cs, cm, ce = cfg.color_start, cfg.color_mid, cfg.color_end
if tprog < 0.5 then
local t = tprog * 2
return lerp(cs[1], cm[1], t), lerp(cs[2], cm[2], t), lerp(cs[3], cm[3], t)
else
local t = (tprog - 0.5) * 2
return lerp(cm[1], ce[1], t), lerp(cm[2], ce[2], t), lerp(cm[3], ce[3], t)
end
end
------------------------------------------------------------
-- Render
------------------------------------------------------------
ashita.events.register('d3d_present', 'nextauto_render_cb', function()
if show_config then
-- Config GUI is drawn even if the bar is hidden.
render_config()
end
if not config.visible then return end
local cfg = config
local interval = swing.interval or get_main_weapon_delay()
if not interval or interval <= 0 then return end
local now = os.clock()
-- Safety: if an open-ended freeze never received its finish packet (e.g. a
-- spell interrupted by movement sends no 0x28 finish), release it so the arc
-- doesn't stay stuck forever.
if swing.action_frozen and now > swing.freeze_safety then
pause_and_resume(0, swing.pause_frac)
end
-- Combat state + transitions (reset the round on engage/disengage).
local engaged = is_engaged()
if swing.was_engaged == nil then swing.was_engaged = engaged end
if engaged ~= swing.was_engaged then
swing.last_swing = 0
swing.action_frozen = false
swing.pause_until = 0
swing.pause_frac = 0
swing.skip_sample = false
if not engaged then swing.samples = {} end
swing.was_engaged = engaged
end
local frozen_ooc = false
if not engaged then
if cfg.hide_out_of_combat then return end
if cfg.pause_out_of_combat then frozen_ooc = true end
end
-- Current fill fraction.
local casting = swing.action_frozen
local locked = now < swing.pause_until
local paused = casting or locked or frozen_ooc
local frac
if casting or locked then
frac = swing.pause_frac or 0
elseif frozen_ooc then
frac = 0
elseif swing.last_swing > 0 then
frac = math.min((now - swing.last_swing) / interval, 1.0)
else
frac = 0
end
-- Build geometry.
local pts, segs, minx, miny, maxx, maxy = build_path(cfg)
local pad = cfg.thickness * 0.5 + 3
local win_w = (maxx - minx) + pad * 2
local win_h = (maxy - miny) + pad * 2
local swFlags = bit.bor(
ImGuiWindowFlags_NoDecoration,
ImGuiWindowFlags_NoFocusOnAppearing,
ImGuiWindowFlags_NoNav,
ImGuiWindowFlags_NoBringToFrontOnFocus)
if not cfg.background then
swFlags = bit.bor(swFlags, ImGuiWindowFlags_NoBackground)
end
if cfg.locked then
swFlags = bit.bor(swFlags, ImGuiWindowFlags_NoMove)
end
imgui.SetNextWindowSize({ win_w, win_h }, ImGuiCond_Always)
-- Zero the window padding so our own `pad` margin fully controls spacing;
-- otherwise ImGui's default padding shifts the content past the window edge
-- and the draw-list clip rect slices off the arc's corners.
-- Also drop the minimum window size: when the curve is near-flat the arc is
-- only a few pixels tall, and the default 32px minimum would otherwise pad
-- the background window out with empty space on the thin axis.
imgui.PushStyleVar(ImGuiStyleVar_WindowPadding, { 0, 0 })
imgui.PushStyleVar(ImGuiStyleVar_WindowMinSize, { 1, 1 })
local pushed_bg = false
if cfg.background then
imgui.PushStyleColor(ImGuiCol_WindowBg, { cfg.bg_color[1], cfg.bg_color[2], cfg.bg_color[3], cfg.bg_color[4] or 0.6 })
pushed_bg = true
end
if imgui.Begin('NextAuto', true, swFlags) then
local ox, oy = imgui.GetCursorScreenPos()
local offx = ox + pad - minx
local offy = oy + pad - miny
local dl
pcall(function() dl = imgui.GetWindowDrawList() end)
if dl then
local th = cfg.thickness
-- Background track (dim)
for i = 0, segs - 1 do
local p1, p2 = pts[i], pts[i + 1]
dl:AddLine({ offx + p1.x, offy + p1.y }, { offx + p2.x, offy + p2.y },
imgui.GetColorU32({ 0.15, 0.15, 0.20, 0.5 }), th)
end
-- Filled progress
if frac > 0 then
for i = 0, segs - 1 do
local p1, p2 = pts[i], pts[i + 1]
local t_mid = (i + 0.5) / segs
local filled
if cfg.reverse then
filled = t_mid >= (1.0 - frac)
else
filled = t_mid <= frac
end
if filled then
local rC, gC, bC, aC
if paused then
rC, gC, bC = 0.40, 0.30, 0.80
aC = 0.50 + 0.15 * math.sin(now * 4)
else
local tprog = cfg.reverse and (1.0 - t_mid) or t_mid
rC, gC, bC = grad_color(tprog, cfg)
aC = 0.92
end
dl:AddLine({ offx + p1.x, offy + p1.y }, { offx + p2.x, offy + p2.y },
imgui.GetColorU32({ rC, gC, bC, aC }), th)
end
end
-- Pendulum bob at the leading edge
if cfg.show_bob and not paused then
local bob_t = cfg.reverse and (1.0 - frac) or frac
local bx, by = path_at(cfg, bob_t)
local bob_col = (frac >= 0.95)
and imgui.GetColorU32({ 1.0, 0.3, 0.2, 0.9 })
or imgui.GetColorU32({ 1.0, 0.9, 0.4, 0.8 })
dl:AddCircleFilled({ offx + bx, offy + by }, th + 1, bob_col, 12)
end
-- White flash on swing land
if (not paused) and swing.last_swing > 0 then
local elapsed = now - swing.last_swing
if elapsed >= 0 and elapsed < 0.18 then
local fa = 0.6 * (1.0 - elapsed / 0.18)
for i = 0, segs - 1 do
local p1, p2 = pts[i], pts[i + 1]
dl:AddLine({ offx + p1.x, offy + p1.y }, { offx + p2.x, offy + p2.y },
imgui.GetColorU32({ 1.0, 1.0, 1.0, fa }), th + 2)
end
end
end
end
end
imgui.Dummy({ win_w - 4, win_h - 4 })
end
imgui.End()
if pushed_bg then imgui.PopStyleColor(1) end
imgui.PopStyleVar(2)
end)
------------------------------------------------------------
-- Config GUI
------------------------------------------------------------
function render_config()
imgui.SetNextWindowSize({ 320, 0 }, ImGuiCond_FirstUseEver)
local is_open = { show_config }
if imgui.Begin('NextAuto Config', is_open, ImGuiWindowFlags_AlwaysAutoResize) then
local changed = false
imgui.TextColored({ 0.5, 0.9, 1.0, 1.0 }, 'Geometry')
imgui.Separator()
local len = { config.length }
if imgui.SliderInt('Length', len, 20, 600) then config.length = len[1]; changed = true end
local th = { config.thickness }
if imgui.SliderInt('Thickness / Height', th, 1, 24) then config.thickness = th[1]; changed = true end
local seg = { config.segments }
if imgui.SliderInt('Smoothness', seg, 2, 128) then config.segments = seg[1]; changed = true end
local cv = { config.curve }
if imgui.SliderFloat('Curve (-1..1, 0=straight)', cv, -1.0, 1.0, '%.2f') then
-- Snap to exact 0 within a small deadzone so a flat bar is easy to hit.
if math.abs(cv[1]) < 0.04 then cv[1] = 0.0 end
config.curve = cv[1]; changed = true
end
imgui.SameLine()
if imgui.SmallButton('Straight') then config.curve = 0.0; changed = true end
local vert = { config.vertical }
if imgui.Checkbox('Vertical', vert) then config.vertical = vert[1]; changed = true end
imgui.SameLine()
local rev = { config.reverse }
if imgui.Checkbox('Reverse fill', rev) then config.reverse = rev[1]; changed = true end
imgui.Spacing()
imgui.TextColored({ 0.5, 0.9, 1.0, 1.0 }, 'Colors')
imgui.Separator()
local cflags = bit.bor(ImGuiColorEditFlags_NoInputs, ImGuiColorEditFlags_NoLabel)
local function color3(label, key)
local c = { config[key][1], config[key][2], config[key][3] }
if imgui.ColorEdit3('##' .. key, c, cflags) then
config[key] = T{ c[1], c[2], c[3] }; changed = true
end
imgui.SameLine(); imgui.Text(label)
end
color3('Start (just swung)', 'color_start')
color3('Mid', 'color_mid')
color3('End (swing ready)', 'color_end')
imgui.Text('Presets:'); imgui.SameLine()
local function preset_btn(label, key)
if imgui.SmallButton(label) then
local p = COLOR_PRESETS[key]
config.color_start = T{ p.s[1], p.s[2], p.s[3] }
config.color_mid = T{ p.m[1], p.m[2], p.m[3] }
config.color_end = T{ p.e[1], p.e[2], p.e[3] }
changed = true
end
end
preset_btn('Classic', 'classic'); imgui.SameLine()
preset_btn('Ion', 'ion'); imgui.SameLine()
preset_btn('Frost', 'frost')
local bob = { config.show_bob }
if imgui.Checkbox('Show pendulum bob', bob) then config.show_bob = bob[1]; changed = true end
imgui.Spacing()
imgui.TextColored({ 0.5, 0.9, 1.0, 1.0 }, 'Background')
imgui.Separator()
local bg = { config.background }
if imgui.Checkbox('Background window', bg) then config.background = bg[1]; changed = true end
if config.background then
local bc = { config.bg_color[1], config.bg_color[2], config.bg_color[3], config.bg_color[4] or 0.6 }
if imgui.ColorEdit4('##bg_color', bc, ImGuiColorEditFlags_AlphaBar) then
config.bg_color = T{ bc[1], bc[2], bc[3], bc[4] }; changed = true
end
end
imgui.Spacing()
imgui.TextColored({ 0.5, 0.9, 1.0, 1.0 }, 'Behavior')
imgui.Separator()
local hooc = { config.hide_out_of_combat }
if imgui.Checkbox('Hide out of combat', hooc) then config.hide_out_of_combat = hooc[1]; changed = true end
if not config.hide_out_of_combat then
local pooc = { config.pause_out_of_combat }
if imgui.Checkbox('Freeze out of combat', pooc) then config.pause_out_of_combat = pooc[1]; changed = true end
end
imgui.Spacing()
imgui.Separator()
local lock = { config.locked }
if imgui.Checkbox('Lock position', lock) then config.locked = lock[1]; changed = true end
imgui.SameLine()
if imgui.Button('Close') then show_config = false end
if changed then settings.save() end
end
imgui.End()
if not is_open[1] then show_config = false end
end
------------------------------------------------------------
-- Commands: /nextauto (aliases /na, /swingers)
------------------------------------------------------------
local function handle_command(args)
local sub = (args[2] or ''):lower()
if sub == '' or sub == 'config' or sub == 'gui' or sub == 'menu' then
show_config = not show_config
elseif sub == 'show' then
config.visible = true; print('[NextAuto] Visible')
elseif sub == 'hide' then
config.visible = false; print('[NextAuto] Hidden')
elseif sub == 'lock' then
config.locked = true; print('[NextAuto] Locked')
elseif sub == 'unlock' then
config.locked = false; print('[NextAuto] Unlocked')
elseif sub == 'reset' then
for k, v in pairs(defaultConfig) do config[k] = v end
print('[NextAuto] Settings reset to defaults')
elseif sub == 'radius' or sub == 'length' then
config.length = tonumber(args[3]) or config.length; print('[NextAuto] Length = ' .. config.length)
elseif sub == 'thickness' then
config.thickness = tonumber(args[3]) or config.thickness; print('[NextAuto] Thickness = ' .. config.thickness)
elseif sub == 'segments' then
config.segments = tonumber(args[3]) or config.segments; print('[NextAuto] Smoothness = ' .. config.segments)
elseif sub == 'curve' then
config.curve = tonumber(args[3]) or config.curve; print('[NextAuto] Curve = ' .. config.curve)
elseif sub == 'debug' then
debug_log = not debug_log
print('[NextAuto] Action-packet debug ' .. (debug_log and 'ON (watch the log)' or 'OFF'))
elseif sub == 'preset' then
local p = COLOR_PRESETS[(args[3] or ''):lower()]
if p then
config.color_start = T{ p.s[1], p.s[2], p.s[3] }
config.color_mid = T{ p.m[1], p.m[2], p.m[3] }
config.color_end = T{ p.e[1], p.e[2], p.e[3] }
print('[NextAuto] Color preset: ' .. p.name)
else
print('[NextAuto] Presets: classic, ion, frost')
end
else
print('[NextAuto] Usage: /nextauto [config|show|hide|lock|unlock|reset|preset NAME|length N|thickness N|segments N|curve F|debug]')
end
settings.save()
end
ashita.events.register('command', 'nextauto_cmd_cb', function(e)
local args = e.command:args()
if not args[1] then return end
local cmd = args[1]:lower()
if cmd ~= '/nextauto' and cmd ~= '/na' and cmd ~= '/swingers' then return end
e.blocked = true
handle_command(args)
end)
------------------------------------------------------------
-- Load / Unload
------------------------------------------------------------
ashita.events.register('load', 'nextauto_load_cb', function()
print('[NextAuto] Loaded. /nextauto (or /na) for the config menu.')
end)
ashita.events.register('unload', 'nextauto_unload_cb', function()
settings.save()
end)