Repository navigation
Expand file tree
/
Copy pathexpbar.lua
More file actions
1321 lines (1240 loc) · 62.9 KB
/
Copy pathexpbar.lua
File metadata and controls
1321 lines (1240 loc) · 62.9 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
require('common');
local imgui = require('imgui');
local fonts = require('fonts');
local progressbar = require('progressbar');
local jobText;
local expText;
local percentText;
----------------------------------------------------------------
-- BEGIN HUNTPARTNER EXP-STATS PATCH (re-apply after HXUI update)
-- Captures "You earn/gain N experience points" lines via text_in,
-- maintains a 15-minute rolling event log, and renders a stats strip
-- under the EXP bar showing: Lv | (cur/max) | % | EXP/hr | mobs-to-level
-- | ding ETA. Hidden in merit/JP/CP modes since the rate doesn't apply.
-- Block bounded by BEGIN/END markers for easy re-application.
----------------------------------------------------------------
local exp_state = {
events = {}, -- { { t = unix_ts, n = xp_gained }, ... }
last_gain = 0, -- last single XP gain (fallback when target-diff unknown)
session_total = 0, -- total XP gained this session
session_start = nil,
-- mob_levels[entity_idx] -> level. Populated from /check (packet 0x0029)
-- and widescan (0x00F4). Stale across zone changes but harmless -- entity
-- indices recycle, lookups just become inaccurate not crashy.
mob_levels = {},
-- observed_xp_for_diff[level_delta] = { sum, count } -- rolling average,
-- caps at CAP samples so post-buff calibration converges within a handful
-- of kills (Anniversary Ring / Empress Band scenarios).
observed_by_diff = {},
-- Last target index polled during render. When an XP-gain text arrives,
-- this is "almost certainly" the mob that just died.
last_seen_target = 0,
}
----------------------------------------------------------------
-- DEDICATION / EXP-BAND TRACKER (state)
-- The Emperor/Empress/Chariot bands grant the Dedication status
-- (id 249) plus a bonus-EXP "pool" that drains as you earn boosted
-- XP. The server never sends the remaining pool to the client, so
-- we ESTIMATE it: detect which band was used (its pool/rate/dur),
-- then subtract the bonus portion of each XP gain. Time-left is a
-- plain countdown from when the buff appeared.
----------------------------------------------------------------
local DED_STATUS_ID = 249
local BANDS = {
-- name = { rate = bonus fraction, pool = bonus-EXP pool, dur = seconds }
emperor = { rate = 0.50, pool = 30000, dur = 10800 },
empress = { rate = 0.50, pool = 15000, dur = 10800 },
chariot = { rate = 0.20, pool = 7000, dur = 3600 },
}
local ded_state = {
active = false, -- Dedication (249) currently on the player
known = false, -- true once we've identified the band (pool/rate)
band = nil, -- 'emperor' | 'empress' | 'chariot'
rate = 0, -- bonus fraction of the active band(s)
pool_max = 0, -- starting bonus-EXP pool
pool_left = 0, -- estimated bonus EXP remaining
duration = 0, -- max buff seconds for the active band
started_at = nil, -- os.time() when the buff/band began
pending_until = 0, -- grace window after a band-use before the buff registers
last_use_at = 0, -- os.time() of the last detected band-use (de-dupe)
last_use_band = nil, -- band of the last detected use (de-dupe)
}
-- v-plus: EXP-bar visual FX state (gain pops, gain flash, level-up burst,
-- smooth fill easing). Written by the XP-gain text handler and animated each
-- frame in DrawWindow. Purely cosmetic; gated on gConfig.expBarJuice.
local exp_fx = {
pops = {}, -- floating "+N": { amount, born (os.clock) }
flash_until = 0, -- os.clock() deadline for the gain flash
ding_at = 0, -- os.clock() the level-up burst started (0 = idle)
last_level = nil, -- last observed main job level (ding detection)
last_job = nil, -- last observed main job id (suppress ding on job change)
disp = nil, -- eased visual fill fraction
last_frame = nil, -- os.clock() of previous frame (dt for easing)
-- v-plus: kill-combo ("chain") meter state.
chain = 0, -- current combo length (>=2 shown)
chain_last = 0, -- os.clock() of the last kill (window check)
chain_at = 0, -- os.clock() of the latest link (bounce/tink)
chain_peak = 0, -- highest link reached this streak
chain_broke_at = 0, -- os.clock() a 3+ streak broke (for the fade)
chain_broke_peak = 0, -- the peak that was lost
confetti = {}, -- rainbow level-up particles
}
-- v-plus: play a short cosmetic sound from assets/sounds. pcall-guarded so a
-- missing file or an older Ashita (no play_sound) never breaks the addon.
local function play_exp_sound(file)
pcall(function()
if ashita and ashita.misc and ashita.misc.play_sound then
ashita.misc.play_sound(('%s/assets/sounds/%s'):format(addon.path, file))
end
end)
end
-- v-plus: kill-combo heat color. n>=2 ramps white-gold -> gold -> orange ->
-- red-hot as the chain climbs. Returns r,g,b in 0..1.
local function chain_heat(n)
local t = (n - 2) / 3
if t < 0 then t = 0 elseif t > 1 then t = 1 end
local stops = {
{ 1.00, 0.97, 0.80 },
{ 1.00, 0.82, 0.30 },
{ 1.00, 0.50, 0.15 },
{ 1.00, 0.25, 0.12 },
}
local seg = t * 3
local i = math.floor(seg) + 1
if i >= 4 then return stops[4][1], stops[4][2], stops[4][3] end
local f = seg - (i - 1)
local a, b = stops[i], stops[i + 1]
return a[1] + (b[1] - a[1]) * f,
a[2] + (b[2] - a[2]) * f,
a[3] + (b[3] - a[3]) * f
end
-- v-plus: HSV->RGB for rainbow FX (overdrive chain, ding burst, confetti).
-- h,s,v and the returned r,g,b are all 0..1.
local function hsv2rgb(h, s, v)
local i = math.floor(h * 6)
local f = h * 6 - i
local p = v * (1 - s)
local q = v * (1 - f * s)
local t = v * (1 - (1 - f) * s)
i = i % 6
if i == 0 then return v, t, p
elseif i == 1 then return q, v, p
elseif i == 2 then return p, v, t
elseif i == 3 then return p, q, v
elseif i == 4 then return t, p, v
else return v, p, q end
end
-- v-plus: EXP bar fill color. The user picks a single base color
-- (gConfig.expBarColor, r/g/b each 0..1) via the config wheel; the bar is
-- rendered as a subtle gradient derived from it -- a touch darker at the
-- bottom, a touch brighter at the top -- so the "juiced" sheen survives for
-- any hue. Defaults reproduce the original gold bar.
local EXPBAR_DEFAULT_COLOR = { 0.91, 0.74, 0.36 }
local function exp_hex(r, g, b)
return ('#%02x%02x%02x'):format(
math.floor(math.clamp(r, 0, 1) * 255 + 0.5),
math.floor(math.clamp(g, 0, 1) * 255 + 0.5),
math.floor(math.clamp(b, 0, 1) * 255 + 0.5))
end
local function exp_base_color()
local c = gConfig and gConfig.expBarColor
if type(c) == 'table' and c[1] and c[2] and c[3] then
return c[1], c[2], c[3]
end
return EXPBAR_DEFAULT_COLOR[1], EXPBAR_DEFAULT_COLOR[2], EXPBAR_DEFAULT_COLOR[3]
end
-- Main fill gradient (bottom -> top) from the chosen base color.
local function exp_fill_gradient()
local r, g, b = exp_base_color()
return exp_hex(r * 0.80, g * 0.80, b * 0.80),
exp_hex(r + (1 - r) * 0.08, g + (1 - g) * 0.08, b + (1 - b) * 0.08)
end
-- Muted "next kill" ghost segment, derived so it tracks the chosen hue.
local function exp_ghost_gradient()
local r, g, b = exp_base_color()
return exp_hex(r * 0.53, g * 0.53, b * 0.53),
exp_hex(r * 0.70, g * 0.70, b * 0.70)
end
local function player_has_buff(id)
local found = false
pcall(function()
local p = AshitaCore:GetMemoryManager():GetPlayer()
if p == nil then return end
local buffs = p:GetBuffs()
if buffs == nil then return end
for i = 0, #buffs do
if buffs[i] == id then found = true; return end
end
end)
return found
end
-- Seed or refresh the Dedication tracker for a known band. Shared by the
-- item-use packet (0x37) detector and the "uses an X Band" text fallback.
-- Both can fire ~1 frame apart for the same activation, so repeats of the
-- same band within a few seconds are collapsed to avoid double-counting.
local function ded_begin(band)
local b = BANDS[band]
if not b then return end
local now = os.time()
if ded_state.last_use_band == band and (now - (ded_state.last_use_at or 0)) <= 3 then
ded_state.last_use_at = now
return
end
if ded_state.active and ded_state.known then
-- Stacking: pools add, the higher bonus % wins, timer refreshes.
ded_state.pool_left = ded_state.pool_left + b.pool
ded_state.pool_max = ded_state.pool_max + b.pool
ded_state.rate = math.max(ded_state.rate, b.rate)
else
ded_state.pool_left = b.pool
ded_state.pool_max = b.pool
ded_state.rate = b.rate
end
ded_state.band = band
ded_state.duration = b.dur
ded_state.started_at = now
ded_state.known = true
ded_state.active = true
ded_state.pending_until = now + 6
ded_state.last_use_at = now
ded_state.last_use_band = band
end
-- Base XP per kill by (mob_lvl - player_lvl). Solo, no chain/signet/conquest.
-- Directionally correct as a baseline; observed values override per-diff.
local XP_BY_DIFF = {
[-10] = 0, [-9] = 6, [-8] = 7, [-7] = 8, [-6] = 10, [-5] = 15,
[-4] = 20, [-3] = 30, [-2] = 50, [-1] = 80,
[0] = 100,
[1] = 110, [2] = 120, [3] = 130, [4] = 140, [5] = 150,
[6] = 160, [7] = 170, [8] = 180, [9] = 190, [10] = 200,
}
local function estimate_xp_for_diff(diff)
if diff <= -11 then return 0 end
if diff >= 11 then return 240 end
return XP_BY_DIFF[diff] or 0
end
local function record_observed_xp(diff, gained)
if not diff or not gained or gained <= 0 then return end
local CAP = 3
local rec = exp_state.observed_by_diff[diff]
if not rec then
rec = { sum = 0, count = 0 }
exp_state.observed_by_diff[diff] = rec
end
if rec.count >= CAP then
local mean = rec.sum / rec.count
rec.sum = mean * (CAP - 1); rec.count = CAP - 1
end
rec.sum = rec.sum + gained
rec.count = rec.count + 1
end
local function observed_xp_for_diff(diff)
local rec = exp_state.observed_by_diff[diff]
if rec and rec.count > 0 then return rec.sum / rec.count end
return nil
end
-- Returns predicted XP for currently-targeted mob using level diff, falling
-- back through: observed-at-this-diff -> last empirical gain -> retail table.
-- Returns nil when no target or target level unknown (run /check).
local function estimate_xp_for_current_target(player_lvl)
if not player_lvl or player_lvl <= 0 then return nil end
local idx
pcall(function() idx = AshitaCore:GetMemoryManager():GetTarget():GetTargetIndex(0) end)
if not idx or idx == 0 then return nil end
local mlvl = exp_state.mob_levels[idx]
if not mlvl or mlvl <= 0 then return nil end
local diff = mlvl - player_lvl
local obs = observed_xp_for_diff(diff)
if obs then return math.floor(obs + 0.5) end
if exp_state.last_gain > 0 then return exp_state.last_gain end
return estimate_xp_for_diff(diff)
end
ashita.events.register('text_in', 'hxuiplus_exp_capture_cb', function(e)
local s = e.message_modified or e.message or ''
if not s or s == '' then return end
local low = s:lower()
local gained = low:match('gain[s]? (%d+) experience point')
or low:match('earn[s]? (%d+) experience point')
if gained then
local n = tonumber(gained)
if n and n > 0 then
local t = os.time()
table.insert(exp_state.events, { t = t, n = n })
exp_state.last_gain = n
-- v-plus: fire a floating "+N" pop and a brief fill-flash (juice FX).
exp_fx.pops[#exp_fx.pops + 1] = { amount = n, born = os.clock() }
if #exp_fx.pops > 12 then table.remove(exp_fx.pops, 1) end
exp_fx.flash_until = os.clock() + 0.22
-- v-plus: kill-combo ("chain") tracking. Consecutive kills inside a
-- shrinking window stack the combo; drives the heat meter + tink.
do
local cnow = os.clock()
local eff = math.max(12, 42 - exp_fx.chain * 6)
if exp_fx.chain > 0 and (cnow - exp_fx.chain_last) <= eff then
exp_fx.chain = exp_fx.chain + 1
else
exp_fx.chain = 1
end
exp_fx.chain_last = cnow
exp_fx.chain_at = cnow
if exp_fx.chain > exp_fx.chain_peak then
exp_fx.chain_peak = exp_fx.chain
end
if exp_fx.chain >= 2 and gConfig.expBarSoundChain ~= false then
play_exp_sound('chain.wav')
end
end
exp_state.session_total = exp_state.session_total + n
if not exp_state.session_start then exp_state.session_start = t end
-- v-plus: drain the Dedication (EXP-band) bonus pool by the bonus
-- portion of this gain. Displayed XP includes the band bonus, so
-- bonus = n * rate/(1+rate). Estimate only -- the real pool lives
-- server-side and is never sent to the client.
if ded_state.active and ded_state.known and ded_state.rate > 0
and player_has_buff(DED_STATUS_ID) then
local bonus = n * (ded_state.rate / (1 + ded_state.rate))
ded_state.pool_left = math.max(0, ded_state.pool_left - bonus)
end
-- Calibrate observed-xp-for-diff table from this kill, if we have
-- both the dead mob's level and ours. Silently skips otherwise.
local mlvl = exp_state.mob_levels[exp_state.last_seen_target]
local plvl
pcall(function() plvl = AshitaCore:GetMemoryManager():GetPlayer():GetMainJobLevel() end)
if mlvl and plvl and mlvl > 0 and plvl > 0 then
record_observed_xp(mlvl - plvl, n)
end
end
end
-- v-plus: text fallback for band activation ("<name> uses an Emperor Band"),
-- used when the item-use packet detector didn't catch it. ded_begin() de-dupes
-- against the packet path so a single activation isn't counted twice.
local band = low:match('use[s]? an? (%a+) band')
if band and BANDS[band] then
ded_begin(band)
end
end)
-- Mob-level capture: /check response (packet 0x0029) and widescan (0x00F4).
-- Cribbed from huntpartner's parser. The MessageNum at 0x18 disambiguates
-- 0x0029 -- skillups vs check responses. We only care about check responses.
ashita.events.register('packet_in', 'hxuiplus_mob_level_cb', function(e)
if e.id == 0x0029 then
local ok, msgnum = pcall(struct.unpack, 'H', e.data, 0x18 + 0x01)
if not ok or msgnum == 38 or msgnum == 53 then return end -- skip skillup ticks
local ok2, lvl = pcall(struct.unpack, 'l', e.data, 0x0C + 0x01)
local ok3, target = pcall(struct.unpack, 'H', e.data, 0x16 + 0x01)
if ok2 and ok3 and target and target > 0 and lvl and lvl > 0 then
exp_state.mob_levels[target] = lvl
end
elseif e.id == 0x00F4 then -- widescan
local ok1, idx = pcall(struct.unpack, 'H', e.data, 0x04 + 0x01)
local ok2, lvl = pcall(struct.unpack, 'b', e.data, 0x06 + 0x01)
if ok1 and ok2 and idx and lvl and lvl > 0 then
exp_state.mob_levels[idx] = lvl
end
end
end)
-- v-plus: PRIMARY EXP-band detection. Using an item sends an outgoing "use
-- item" packet (0x37). We resolve the item from your inventory via the
-- container/index carried in the packet and, if it's an Emperor/Empress/
-- Chariot Band, seed the Dedication pool tracker. This is language-independent
-- and works mid-session, unlike the "uses an X Band" chat-text fallback.
-- Offsets match luashitacast's HandleItemPacket on this client: item index at
-- byte 0x0E, container at byte 0x10 (packet data includes the 4-byte header).
ashita.events.register('packet_out', 'hxuiplus_exp_band_use_cb', function(e)
if e.id ~= 0x37 then return end
local ok_i, itemIndex = pcall(struct.unpack, 'B', e.data, 0x0E + 1)
local ok_c, container = pcall(struct.unpack, 'B', e.data, 0x10 + 1)
if not (ok_i and ok_c) or not itemIndex or itemIndex == 0 then return end
local band
pcall(function()
local inv = AshitaCore:GetMemoryManager():GetInventory()
if inv == nil then return end
local item = inv:GetContainerItem(container, itemIndex)
if item == nil or item.Id == nil or item.Id == 0 then return end
local res = AshitaCore:GetResourceManager():GetItemById(item.Id)
if res == nil or res.Name == nil then return end
local nm = res.Name[1]
if type(nm) ~= 'string' then return end
band = nm:lower():match('(%a+)%s+band')
end)
if band and BANDS[band] then
ded_begin(band)
end
end)
local function exp_prune(window_s)
local cutoff = os.time() - window_s
local kept = {}
for _, ev in ipairs(exp_state.events) do
if ev.t >= cutoff then kept[#kept + 1] = ev end
end
exp_state.events = kept
end
local function exp_per_hour(window_min)
local window_s = (window_min or 15) * 60
exp_prune(window_s)
if #exp_state.events == 0 then return 0 end
local total = 0
for _, ev in ipairs(exp_state.events) do total = total + ev.n end
local span = math.max(1, os.time() - exp_state.events[1].t)
local denom = math.min(window_s, span)
if denom < 1 then denom = 1 end
return total * 3600.0 / denom
end
local function fmt_eta_short(secs)
if secs < 60 then return ('%ds'):format(math.floor(secs)) end
if secs < 3600 then return ('%dm'):format(math.floor(secs / 60 + 0.5)) end
local h = math.floor(secs / 3600)
local m = math.floor((secs % 3600) / 60 + 0.5)
return ('%dh%02dm'):format(h, m)
end
----------------------------------------------------------------
-- DEDICATION / EXP-BAND TRACKER (helpers)
----------------------------------------------------------------
local function ded_short(v)
v = math.floor(v + 0.5)
if v >= 10000 then return ('%dk'):format(math.floor(v / 1000 + 0.5)) end
if v >= 1000 then return ((('%.1fk'):format(v / 1000)):gsub('%.0k$', 'k')) end
return tostring(v)
end
-- Sync active state from the player's buffs each frame. Clears when the
-- Dedication buff drops (pool depleted or timer expired). A short grace
-- window keeps state after a band-use until the buff actually registers.
local function ded_sync()
if player_has_buff(DED_STATUS_ID) then
ded_state.pending_until = 0
if not ded_state.active then
ded_state.active = true
if not ded_state.known and not ded_state.started_at then
ded_state.started_at = os.time()
end
end
elseif os.time() >= (ded_state.pending_until or 0) then
if ded_state.active or ded_state.known or ded_state.started_at then
ded_state.active = false
ded_state.known = false
ded_state.band = nil
ded_state.rate = 0
ded_state.pool_max = 0
ded_state.pool_left = 0
ded_state.duration = 0
ded_state.started_at = nil
end
end
end
local function ded_time_left()
if not ded_state.started_at or ded_state.duration <= 0 then return nil end
local left = ded_state.duration - (os.time() - ded_state.started_at)
if left < 0 then left = 0 end
return left
end
local function ded_status_string()
if not ded_state.active then return '' end
if ded_state.known and ded_state.pool_max > 0 then
local label = (ded_state.band or 'exp'):gsub('^%l', string.upper)
local tleft = ded_time_left()
local tstr = tleft and (' \194\183 ' .. fmt_eta_short(tleft)) or ''
return ('%s band %s left%s'):format(label, ded_short(ded_state.pool_left), tstr)
end
return 'EXP band active'
end
----------------------------------------------------------------
-- END HUNTPARTNER EXP-STATS PATCH (header section)
----------------------------------------------------------------
local expbar = {
limitPoints = {},
meritPoints = {},
capacityPoints = {},
jobPoints = {},
};
local function UpdateTextVisibility(visible)
jobText:SetVisible(visible);
expText:SetVisible(visible);
percentText:SetVisible(visible);
end
--[[
* event: d3d_present
* desc : Event called when the Direct3D device is presenting a scene.
--]]
expbar.DrawWindow = function(settings)
-- Obtain the player entity..
local player = AshitaCore:GetMemoryManager():GetPlayer();
if (player == nil) then
UpdateTextVisibility(false);
return;
end
local mainJob = player:GetMainJob();
if (player.isZoning or mainJob == 0) then
UpdateTextVisibility(false);
return;
end
-- v-plus: poll current target so XP capture can correlate "the mob you
-- just killed" with its level for diff-calibration.
pcall(function()
local tidx = AshitaCore:GetMemoryManager():GetTarget():GetTargetIndex(0)
if tidx and tidx ~= 0 then exp_state.last_seen_target = tidx end
end)
-- v-plus: sync Dedication (EXP-band) active state from the player's buffs.
ded_sync()
local jobLevel = player:GetMainJobLevel();
local subJob = player:GetSubJob();
local subJobLevel = player:GetSubJobLevel();
local expPoints = { player:GetExpCurrent(), player:GetExpNeeded() };
local expPointsProgress = expPoints[1] / expPoints[2];
local limitPoints = expbar.limitPoints;
local limitPointsProgress = limitPoints[1] / limitPoints[2];
local meritPoints = expbar.meritPoints;
-- expbar.capacityPoints[1] = player:GetCapacityPoints(mainJob);
-- expbar.jobPoints[1] = player:GetJobPoints(mainJob);
local capPoints = expbar.capacityPoints;
local capPointsProgress = expbar.capacityPoints[1] / expbar.capacityPoints[2];
local jobPoints = expbar.jobPoints;
local meritMode = gConfig.expBarLimitPointsMode and (expPoints[1] == 55999 or ((player:GetIsLimitModeEnabled() or player:GetIsExperiencePointsLocked()) and jobLevel >= 75));
-- If player is a max level then only enable meritMode in the xp bar if limit mode is specifically enabled
-- this is so we display capacity points by default
-- TODO: Tapping on Exp bar switches between merit mode and capacity points
if jobLevel >= 99 and not player:GetIsLimitModeEnabled() then
meritMode = false
end
local progressBarProgress = 0
if meritMode then
progressBarProgress = limitPointsProgress;
elseif jobLevel >= 99 then
progressBarProgress = capPointsProgress;
else
progressBarProgress = expPointsProgress
end
local inlineMode = gConfig.expBarInlineMode;
local windowSize = inlineMode and settings.barWidth + settings.textWidth or math.max(settings.barWidth, settings.textWidth);
imgui.SetNextWindowSize({ windowSize, -1 }, ImGuiCond_Always);
local windowFlags = bit.bor(ImGuiWindowFlags_NoDecoration, ImGuiWindowFlags_AlwaysAutoResize, ImGuiWindowFlags_NoFocusOnAppearing, ImGuiWindowFlags_NoNav, ImGuiWindowFlags_NoBackground, ImGuiWindowFlags_NoBringToFrontOnFocus);
if (gConfig.lockPositions) then
windowFlags = bit.bor(windowFlags, ImGuiWindowFlags_NoMove);
end
-- v-plus: hoist startX/startY so the EXP-STATS PATCH render block
-- below can position the stats window relative to the bar's screen pos.
local startX, startY = 0, 0
if (imgui.Begin('ExpBar', true, windowFlags)) then
-- Draw HP Bar
startX, startY = imgui.GetCursorScreenPos();
local col2X = startX + settings.textWidth - imgui.GetStyle().FramePadding.x * 2;
local progressBarWidth = settings.barWidth - imgui.GetStyle().FramePadding.x * 2;
if inlineMode then
imgui.SetCursorScreenPos({col2X, startY});
end
-- v-plus: stack a "ghost" segment showing where the next kill would
-- push the bar. Prefers currently-targeted mob's level-diff estimate
-- (calibrated from prior kills at that diff), falls back to last
-- empirical gain when no target or target level unknown.
-- v-plus: EXP-bar juice - smooth fill easing + level-up (ding) detect.
-- Cosmetic only; the real progress value stays intact for stats/ticks.
local juiceOn = (gConfig.expBarJuice ~= false)
local fillProgress = progressBarProgress
if juiceOn then
local nowc = os.clock()
local dt = exp_fx.last_frame and math.max(0, nowc - exp_fx.last_frame) or 0
exp_fx.last_frame = nowc
-- Ding: main job level increased since last frame (no false fire on
-- load, and de-levels/mode swaps just resync without a burst).
-- Changing main jobs also resyncs silently, so swapping to a
-- higher-level job never dings as if it were a real level-up.
if exp_fx.last_level == nil or exp_fx.last_job ~= mainJob then
exp_fx.last_level = jobLevel
exp_fx.last_job = mainJob
elseif jobLevel > exp_fx.last_level then
exp_fx.ding_at = nowc
exp_fx.last_level = jobLevel
if gConfig.expBarSoundDing ~= false then play_exp_sound('ding.wav') end
-- v-plus: spawn a rainbow confetti fountain off the level-up.
if gConfig.expBarRainbow ~= false then
exp_fx.confetti = {}
for ci = 1, 26 do
local cr, cg, cb = hsv2rgb(math.random(), 0.85, 1.0)
exp_fx.confetti[ci] = {
fx = math.random(), y = 0,
vx = (math.random() - 0.5) * 130,
vy = -90 - math.random() * 100,
r = cr, g = cg, b = cb, born = nowc,
}
end
end
else
exp_fx.last_level = jobLevel
end
-- Ease the visible fill toward the real value; snap on big jumps
-- (ding reset, job/mode switch) so we never animate a long drain.
if exp_fx.disp == nil then exp_fx.disp = progressBarProgress end
local diff = progressBarProgress - exp_fx.disp
if math.abs(diff) > 0.5 or progressBarProgress < exp_fx.disp - 0.12 then
exp_fx.disp = progressBarProgress
else
exp_fx.disp = exp_fx.disp + diff * (1 - math.exp(-dt * 12))
if math.abs(progressBarProgress - exp_fx.disp) < 0.0008 then
exp_fx.disp = progressBarProgress
end
end
fillProgress = exp_fx.disp
else
exp_fx.disp = progressBarProgress
end
local fillStart, fillEnd = exp_fill_gradient()
local barSegments = {{fillProgress, {fillStart, fillEnd}}}
local ghost_xp = 0
if not meritMode and jobLevel < 99
and expPoints[2] > 0 and expPoints[1] < expPoints[2] then
local tgt = estimate_xp_for_current_target(jobLevel)
if tgt and tgt > 0 then
ghost_xp = tgt
elseif exp_state.last_gain > 0 then
ghost_xp = exp_state.last_gain
end
if ghost_xp > 0 then
local headroom = math.max(0, 1.0 - fillProgress)
local ghost = math.min(ghost_xp / expPoints[2], headroom)
if ghost > 0 then
local ghostStart, ghostEnd = exp_ghost_gradient()
table.insert(barSegments, { ghost, { ghostStart, ghostEnd } })
end
end
end
progressbar.ProgressBar(barSegments, {progressBarWidth, settings.barHeight}, {decorate = gConfig.showExpBarBookends});
-- EXP bar segment dividers (configurable: 5 or 20 segments)
local expSegments = gConfig.expBarSegments or 5
if expSegments > 1 then
local dl_seg
pcall(function() dl_seg = imgui.GetWindowDrawList() end)
if dl_seg then
local pad = imgui.GetStyle().FramePadding.x
local segBarX = (inlineMode and col2X or startX) + pad
local segBarY = startY
local segBw = progressBarWidth
local segBh = settings.barHeight
-- Bookend offset
if gConfig.showExpBarBookends then
local bookendW = segBh / 2
segBarX = segBarX + bookendW
segBw = segBw - bookendW * 2
end
for i = 1, expSegments - 1 do
local frac = i / expSegments
local lx = segBarX + segBw * frac
dl_seg:AddLine(
{ lx, segBarY + 1 },
{ lx, segBarY + segBh - 1 },
imgui.GetColorU32({ 0.0, 0.0, 0.0, 0.5 }), 1)
end
end
end
----------------------------------------------------------------
-- HUNTPARTNER EXP TIME PROJECTION TICKS
-- Adaptive time ticks on EXP bar based on current rate.
-- Shows where you'll be at future clock times + a DING marker.
----------------------------------------------------------------
if not meritMode and jobLevel < 99 and expPoints[2] > 0 then
local per_hour = exp_per_hour(15)
if per_hour > 0 then
local dl_e
pcall(function() dl_e = imgui.GetForegroundDrawList() end)
if dl_e then
local pad = imgui.GetStyle().FramePadding.x
local barX = (inlineMode and col2X or startX) + pad
local barY = startY
local bw = progressBarWidth
local bh = settings.barHeight
local cur_xp = expPoints[1]
local needed = expPoints[2]
local cur_frac = cur_xp / needed
local remaining = needed - cur_xp
local time_to_ding_min = remaining * 60.0 / per_hour
-- Draw ticks at 20/40/60/80/100% milestones (skip already passed)
local milestones = { 0.20, 0.40, 0.60, 0.80 }
for _, target_frac in ipairs(milestones) do
if target_frac > cur_frac + 0.02 then
local xp_needed_for_ms = needed * target_frac
local xp_to_go = xp_needed_for_ms - cur_xp
local mins_to_ms = xp_to_go * 60.0 / per_hour
local tx = barX + bw * target_frac
dl_e:AddLine(
{ tx, barY },
{ tx, barY + bh },
imgui.GetColorU32({ 1.0, 0.95, 0.4, 1.0 }), 2)
-- Clock time label with shadow for readability
local wall = os.date('%I:%M', os.time() + math.floor(mins_to_ms * 60))
:gsub('^0', '')
dl_e:AddText({ tx - 7, barY - 11 },
imgui.GetColorU32({ 0, 0, 0, 1.0 }), wall)
dl_e:AddText({ tx - 8, barY - 12 },
imgui.GetColorU32({ 1.0, 0.95, 0.5, 1.0 }), wall)
end
end
-- DING marker at 100%
local ding_x = barX + bw
local ding_time = os.date('%I:%M', os.time() + math.floor(time_to_ding_min * 60))
:gsub('^0', '')
local ding_str = ding_time .. '!'
dl_e:AddText({ ding_x - 23, barY - 11 },
imgui.GetColorU32({ 0, 0, 0, 1.0 }), ding_str)
dl_e:AddText({ ding_x - 24, barY - 12 },
imgui.GetColorU32({ 0.2, 1.0, 0.4, 1.0 }), ding_str)
end
end
end
----------------------------------------------------------------
-- END HUNTPARTNER EXP TIME PROJECTION TICKS
----------------------------------------------------------------
----------------------------------------------------------------
-- DEDICATION / EXP-BAND POOL BAR
-- Thin gold bar hugging the underside of the EXP bar showing the
-- estimated bonus-EXP pool remaining. Only drawn while a band is
-- active and identified.
----------------------------------------------------------------
if ded_state.active and ded_state.known and ded_state.pool_max > 0 then
local dl_d
pcall(function() dl_d = imgui.GetForegroundDrawList() end)
if dl_d then
local pad = imgui.GetStyle().FramePadding.x
local dBarX = (inlineMode and col2X or startX) + pad
local dBw = progressBarWidth
local dBy = startY + settings.barHeight + 1
local frac = ded_state.pool_left / ded_state.pool_max
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
local pulse = 0.75 + 0.25 * math.sin(os.clock() * 3.0)
dl_d:AddLine({ dBarX, dBy }, { dBarX + dBw, dBy },
imgui.GetColorU32({ 0.10, 0.08, 0.02, 0.55 }), 2)
dl_d:AddLine({ dBarX, dBy }, { dBarX + dBw * frac, dBy },
imgui.GetColorU32({ 1.0, 0.84, 0.30, 0.90 * pulse }), 2)
end
end
----------------------------------------------------------------
-- v-plus: EXP-BAR JUICE - gain flash, floating +XP pops, ding burst.
-- Cosmetic overlay drawn on the foreground list over the bar. Gated
-- on gConfig.expBarJuice (defaults on).
----------------------------------------------------------------
if juiceOn then
local dl_fx
pcall(function() dl_fx = imgui.GetForegroundDrawList() end)
if dl_fx then
local nowc = os.clock()
local pad = imgui.GetStyle().FramePadding.x
local fxX = (inlineMode and col2X or startX) + pad
local fxY = startY
local fxW = progressBarWidth
local fxH = settings.barHeight
local fillX = fxX + fxW * math.max(0, math.min(1, exp_fx.disp or 0))
-- v-plus: expire the kill-combo if its (shrinking) window lapsed.
if exp_fx.chain > 0 then
local eff = math.max(12, 42 - exp_fx.chain * 6)
if (nowc - exp_fx.chain_last) > eff then
if exp_fx.chain_peak >= 3 then
exp_fx.chain_broke_at = nowc
exp_fx.chain_broke_peak = exp_fx.chain_peak
end
exp_fx.chain = 0
exp_fx.chain_peak = 0
end
end
-- Gain flash: brief warm wash over the filled portion.
if nowc < exp_fx.flash_until then
local a = (exp_fx.flash_until - nowc) / 0.22
dl_fx:AddRectFilled({ fxX, fxY }, { fillX, fxY + fxH },
imgui.GetColorU32({ 1.0, 0.97, 0.75, 0.30 * a }), 0, 0)
end
-- Floating "+N" pops rising off the bar and fading out. Big,
-- bold, and long-lived so even a single kill reads as juicy.
local kept = {}
for _, p in ipairs(exp_fx.pops) do
local age = nowc - p.born
local life = 1.5
if age >= 0 and age < life then
kept[#kept + 1] = p
local tt = age / life
local alpha = (tt < 0.55) and 1.0 or (1.0 - (tt - 0.55) / 0.45)
-- ease-out rise so it "pops" up then drifts
local rise = 42 * (1 - (1 - tt) * (1 - tt))
local px = fillX - 10
if px < fxX then px = fxX end
if px > fxX + fxW - 34 then px = fxX + fxW - 34 end
local py = fxY - 3 - rise
local label = '+' .. tostring(p.amount)
-- green normally; gold on the fat gains
local pr, pg, pb = 0.60, 1.0, 0.50
if p.amount >= 350 then pr, pg, pb = 1.0, 0.85, 0.30 end
local oc = imgui.GetColorU32({ 0, 0, 0, 0.85 * alpha })
dl_fx:AddText({ px - 1, py }, oc, label)
dl_fx:AddText({ px + 1, py }, oc, label)
dl_fx:AddText({ px, py - 1 }, oc, label)
dl_fx:AddText({ px, py + 1 }, oc, label)
dl_fx:AddText({ px, py }, imgui.GetColorU32({ pr, pg, pb, alpha }), label)
end
end
exp_fx.pops = kept
-- Level-up burst: full-bar flash + a light sweep + rising spark
-- streaks + a "LEVEL UP!" that lifts off and fades.
if exp_fx.ding_at > 0 then
local dage = nowc - exp_fx.ding_at
local ddur = 0.9
if dage >= ddur then
exp_fx.ding_at = 0
else
local tt = dage / ddur
local fade = 1 - tt
local ease = 1 - fade * fade
local rb = (gConfig.expBarRainbow ~= false)
-- full-bar flash
local fr, fg, fb = 1.0, 0.95, 0.6
if rb then fr, fg, fb = hsv2rgb((nowc * 0.8) % 1, 0.55, 1.0) end
dl_fx:AddRectFilled({ fxX, fxY }, { fxX + fxW, fxY + fxH },
imgui.GetColorU32({ fr, fg, fb, 0.5 * fade }), 0, 0)
-- light sweep
local sweepX = fxX + fxW * ease
local swr, swg, swb = 1.0, 1.0, 0.85
if rb then swr, swg, swb = hsv2rgb((ease + nowc) % 1, 0.8, 1.0) end
dl_fx:AddRectFilled({ sweepX - 5, fxY }, { sweepX + 5, fxY + fxH },
imgui.GetColorU32({ swr, swg, swb, 0.55 * fade }), 0, 0)
-- rising spark streaks (rainbow spread when enabled)
local baseY = fxY + fxH * 0.5
local rise = (fxH * 1.4 + 16) * ease
for i = 1, 14 do
local sx = fxX + fxW * ((i - 0.5) / 14)
local hx = sx + math.sin(i * 12.9898) * 6 * ease
local hy = baseY - rise
local kr, kg, kb = 1.0, 0.95, 0.7
if rb then kr, kg, kb = hsv2rgb((i / 14 + nowc * 0.5) % 1, 0.85, 1.0) end
dl_fx:AddLine({ hx, hy }, { hx, hy + 4 },
imgui.GetColorU32({ kr, kg, kb, 0.9 * fade }), 2)
end
-- "LEVEL UP!" lift-off (rainbow per-char when enabled)
local lu = 'LEVEL UP!'
local luX = fxX + fxW * 0.5 - 27
local luY = fxY - 14 - 14 * ease
if rb then
for ci = 1, #lu do
local lr, lg, lb = hsv2rgb((nowc * 0.8 + ci * 0.07) % 1, 0.85, 1.0)
local lx = luX + (ci - 1) * 6
dl_fx:AddText({ lx + 1, luY + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.85 * fade }), lu:sub(ci, ci))
dl_fx:AddText({ lx, luY },
imgui.GetColorU32({ lr, lg, lb, fade }), lu:sub(ci, ci))
end
else
dl_fx:AddText({ luX + 1, luY + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.85 * fade }), lu)
dl_fx:AddText({ luX, luY },
imgui.GetColorU32({ 1.0, 0.9, 0.35, fade }), lu)
end
end
end
-- v-plus: rainbow confetti shower raining off a level-up.
if #exp_fx.confetti > 0 then
local kept = {}
for _, c in ipairs(exp_fx.confetti) do
local age = nowc - c.born
if age < 1.3 then
kept[#kept + 1] = c
local px = fxX + fxW * c.fx + c.vx * age
local py = fxY + c.y + c.vy * age + 190 * age * age
dl_fx:AddRectFilled({ px, py }, { px + 3, py + 3 },
imgui.GetColorU32({ c.r, c.g, c.b, 1.0 - age / 1.3 }), 0, 0)
end
end
exp_fx.confetti = kept
end
-- v-plus: "next kill dings" anticipation shimmer. When one
-- predicted kill would reach the level, wash the bar gold and
-- float a cue so the payoff feels telegraphed.
if (gConfig.expBarAnticipation ~= false) and not meritMode
and jobLevel < 99 and expPoints[2] and expPoints[2] > 0
and expPoints[1] < expPoints[2] and exp_fx.ding_at == 0 then
local pred = estimate_xp_for_current_target(jobLevel) or exp_state.last_gain
if pred and pred > 0 and (expPoints[1] + pred) >= expPoints[2] then
local pulse = 0.5 + 0.5 * math.sin(nowc * 6.0)
dl_fx:AddRectFilled({ fxX, fxY }, { fxX + fxW, fxY + fxH },
imgui.GetColorU32({ 1.0, 0.85, 0.30, 0.10 + 0.12 * pulse }), 0, 0)
local g = (nowc % 1.1) / 1.1
local gx = fxX + fxW * g
dl_fx:AddRectFilled({ gx - 6, fxY }, { gx + 6, fxY + fxH },
imgui.GetColorU32({ 1.0, 1.0, 0.80, 0.25 * (1 - g) }), 0, 0)
local cue = 'DING NEXT!'
local cw = #cue * 6
local cx = fxX + fxW * 0.5 - cw / 2
local cy = fxY - 16 - 2 * math.sin(nowc * 6.0)
dl_fx:AddText({ cx + 1, cy + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.8 }), cue)
dl_fx:AddText({ cx, cy },
imgui.GetColorU32({ 1.0, 0.90, 0.40, 0.95 }), cue)
end
end
-- v-plus: kill-combo ("chain") meter. Grows and heats up as the
-- streak climbs; the fill gets a matching pulse. A broken streak
-- of 3+ leaves a brief "CHAIN xN LOST" note.
if (gConfig.expBarChain ~= false) then
if exp_fx.chain >= 2 then
local n = exp_fx.chain
local since = nowc - exp_fx.chain_at
local bounce = math.max(0, 1 - since / 0.3)
local rbchain = (gConfig.expBarRainbow ~= false) and n >= 5
local r, g, b = chain_heat(n)
local label = ('CHAIN %d'):format(n)
if n >= 4 then label = label .. '!' end
local lw = #label * 6
local cx = fxX + fxW * 0.5 - lw / 2
local cy = fxY - 36 - 6 * bounce
local padp = 3 + 3 * bounce
if rbchain then
-- OVERDRIVE: rainbow text, glowing pill, fill sheen, tag.
local gr, gg, gb = hsv2rgb((nowc * 0.7) % 1, 0.5, 1.0)
dl_fx:AddRectFilled({ cx - padp, cy - 2 }, { cx + lw + padp, cy + 13 },
imgui.GetColorU32({ gr * 0.4, gg * 0.4, gb * 0.4, 0.6 }), 3, 0)
for ci = 1, #label do
local hr, hg, hb = hsv2rgb((nowc * 0.9 + ci * 0.07) % 1, 0.85, 1.0)
local lx = cx + (ci - 1) * 6
dl_fx:AddText({ lx + 1, cy + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.85 }), label:sub(ci, ci))
dl_fx:AddText({ lx, cy },
imgui.GetColorU32({ hr, hg, hb, 1.0 }), label:sub(ci, ci))
end
local od = 'OVERDRIVE!'
local ow = #od * 6
local ox = fxX + fxW * 0.5 - ow / 2
local oy = cy - 12
for ci = 1, #od do
local hr, hg, hb = hsv2rgb((nowc * 1.1 + ci * 0.06) % 1, 0.9, 1.0)
local lx = ox + (ci - 1) * 6
dl_fx:AddText({ lx + 1, oy + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.8 }), od:sub(ci, ci))
dl_fx:AddText({ lx, oy },
imgui.GetColorU32({ hr, hg, hb, 1.0 }), od:sub(ci, ci))
end
local slices = 22
for s = 0, slices - 1 do
local x0 = fxX + (fillX - fxX) * (s / slices)
local x1 = fxX + (fillX - fxX) * ((s + 1) / slices)
local hr, hg, hb = hsv2rgb((s / slices + nowc * 0.6) % 1, 0.8, 1.0)
dl_fx:AddRectFilled({ x0, fxY }, { x1, fxY + fxH },
imgui.GetColorU32({ hr, hg, hb, 0.22 }), 0, 0)
end
else
dl_fx:AddRectFilled({ cx - padp, cy - 2 }, { cx + lw + padp, cy + 13 },
imgui.GetColorU32({ r * 0.30, g * 0.18, b * 0.12, 0.55 }), 3, 0)
dl_fx:AddText({ cx + 1, cy + 1 },
imgui.GetColorU32({ 0, 0, 0, 0.85 }), label)
dl_fx:AddText({ cx, cy }, imgui.GetColorU32({ r, g, b, 1.0 }), label)
if n >= 4 then
dl_fx:AddText({ cx, cy }, imgui.GetColorU32({ r, g, b, 0.5 }), label)
end
local amp = math.min(0.14, 0.03 + n * 0.010)
local wash = amp * (0.5 + 0.5 * math.sin(nowc * (4 + n * 0.3)))
dl_fx:AddRectFilled({ fxX, fxY }, { fillX, fxY + fxH },
imgui.GetColorU32({ r, g, b, wash }), 0, 0)
end
-- v-plus: chain countdown -- shows how long the streak
-- holds if you don't get the next kill. A depleting bar +
-- seconds that go green -> yellow -> red (pulsing) as the
-- window runs out. Matches the expiry window exactly.
local eff = math.max(12, 42 - n * 6)
local remain = eff - (nowc - exp_fx.chain_last)
if remain < 0 then remain = 0 end
local frac = math.max(0, math.min(1, remain / eff))
local tr, tg, tb = 0.35, 1.0, 0.40
if frac < 0.5 then tr, tg, tb = 1.0, 0.85, 0.25 end
if frac < 0.25 then
local pl = 0.55 + 0.45 * math.sin(nowc * 12)
tr, tg, tb = 1.0, 0.35 * pl, 0.25 * pl
end
-- depleting underline hugging the bottom of the pill
local ubx = cx - padp