@rbxts/vfx-forge 2.2.2-ts.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +82 -0
  2. package/README.md +39 -0
  3. package/out/forge-vfx/effects/beam.luau +312 -0
  4. package/out/forge-vfx/effects/bezier.luau +392 -0
  5. package/out/forge-vfx/effects/camera_shake.luau +200 -0
  6. package/out/forge-vfx/effects/lightning.luau +1183 -0
  7. package/out/forge-vfx/effects/mesh.luau +466 -0
  8. package/out/forge-vfx/effects/particle.luau +64 -0
  9. package/out/forge-vfx/effects/randomizer.luau +110 -0
  10. package/out/forge-vfx/effects/screen.luau +61 -0
  11. package/out/forge-vfx/effects/shockwave_debris.luau +277 -0
  12. package/out/forge-vfx/effects/shockwave_line.luau +356 -0
  13. package/out/forge-vfx/effects/shockwave_ring.luau +252 -0
  14. package/out/forge-vfx/effects/sound.luau +311 -0
  15. package/out/forge-vfx/effects/spin.luau +88 -0
  16. package/out/forge-vfx/effects/tweener.luau +122 -0
  17. package/out/forge-vfx/emitters.luau +387 -0
  18. package/out/forge-vfx/index.d.ts +356 -0
  19. package/out/forge-vfx/init.luau +279 -0
  20. package/out/forge-vfx/mod/attributes.luau +227 -0
  21. package/out/forge-vfx/mod/color/Oklab.luau +93 -0
  22. package/out/forge-vfx/mod/color/sRGB.luau +71 -0
  23. package/out/forge-vfx/mod/common/bezier.luau +372 -0
  24. package/out/forge-vfx/mod/common/flipbook.luau +102 -0
  25. package/out/forge-vfx/mod/lerp.luau +210 -0
  26. package/out/forge-vfx/mod/logger.luau +20 -0
  27. package/out/forge-vfx/mod/shape.luau +207 -0
  28. package/out/forge-vfx/mod/tween.luau +161 -0
  29. package/out/forge-vfx/mod/utility.luau +707 -0
  30. package/out/forge-vfx/obj/Bezier.luau +268 -0
  31. package/out/forge-vfx/obj/ObjectCache.luau +289 -0
  32. package/out/forge-vfx/services/caches.luau +62 -0
  33. package/out/forge-vfx/services/effects.luau +234 -0
  34. package/out/forge-vfx/services/enabled_effects.luau +120 -0
  35. package/out/forge-vfx/services/texture_loader.luau +174 -0
  36. package/out/forge-vfx/types.luau +43 -0
  37. package/out/index.d.ts +3 -0
  38. package/out/init.luau +5 -0
  39. package/out/shake.d.ts +2 -0
  40. package/out/shake.luau +6 -0
  41. package/out/tsconfig.tsbuildinfo +1 -0
  42. package/package.json +63 -0
@@ -0,0 +1,1183 @@
1
+ local TS = _G[script.Parent.Parent.Parent]
2
+ --!nolint LocalShadow
3
+ local attr = require(script.Parent.Parent.mod.attributes)
4
+ local tween = require(script.Parent.Parent.mod.tween)
5
+ local types = require(script.Parent.Parent.types)
6
+ local utility = require(script.Parent.Parent.mod.utility)
7
+ local bezier_common = require(script.Parent.Parent.mod.common.bezier)
8
+
9
+ local Bezier = require(script.Parent.Parent.obj.Bezier)
10
+ local ObjectCache = require(script.Parent.Parent.obj.ObjectCache)
11
+
12
+ local lightning = {}
13
+
14
+ local part_cache: ObjectCache.ObjectCache?
15
+
16
+ function lightning.init(cache)
17
+ part_cache = cache
18
+ end
19
+
20
+ function lightning.deinit()
21
+ part_cache = nil
22
+ end
23
+
24
+ local function readLightningAttributes(ref: Attachment)
25
+ return {
26
+ -- lightning
27
+ segments = math.max(attr.get(ref, "Segments", 8), 2),
28
+ jaggedness = attr.getRange(ref, "Jaggedness", NumberRange.new(0.5, 1), NumberRange.new(0, math.huge)),
29
+ offsetScale = attr.get(ref, "OffsetScale", 1),
30
+ refreshRate = attr.get(ref, "RefreshRate", 15),
31
+ independentSegments = attr.get(ref, "IndependentSegments", false),
32
+ refreshDuringDissipate = attr.get(ref, "RefreshDuringDissipate", false),
33
+ nestedEffectMode = attr.getEnum(ref, "NestedEffectMode", "None", { "None", "All", "Head", "Tail" }),
34
+
35
+ -- color
36
+ colorSequence = attr.get(ref, "Color", ColorSequence.new(Color3.new(1, 1, 1))),
37
+ colorEasingData = attr.get(ref, "Color_Curve", utility.linear_bezier),
38
+ colorDuration = attr.get(ref, "Color_Duration", 1),
39
+
40
+ -- transparency
41
+ transparencyStart = attr.get(ref, "Transparency_Start", 0),
42
+ transparencyEnd = attr.get(ref, "Transparency_End", 0),
43
+
44
+ -- fill
45
+ fillColorSequence = attr.get(ref, "Fill_Color", ColorSequence.new(Color3.new(1, 1, 1))),
46
+ fillColorEasingData = attr.get(ref, "Fill_Color_Curve", utility.linear_bezier),
47
+ fillColorDuration = attr.get(ref, "Fill_Color_Duration", 1),
48
+ fillTransparencyStart = attr.get(ref, "Fill_Transparency_Start", 1),
49
+ fillTransparencyEnd = attr.get(ref, "Fill_Transparency_End", 1),
50
+ fillDepthMode = attr.getEnum(ref, "Fill_DepthMode", "Occluded", { "AlwaysOnTop", "Occluded" }),
51
+
52
+ -- fade-in
53
+ fadeInStart = attr.get(ref, "Fade_In_Start", 1),
54
+ fadeInDuration = attr.get(ref, "Fade_In_Duration", 0),
55
+ fadeInCurveData = attr.get(ref, "Fade_In_Curve", utility.default_bezier),
56
+
57
+ -- fade-out
58
+ fadeOutEnd = attr.get(ref, "Fade_Out_End", 1),
59
+ fadeOutDuration = attr.get(ref, "Fade_Out_Duration", 0),
60
+ fadeOutCurveData = attr.get(ref, "Fade_Out_Curve", utility.default_bezier),
61
+
62
+ -- length
63
+ lengthStart = attr.get(ref, "Length_Start", 1),
64
+ lengthEnd = attr.get(ref, "Length_End", 1),
65
+
66
+ -- dissipation
67
+ dissipateMode = attr.getEnum(ref, "Dissipate_Mode", "None", { "None", "Retract", "Scale" }),
68
+ dissipateDuration = attr.get(ref, "Dissipate_Duration", 0.5),
69
+ dissipateCurveData = attr.get(ref, "Dissipate_Curve", utility.default_bezier),
70
+
71
+ -- width
72
+ widthStart = attr.get(ref, "Width_Start", 2),
73
+ widthEnd = attr.get(ref, "Width_End", 0.2),
74
+ }
75
+ end
76
+
77
+ local function createSegmentStates(count: number)
78
+ local states = {}
79
+
80
+ for j = 1, count do
81
+ states[j] = {
82
+ birthTime = nil,
83
+ initialWidth = nil,
84
+ initialTransparency = nil,
85
+ wasVisible = false,
86
+ }
87
+ end
88
+
89
+ return states
90
+ end
91
+
92
+ local function computeJaggedOffset(
93
+ rng: Random,
94
+ jaggedness: NumberRange,
95
+ offsetScale: number,
96
+ segLength: number,
97
+ right: Vector3,
98
+ up: Vector3
99
+ )
100
+ local jaggedAmount = rng:NextNumber(jaggedness.Min, jaggedness.Max)
101
+ local maxOffset = segLength * 0.8
102
+
103
+ local offsetMag = math.min(jaggedAmount * offsetScale * segLength, maxOffset)
104
+ local angle = rng:NextNumber(0, math.pi * 2)
105
+
106
+ return (right * math.cos(angle) + up * math.sin(angle)) * offsetMag
107
+ end
108
+
109
+ local function generateLightningPoints(
110
+ rng: Random,
111
+ segments: number,
112
+ getPos: (number) -> Vector3,
113
+ jaggedness: NumberRange,
114
+ offsetScale: number
115
+ )
116
+ local points = table.create(segments + 1)
117
+ local invSegments = 1 / segments
118
+
119
+ points[1] = getPos(0)
120
+
121
+ local prevPos = points[1]
122
+
123
+ for j = 1, segments - 1 do
124
+ local t = j * invSegments
125
+ local pos = getPos(t)
126
+
127
+ local nextT = math.min(t + 0.01, 1)
128
+ local tangent = getPos(nextT) - pos
129
+
130
+ tangent = if tangent.Magnitude > 0.001 then tangent.Unit else Vector3.yAxis
131
+
132
+ local right, up = bezier_common.getPerpendicularVectors(tangent)
133
+ local segmentLength = (pos - prevPos).Magnitude
134
+
135
+ local offset = computeJaggedOffset(rng, jaggedness, offsetScale, segmentLength, right, up)
136
+
137
+ pos = pos + offset
138
+ prevPos = pos
139
+
140
+ points[j + 1] = pos
141
+ end
142
+
143
+ points[segments + 1] = getPos(1)
144
+
145
+ return points
146
+ end
147
+
148
+ local function generateProjectilePoints(
149
+ rng: Random,
150
+ segments: number,
151
+ tailPos: Vector3,
152
+ headPos: Vector3,
153
+ jaggedness: NumberRange,
154
+ offsetScale: number,
155
+ fallbackDir: Vector3
156
+ )
157
+ local points = table.create(segments + 1)
158
+ local invSegments = 1 / segments
159
+
160
+ local pathDir = headPos - tailPos
161
+ local pathLength = pathDir.Magnitude
162
+
163
+ local tangent = if pathLength > 0.001 then pathDir / pathLength else fallbackDir
164
+
165
+ local right, up = bezier_common.getPerpendicularVectors(tangent)
166
+ local segLength = pathLength * invSegments
167
+
168
+ points[1] = tailPos
169
+
170
+ for j = 1, segments - 1 do
171
+ local t = j * invSegments
172
+
173
+ local pos = tailPos:Lerp(headPos, t)
174
+ local offset = computeJaggedOffset(rng, jaggedness, offsetScale, segLength, right, up)
175
+
176
+ points[j + 1] = pos + offset
177
+ end
178
+
179
+ points[segments + 1] = headPos
180
+
181
+ return points
182
+ end
183
+
184
+ export type TransitionState = {
185
+ curvedTailT: number,
186
+ curvedLengthT: number,
187
+ boltLength: number,
188
+ transitionBuffer: number,
189
+ distanceTraveled: number,
190
+ jaggedOffsets: { Vector3 }?,
191
+ }
192
+
193
+ local function getBlendedPosition(
194
+ t: number,
195
+ getPos: (number) -> Vector3,
196
+ straightTailPos: Vector3,
197
+ straightHeadPos: Vector3,
198
+ state: TransitionState
199
+ ): Vector3
200
+ -- per-point transition alpha
201
+ local pointExitDistance = (1 - t) * state.boltLength
202
+ local pointOvershoot = state.distanceTraveled - pointExitDistance
203
+ local pointAlpha = math.clamp(pointOvershoot / state.transitionBuffer, 0, 1)
204
+
205
+ -- curved position on sliding bezier
206
+ local bezierProgress = math.min(state.distanceTraveled / state.boltLength, 1) * state.curvedLengthT
207
+ local bezierT = math.min(1, state.curvedTailT + t * state.curvedLengthT + bezierProgress)
208
+
209
+ local curvedPos = getPos(bezierT)
210
+
211
+ -- straight position
212
+ local straightPos = straightTailPos:Lerp(straightHeadPos, t)
213
+
214
+ return curvedPos:Lerp(straightPos, pointAlpha)
215
+ end
216
+
217
+ local function generateTransitionPoints(
218
+ rng: Random,
219
+ segments: number,
220
+ getPos: (number) -> Vector3,
221
+ straightTailPos: Vector3,
222
+ straightHeadPos: Vector3,
223
+ jaggedness: NumberRange,
224
+ offsetScale: number,
225
+ fallbackDir: Vector3,
226
+ state: TransitionState
227
+ )
228
+ local points = table.create(segments + 1)
229
+ local offsets = table.create(segments + 1)
230
+
231
+ local invSegments = 1 / segments
232
+
233
+ local straightDir = straightHeadPos - straightTailPos
234
+ local straightLength = straightDir.Magnitude
235
+
236
+ local tangent = if straightLength > 0.001 then straightDir / straightLength else fallbackDir
237
+ local right, up = bezier_common.getPerpendicularVectors(tangent)
238
+
239
+ local segLength = straightLength * invSegments
240
+
241
+ for j = 0, segments do
242
+ local t = j * invSegments
243
+ local basePos = getBlendedPosition(t, getPos, straightTailPos, straightHeadPos, state)
244
+
245
+ if j == 0 or j == segments then
246
+ points[j + 1] = basePos
247
+ offsets[j + 1] = Vector3.zero
248
+ else
249
+ local offset = computeJaggedOffset(rng, jaggedness, offsetScale, segLength, right, up)
250
+
251
+ points[j + 1] = basePos + offset
252
+ offsets[j + 1] = offset
253
+ end
254
+ end
255
+
256
+ return points, offsets
257
+ end
258
+
259
+ local function updateTransitionPoints(
260
+ segments: number,
261
+ getPos: (number) -> Vector3,
262
+ straightTailPos: Vector3,
263
+ straightHeadPos: Vector3,
264
+ state: TransitionState
265
+ ): { Vector3 }
266
+ local points = table.create(segments + 1)
267
+
268
+ local invSegments = 1 / segments
269
+ local offsets = state.jaggedOffsets
270
+
271
+ for j = 0, segments do
272
+ local t = j * invSegments
273
+ local basePos = getBlendedPosition(t, getPos, straightTailPos, straightHeadPos, state)
274
+
275
+ points[j + 1] = basePos + (if offsets then offsets[j + 1] else Vector3.zero)
276
+ end
277
+
278
+ return points
279
+ end
280
+
281
+ local function isTransitionComplete(state: TransitionState): boolean
282
+ return state.distanceTraveled >= state.boltLength + state.transitionBuffer
283
+ end
284
+
285
+ function lightning.emit(ref: Attachment, refObj: Part, scope: types.scope, mustEmit: boolean?)
286
+ local root = ref:FindFirstChild("Points")
287
+
288
+ if not root or not root:IsA("Attachment") or not part_cache then
289
+ return
290
+ end
291
+
292
+ local common = bezier_common.readCommonAttributes(ref)
293
+ local attrs = readLightningAttributes(ref)
294
+
295
+ local drawFunc = bezier_common.drawFuncMap[common.shapeType]
296
+ and bezier_common.drawFuncMap[common.shapeType][common.shapeStyle]
297
+
298
+ if not drawFunc then
299
+ return
300
+ end
301
+
302
+ local useDuration = common.emitDuration > 0
303
+
304
+ task.wait(common.emitDelay)
305
+
306
+ if useDuration and not mustEmit then
307
+ attr.trigger(ref, "Enabled", true)
308
+
309
+ if common.speedStart ~= common.speedEnd then
310
+ attr.setState(ref, "SpeedTweening", true)
311
+
312
+ table.insert(
313
+ scope,
314
+ tween.fromParams(
315
+ attr.get(ref, "Speed_Curve", utility.default_bezier),
316
+ attr.get(ref, "Speed_Duration", 0.1),
317
+ function(alpha, deltaTime)
318
+ attr.setState(ref, "SpeedOverride", utility.lerp(common.speedStart, common.speedEnd, alpha))
319
+ return deltaTime
320
+ end,
321
+ nil,
322
+ function()
323
+ attr.setState(ref, "SpeedTweening", nil)
324
+ end
325
+ )
326
+ )
327
+ end
328
+
329
+ task.wait(common.emitDuration)
330
+
331
+ attr.trigger(ref, "Enabled", false)
332
+ attr.clearState(ref)
333
+
334
+ return
335
+ end
336
+
337
+ if common.emitCount <= 0 then
338
+ return
339
+ end
340
+
341
+ local parent = bezier_common.validateParent(ref)
342
+
343
+ if not parent then
344
+ return
345
+ end
346
+
347
+ local originCFrame, originSize = utility.getTransformedOriginExtents(parent)
348
+
349
+ if not originCFrame then
350
+ return
351
+ end
352
+
353
+ local endPoint, endT1 = bezier_common.findEndAttachments(ref)
354
+ local points = utility.getBezierPoints(root)
355
+
356
+ local rng = Random.new()
357
+ local baseBezier = not endPoint and Bezier.new(points)
358
+
359
+ local colorEasingBezier = Bezier.new(utility.deserializePath(attrs.colorEasingData), 0)
360
+ local fillColorEasingBezier = Bezier.new(utility.deserializePath(attrs.fillColorEasingData), 0)
361
+
362
+ local fadeInEasingBezier = Bezier.new(utility.deserializePath(attrs.fadeInCurveData), 0)
363
+ local fadeOutEasingBezier = Bezier.new(utility.deserializePath(attrs.fadeOutCurveData), 0)
364
+
365
+ local hitboxParams = bezier_common.createHitboxParams({
366
+ enabled = common.hitboxEnabled,
367
+ collisionGroup = common.hitboxCollisionGroup,
368
+ filterTag = common.hitboxFilterTag,
369
+ filterType = common.hitboxFilterType,
370
+ ignoreCanCollide = common.hitboxIgnoreCanCollide,
371
+ }, parent, root)
372
+
373
+ local promises = {}
374
+
375
+ for _ = 1, common.emitCount do
376
+ local duration = rng:NextNumber(common.duration.Min, common.duration.Max)
377
+ local lifetime = common.projectileEnabled
378
+ and rng:NextNumber(common.projectileLifetime.Min, common.projectileLifetime.Max)
379
+
380
+ table.insert(
381
+ promises,
382
+ TS.Promise.new(function(resolve)
383
+ local cf = bezier_common.calculateEmissionCFrame(originCFrame, originSize, {
384
+ face = common.face,
385
+ spreadAngle = common.spreadAngle,
386
+ mirror = common.mirror,
387
+ mirrorRot = common.mirrorRot,
388
+ partial = common.partial,
389
+ emissionDirection = common.emissionDirection,
390
+ }, drawFunc, rng, endPoint, parent:IsA("Attachment"))
391
+
392
+ local bezier = baseBezier or bezier_common.createBezierWithEndpoint(points, cf, endPoint, endT1)
393
+ local getPos = bezier_common.createPosGetter(bezier, points, cf, endPoint, true)
394
+
395
+ -- create segment parts
396
+ local segmentRealParts = table.create(attrs.segments)
397
+ local segmentCacheIds = table.create(attrs.segments)
398
+
399
+ local segmentState = createSegmentStates(attrs.segments)
400
+
401
+ local invSegments = 1 / attrs.segments
402
+ local segmentBounds = table.create(attrs.segments)
403
+
404
+ for j = 1, attrs.segments do
405
+ segmentBounds[j] = {
406
+ start = (j - 1) * invSegments,
407
+ finish = j * invSegments,
408
+ }
409
+ end
410
+
411
+ if not part_cache then
412
+ resolve()
413
+ return
414
+ end
415
+
416
+ -- create a highlight container if needed
417
+ local useHighlight = attrs.fillTransparencyStart < 1 or attrs.fillTransparencyEnd < 1
418
+
419
+ local highlight: Highlight?
420
+ local containerModel: Model?
421
+
422
+ if useHighlight then
423
+ local container = Instance.new("Model")
424
+ container.Name = "LightningContainer"
425
+ container.Parent = workspace.Terrain
426
+ containerModel = container
427
+
428
+ local hl = Instance.new("Highlight")
429
+ hl.Adornee = container
430
+ hl.FillColor = bezier_common.getColorWithEasingOklab(attrs.fillColorSequence, 0, fillColorEasingBezier)
431
+ hl.FillTransparency = attrs.fillTransparencyStart
432
+ hl.OutlineTransparency = 1
433
+ hl.DepthMode = Enum.HighlightDepthMode[attrs.fillDepthMode]
434
+ hl.Parent = container
435
+
436
+ highlight = hl
437
+ end
438
+
439
+ -- determine which segments get nested effects
440
+ local function shouldEmitNested(segmentIndex: number): boolean
441
+ local mode = attrs.nestedEffectMode
442
+
443
+ if mode == "All" then
444
+ return true
445
+ elseif mode == "Head" then
446
+ return segmentIndex == attrs.segments
447
+ elseif mode == "Tail" then
448
+ return segmentIndex == 1
449
+ end
450
+
451
+ return false
452
+ end
453
+
454
+ local emitOnFinish = if attrs.nestedEffectMode ~= "None"
455
+ then scope.effects.prepareEmitOnFinish(refObj, scope)
456
+ else nil
457
+
458
+ -- track segments that need nested effect emission
459
+ local nestedSegments = {}
460
+ local nestedEffectChildren = {}
461
+
462
+ -- create segment parts
463
+ for j = 1, attrs.segments do
464
+ local cacheId = utility.getRandomId()
465
+
466
+ local objAbstr = part_cache:get(cacheId)
467
+ local obj = objAbstr._getReal()
468
+
469
+ utility.copyProperties(refObj, obj, utility.COPY_PART_PROPERTIES)
470
+
471
+ obj.Size = Vector3.new(attrs.widthStart, attrs.widthStart, 1)
472
+
473
+ obj.Anchored = true
474
+ obj.CanQuery = false
475
+ obj.CanTouch = false
476
+ obj.CanCollide = false
477
+
478
+ if useHighlight and containerModel then
479
+ obj.Parent = containerModel
480
+ end
481
+
482
+ if shouldEmitNested(j) then
483
+ local clone = refObj:Clone()
484
+
485
+ for _, child in clone:GetChildren() do
486
+ child.Parent = obj
487
+
488
+ if attrs.nestedEffectMode == "Head" then
489
+ table.insert(nestedEffectChildren, child)
490
+ end
491
+ end
492
+
493
+ clone:Destroy()
494
+
495
+ table.insert(nestedSegments, j)
496
+ end
497
+
498
+ segmentRealParts[j] = obj
499
+ segmentCacheIds[j] = cacheId
500
+ end
501
+
502
+ -- for Head mode, reparent children to segment 1 initially
503
+ local currentNestedParentIndex
504
+
505
+ if attrs.nestedEffectMode == "Head" and #nestedEffectChildren > 0 then
506
+ for _, child in nestedEffectChildren do
507
+ child.Parent = segmentRealParts[1]
508
+ end
509
+ currentNestedParentIndex = 1
510
+ end
511
+
512
+ -- cleanup
513
+ table.insert(scope, function()
514
+ if part_cache then
515
+ for j = 1, attrs.segments do
516
+ part_cache:free(segmentCacheIds[j])
517
+ end
518
+
519
+ if containerModel then
520
+ containerModel:Destroy()
521
+ end
522
+ end
523
+ end)
524
+
525
+ local effectContainer = containerModel or refObj
526
+
527
+ -- animation state
528
+ local finished = false
529
+
530
+ local currentSpeed = common.speedStart
531
+ local currentWidth = attrs.widthStart
532
+ local currentLength = attrs.lengthStart
533
+ local currentTransparency = attrs.transparencyStart
534
+ local currentFillTransparency = attrs.fillTransparencyStart
535
+
536
+ local currentColorAlpha = 0
537
+ local currentFillColorAlpha = 0
538
+
539
+ local lastRefreshTime = 0
540
+
541
+ local lightningPoints =
542
+ generateLightningPoints(rng, attrs.segments, getPos, attrs.jaggedness, attrs.offsetScale)
543
+
544
+ -- emit nested effects
545
+ for _, j in nestedSegments do
546
+ local emitFrom = if attrs.nestedEffectMode == "Head" then 1 else j
547
+ local env = scope.effects.emitNested(segmentRealParts[emitFrom], scope.depth + 1)
548
+ table.insert(promises, env.Finished)
549
+ end
550
+
551
+ local lastVelocity = Vector3.zero
552
+ local lastHeadPos = Vector3.zero
553
+
554
+ local scaledElapsedTime = 0
555
+
556
+ local isDissipating = false
557
+
558
+ local currentHeadAlpha = 0
559
+
560
+ local dissipateProgress = 0
561
+ local dissipateStartTail = 0
562
+
563
+ local dissipateStartWidth = attrs.widthStart
564
+ local currentOriginCFrame = originCFrame
565
+
566
+ local projectileDir: Vector3?
567
+ local projectileHeadPos: Vector3?
568
+ local projectileTailPos: Vector3?
569
+
570
+ local transitionState: TransitionState?
571
+
572
+ local speedTween
573
+
574
+ if common.speedStart ~= common.speedEnd and not attr.getState(ref, "SpeedOverride", nil) then
575
+ speedTween = tween.fromParams(
576
+ attr.get(ref, "Speed_Curve", utility.default_bezier),
577
+ attr.get(ref, "Speed_Duration", 0.1),
578
+ function(alpha, deltaTime)
579
+ currentSpeed = utility.lerp(common.speedStart, common.speedEnd, alpha)
580
+ return deltaTime
581
+ end
582
+ )
583
+
584
+ table.insert(scope, speedTween)
585
+ end
586
+
587
+ local function getEffectiveSpeed()
588
+ return attr.getState(ref, "SpeedOverride", currentSpeed)
589
+ end
590
+
591
+ local function isSpeedTweening()
592
+ return if speedTween then speedTween.Connected else attr.getState(ref, "SpeedTweening", false)
593
+ end
594
+
595
+ local function getSpeedDelta(deltaTime: number, allowZeroSpeed: boolean?)
596
+ local speed = getEffectiveSpeed()
597
+ currentSpeed = speed
598
+
599
+ if speed > 0 then
600
+ return deltaTime * speed
601
+ elseif allowZeroSpeed or isSpeedTweening() then
602
+ return deltaTime * speed
603
+ else
604
+ return nil
605
+ end
606
+ end
607
+
608
+ local function getHeadSegmentIndex(): number
609
+ return math.clamp(math.ceil(currentHeadAlpha * attrs.segments), 1, attrs.segments)
610
+ end
611
+
612
+ local function shapecast()
613
+ if not common.hitboxEnabled then
614
+ return false
615
+ end
616
+
617
+ local headSegment = segmentRealParts[getHeadSegmentIndex()]
618
+
619
+ if headSegment and headSegment.Transparency < 1 then
620
+ local result = workspace:GetPartsInPart(headSegment, hitboxParams)
621
+
622
+ if result[1] then
623
+ return true
624
+ end
625
+ end
626
+
627
+ return false
628
+ end
629
+
630
+ -- refresh lightning points based on current state
631
+ local function refreshPoints()
632
+ if projectileHeadPos and projectileTailPos and projectileDir then
633
+ if transitionState and not isTransitionComplete(transitionState) then
634
+ lightningPoints, transitionState.jaggedOffsets = generateTransitionPoints(
635
+ rng,
636
+ attrs.segments,
637
+ getPos,
638
+ projectileTailPos,
639
+ projectileHeadPos,
640
+ attrs.jaggedness,
641
+ attrs.offsetScale,
642
+ projectileDir,
643
+ transitionState
644
+ )
645
+ else
646
+ lightningPoints = generateProjectilePoints(
647
+ rng,
648
+ attrs.segments,
649
+ projectileTailPos,
650
+ projectileHeadPos,
651
+ attrs.jaggedness,
652
+ attrs.offsetScale,
653
+ projectileDir
654
+ )
655
+
656
+ if transitionState then
657
+ transitionState.jaggedOffsets = nil
658
+ end
659
+ end
660
+ else
661
+ lightningPoints = generateLightningPoints(rng, attrs.segments, getPos, attrs.jaggedness, attrs.offsetScale)
662
+ end
663
+ end
664
+
665
+ -- update points between refreshes during transition
666
+ local function updatePointsBetweenRefreshes(deltaTime: number)
667
+ if transitionState and not isTransitionComplete(transitionState) then
668
+ if transitionState.jaggedOffsets then
669
+ lightningPoints = updateTransitionPoints(
670
+ attrs.segments,
671
+ getPos,
672
+ projectileTailPos :: Vector3,
673
+ projectileHeadPos :: Vector3,
674
+ transitionState
675
+ )
676
+ else
677
+ -- generate offsets on first frame
678
+ lightningPoints, transitionState.jaggedOffsets = generateTransitionPoints(
679
+ rng,
680
+ attrs.segments,
681
+ getPos,
682
+ projectileTailPos :: Vector3,
683
+ projectileHeadPos :: Vector3,
684
+ attrs.jaggedness,
685
+ attrs.offsetScale,
686
+ projectileDir :: Vector3,
687
+ transitionState
688
+ )
689
+ end
690
+ else
691
+ -- fully transitioned, move points along the projectile direction
692
+ local offset = projectileDir * common.projectileSpeed * deltaTime
693
+
694
+ for j = 1, #lightningPoints do
695
+ lightningPoints[j] = lightningPoints[j] + offset
696
+ end
697
+ end
698
+ end
699
+
700
+ local function updateSegments(
701
+ headAlpha: number,
702
+ tailAlphaOverride: number?,
703
+ scaledDelta: number,
704
+ allowRefresh: boolean?,
705
+ widthOverride: number?
706
+ )
707
+ local currentTime = os.clock()
708
+
709
+ currentHeadAlpha = headAlpha
710
+
711
+ local tailAlpha = tailAlphaOverride or math.max(0, headAlpha - currentLength)
712
+ local effectiveWidth = widthOverride or currentWidth
713
+
714
+ -- refresh lightning points
715
+ if allowRefresh ~= false and attrs.refreshRate > 0 then
716
+ local refreshInterval = 1 / attrs.refreshRate
717
+ lastRefreshTime += scaledDelta
718
+
719
+ if lastRefreshTime >= refreshInterval then
720
+ refreshPoints()
721
+ lastRefreshTime = 0
722
+ end
723
+ end
724
+
725
+ -- update each segment
726
+ for j = 1, attrs.segments do
727
+ local state = segmentState[j]
728
+
729
+ local bounds = segmentBounds[j]
730
+ local segment = segmentRealParts[j]
731
+
732
+ local segStart = bounds.start
733
+ local segEnd = bounds.finish
734
+
735
+ local isVisible = segEnd > tailAlpha and segStart < headAlpha
736
+
737
+ if not isVisible then
738
+ segment.Transparency = 1
739
+
740
+ if state.wasVisible then
741
+ state.wasVisible = false
742
+ state.birthTime = nil
743
+ state.initialWidth = nil
744
+ state.initialTransparency = nil
745
+ end
746
+ else
747
+ if not state.wasVisible then
748
+ state.wasVisible = true
749
+ state.birthTime = currentTime
750
+ state.initialWidth = currentWidth
751
+ state.initialTransparency = currentTransparency
752
+ end
753
+
754
+ -- color
755
+ segment.Color =
756
+ bezier_common.getColorWithEasingOklab(attrs.colorSequence, currentColorAlpha, colorEasingBezier)
757
+
758
+ -- transparency with fade in/out
759
+ local baseTransparency = if attrs.independentSegments and state.initialTransparency
760
+ then state.initialTransparency
761
+ else currentTransparency
762
+
763
+ local fadeInAlpha = if attrs.fadeInDuration > 0
764
+ then math.clamp(scaledElapsedTime / attrs.fadeInDuration, 0, 1)
765
+ else 1
766
+
767
+ local fadeInEased = 1 - fadeInEasingBezier:getEase(fadeInAlpha).y
768
+ local fadeInTransparency = utility.lerp(attrs.fadeInStart, baseTransparency, fadeInEased)
769
+
770
+ local fadeOutTransparency = baseTransparency
771
+
772
+ if isDissipating and attrs.fadeOutDuration > 0 then
773
+ local fadeOutAlpha = math.clamp(dissipateProgress / attrs.fadeOutDuration, 0, 1)
774
+ local fadeOutEased = 1 - fadeOutEasingBezier:getEase(fadeOutAlpha).y
775
+
776
+ fadeOutTransparency = utility.lerp(baseTransparency, attrs.fadeOutEnd, fadeOutEased)
777
+ end
778
+
779
+ segment.Transparency = if fadeInAlpha < 1
780
+ then fadeInTransparency
781
+ elseif isDissipating then fadeOutTransparency
782
+ else baseTransparency
783
+
784
+ -- width
785
+ local useWidth
786
+
787
+ if widthOverride then
788
+ local baseWidth = if attrs.independentSegments and state.initialWidth
789
+ then state.initialWidth
790
+ else currentWidth
791
+
792
+ useWidth = baseWidth * (if currentWidth > 0 then widthOverride / currentWidth else 0)
793
+ elseif attrs.independentSegments and state.initialWidth then
794
+ useWidth = state.initialWidth
795
+ else
796
+ useWidth = effectiveWidth
797
+ end
798
+
799
+ -- geometry
800
+ local p1 = lightningPoints[j]
801
+ local p2 = lightningPoints[j + 1]
802
+
803
+ if p1 and p2 then
804
+ local clampedP1 = if segStart < tailAlpha
805
+ then p1:Lerp(p2, (tailAlpha - segStart) / (segEnd - segStart))
806
+ else p1
807
+
808
+ local clampedP2 = if segEnd > headAlpha
809
+ then p1:Lerp(p2, (headAlpha - segStart) / (segEnd - segStart))
810
+ else p2
811
+
812
+ local midpoint = (clampedP1 + clampedP2) / 2
813
+ local length = (clampedP2 - clampedP1).Magnitude
814
+
815
+ if length > 0.001 then
816
+ segment.Size = Vector3.new(useWidth, useWidth, length)
817
+ segment.CFrame = CFrame.lookAt(midpoint, clampedP2)
818
+ end
819
+ end
820
+ end
821
+ end
822
+
823
+ -- update fill
824
+ if highlight then
825
+ highlight.FillColor = bezier_common.getColorWithEasingOklab(
826
+ attrs.fillColorSequence,
827
+ currentFillColorAlpha,
828
+ fillColorEasingBezier
829
+ )
830
+
831
+ highlight.FillTransparency = currentFillTransparency
832
+ end
833
+
834
+ -- reparent nested effect children to current head segment (Head mode)
835
+ if currentNestedParentIndex then
836
+ local headIndex = getHeadSegmentIndex()
837
+
838
+ if headIndex ~= currentNestedParentIndex then
839
+ local targetSegment = segmentRealParts[headIndex]
840
+
841
+ for _, child in nestedEffectChildren do
842
+ child.Parent = targetSegment
843
+ end
844
+
845
+ currentNestedParentIndex = headIndex
846
+ end
847
+ end
848
+
849
+ -- track velocity
850
+ local currentHeadPos = getPos(math.min(headAlpha, 1))
851
+
852
+ if scaledDelta > 0 then
853
+ lastVelocity = (currentHeadPos - lastHeadPos) / scaledDelta
854
+ end
855
+
856
+ lastHeadPos = currentHeadPos
857
+ end
858
+
859
+ -- property tweens
860
+ bezier_common.createPropertyTween(scope, ref, "Width", duration, attrs.widthStart, attrs.widthEnd, function(v)
861
+ currentWidth = v
862
+ end, getEffectiveSpeed, speedTween)
863
+
864
+ bezier_common.createPropertyTween(
865
+ scope,
866
+ ref,
867
+ "Transparency",
868
+ duration,
869
+ attrs.transparencyStart,
870
+ attrs.transparencyEnd,
871
+ function(v)
872
+ currentTransparency = v
873
+ end,
874
+ getEffectiveSpeed,
875
+ speedTween
876
+ )
877
+
878
+ bezier_common.createPropertyTween(
879
+ scope,
880
+ ref,
881
+ "Length",
882
+ duration,
883
+ attrs.lengthStart,
884
+ attrs.lengthEnd,
885
+ function(v)
886
+ currentLength = v
887
+ end,
888
+ getEffectiveSpeed,
889
+ speedTween
890
+ )
891
+
892
+ if useHighlight then
893
+ bezier_common.createPropertyTween(
894
+ scope,
895
+ ref,
896
+ "FillTransparency",
897
+ attrs.fillColorDuration,
898
+ attrs.fillTransparencyStart,
899
+ attrs.fillTransparencyEnd,
900
+ function(v)
901
+ currentFillTransparency = v
902
+ end,
903
+ getEffectiveSpeed,
904
+ speedTween
905
+ )
906
+ end
907
+
908
+ -- dissipation handler
909
+ local function handleDissipation(
910
+ headAlpha: number,
911
+ startTail: number,
912
+ startWidth: number,
913
+ curveData: string,
914
+ dissipationDuration: number,
915
+ mode: string,
916
+ onComplete: () -> ()
917
+ )
918
+ isDissipating = true
919
+ dissipateStartTail = startTail
920
+ dissipateStartWidth = startWidth
921
+
922
+ tween.fromParams(curveData, dissipationDuration, function(alpha, deltaTime, elapsed)
923
+ dissipateProgress = elapsed
924
+
925
+ local scaledDelta = deltaTime * getEffectiveSpeed()
926
+ scaledElapsedTime += scaledDelta
927
+
928
+ if attrs.refreshDuringDissipate then
929
+ currentColorAlpha = (currentColorAlpha + scaledDelta / attrs.colorDuration) % 1
930
+ currentFillColorAlpha = (currentFillColorAlpha + scaledDelta / attrs.fillColorDuration) % 1
931
+ end
932
+
933
+ local tailOverride = if mode == "Retract" then utility.lerp(dissipateStartTail, headAlpha, alpha) else nil
934
+ local widthOverrideVal = if mode == "Scale" then utility.lerp(dissipateStartWidth, 0, alpha) else nil
935
+
936
+ updateSegments(headAlpha, tailOverride, scaledDelta, attrs.refreshDuringDissipate, widthOverrideVal)
937
+
938
+ return getSpeedDelta(deltaTime, true)
939
+ end, speedTween, onComplete, true, utility.RENDER_PRIORITY + scope.depth)
940
+ end
941
+
942
+ local function startDissipation()
943
+ if isDissipating or finished then
944
+ return
945
+ end
946
+
947
+ local headAlpha = currentHeadAlpha
948
+
949
+ local finishEnv = if emitOnFinish
950
+ then scope.effects.emitOnFinish(emitOnFinish, effectContainer, scope.depth + 1)
951
+ else nil
952
+
953
+ local function onDissipationComplete()
954
+ if finished then
955
+ return
956
+ end
957
+
958
+ finished = true
959
+
960
+ if finishEnv then
961
+ finishEnv.Finished:finally(function()
962
+ resolve()
963
+ end)
964
+ else
965
+ resolve()
966
+ end
967
+ end
968
+
969
+ if attrs.dissipateMode ~= "None" then
970
+ handleDissipation(
971
+ headAlpha,
972
+ math.max(0, headAlpha - currentLength),
973
+ currentWidth,
974
+ attrs.dissipateCurveData,
975
+ attrs.dissipateDuration,
976
+ attrs.dissipateMode,
977
+ onDissipationComplete
978
+ )
979
+ elseif attrs.fadeOutDuration > 0 then
980
+ handleDissipation(
981
+ headAlpha,
982
+ math.max(0, headAlpha - currentLength),
983
+ currentWidth,
984
+ attrs.fadeOutCurveData,
985
+ attrs.fadeOutDuration,
986
+ "None",
987
+ onDissipationComplete
988
+ )
989
+ else
990
+ onDissipationComplete()
991
+ end
992
+ end
993
+
994
+ -- main animation
995
+ table.insert(
996
+ scope,
997
+ tween.fromParams(
998
+ attr.get(ref, "Easing_Curve", utility.linear_bezier),
999
+ duration,
1000
+ function(alpha, deltaTime, elapsed)
1001
+ local scaledDelta = deltaTime * getEffectiveSpeed()
1002
+ scaledElapsedTime += scaledDelta
1003
+
1004
+ currentColorAlpha = (currentColorAlpha + scaledDelta / attrs.colorDuration) % 1
1005
+ currentFillColorAlpha = (currentFillColorAlpha + scaledDelta / attrs.fillColorDuration) % 1
1006
+
1007
+ if common.syncPosition then
1008
+ local newOrigin = utility.getTransformedOriginExtents(parent)
1009
+ local offset = newOrigin * currentOriginCFrame:Inverse()
1010
+
1011
+ for j = 1, #lightningPoints do
1012
+ lightningPoints[j] =
1013
+ offset:PointToWorldSpace(currentOriginCFrame:PointToObjectSpace(lightningPoints[j]))
1014
+ end
1015
+
1016
+ currentOriginCFrame = newOrigin
1017
+ end
1018
+
1019
+ updateSegments(alpha, nil, scaledDelta)
1020
+
1021
+ if shapecast() then
1022
+ startDissipation()
1023
+ return nil
1024
+ end
1025
+
1026
+ local speedDelta = getSpeedDelta(deltaTime)
1027
+
1028
+ if speedDelta == nil then
1029
+ return nil
1030
+ end
1031
+
1032
+ if (common.projectileEnabled and alpha * duration < lifetime) or not common.projectileEnabled then
1033
+ return speedDelta
1034
+ else
1035
+ startDissipation()
1036
+ return nil
1037
+ end
1038
+ end,
1039
+ speedTween,
1040
+ function()
1041
+ if isDissipating or finished then
1042
+ return
1043
+ end
1044
+
1045
+ -- non-projectile mode
1046
+ if not common.projectileEnabled then
1047
+ if common.destroyDelay > 0 then
1048
+ tween.fromParams(
1049
+ utility.linear_bezier,
1050
+ common.destroyDelay,
1051
+ function(_, deltaTime)
1052
+ local scaledDelta = deltaTime * getEffectiveSpeed()
1053
+ scaledElapsedTime += scaledDelta
1054
+
1055
+ currentColorAlpha = (currentColorAlpha + scaledDelta / attrs.colorDuration) % 1
1056
+ currentFillColorAlpha = (currentFillColorAlpha + scaledDelta / attrs.fillColorDuration) % 1
1057
+
1058
+ updateSegments(1, nil, scaledDelta, true)
1059
+
1060
+ if shapecast() then
1061
+ startDissipation()
1062
+ return nil
1063
+ end
1064
+
1065
+ return getSpeedDelta(deltaTime, true)
1066
+ end,
1067
+ speedTween,
1068
+ function()
1069
+ startDissipation()
1070
+ end,
1071
+ true,
1072
+ utility.RENDER_PRIORITY + scope.depth
1073
+ )
1074
+ else
1075
+ startDissipation()
1076
+ end
1077
+
1078
+ return
1079
+ end
1080
+
1081
+ -- projectile mode
1082
+ local dir = if common.projectileMatchEnd and endPoint
1083
+ then endPoint.WorldCFrame.LookVector
1084
+ else lastVelocity.Unit
1085
+
1086
+ if dir ~= dir or dir.Magnitude < 0.001 then
1087
+ dir = Vector3.zAxis
1088
+ else
1089
+ dir = dir.Unit
1090
+ end
1091
+
1092
+ local initialHeadPos = getPos(1)
1093
+ local initialTailPos = getPos(math.max(0, 1 - currentLength))
1094
+
1095
+ local boltLength = (initialHeadPos - initialTailPos).Magnitude
1096
+
1097
+ local curvedTailT = math.max(0, 1 - currentLength)
1098
+ local curvedLengthT = 1 - curvedTailT
1099
+
1100
+ local transitionBuffer = boltLength * 0.3
1101
+
1102
+ -- preserve existing jaggedness offsets from bezier phase
1103
+ local existingOffsets = table.create(attrs.segments + 1)
1104
+
1105
+ for j = 0, attrs.segments do
1106
+ local t = j / attrs.segments
1107
+ local bezierT = curvedTailT + t * curvedLengthT
1108
+ local basePos = getPos(bezierT)
1109
+
1110
+ existingOffsets[j + 1] = lightningPoints[j + 1] - basePos
1111
+ end
1112
+
1113
+ local state: TransitionState = {
1114
+ curvedTailT = curvedTailT,
1115
+ curvedLengthT = curvedLengthT,
1116
+ boltLength = boltLength,
1117
+ transitionBuffer = transitionBuffer,
1118
+ distanceTraveled = 0,
1119
+ jaggedOffsets = existingOffsets,
1120
+ }
1121
+
1122
+ transitionState = state
1123
+
1124
+ tween.timer(lifetime, function(deltaTime, elapsed)
1125
+ local scaledDelta = deltaTime * getEffectiveSpeed()
1126
+ scaledElapsedTime += scaledDelta
1127
+
1128
+ currentColorAlpha = (currentColorAlpha + scaledDelta / attrs.colorDuration) % 1
1129
+ currentFillColorAlpha = (currentFillColorAlpha + scaledDelta / attrs.fillColorDuration) % 1
1130
+
1131
+ local distanceTraveled = elapsed * common.projectileSpeed
1132
+ state.distanceTraveled = distanceTraveled
1133
+
1134
+ local currentHeadPos = initialHeadPos + dir * distanceTraveled
1135
+ local currentTailPos = currentHeadPos - dir * boltLength
1136
+
1137
+ projectileHeadPos = currentHeadPos
1138
+ projectileTailPos = currentTailPos
1139
+ projectileDir = dir
1140
+
1141
+ local shouldRefresh = attrs.refreshRate > 0
1142
+
1143
+ if shouldRefresh then
1144
+ lastRefreshTime += scaledDelta
1145
+
1146
+ if lastRefreshTime >= 1 / attrs.refreshRate then
1147
+ refreshPoints()
1148
+ lastRefreshTime = 0
1149
+ else
1150
+ updatePointsBetweenRefreshes(deltaTime)
1151
+ end
1152
+ else
1153
+ updatePointsBetweenRefreshes(deltaTime)
1154
+ end
1155
+
1156
+ updateSegments(1, nil, scaledDelta, false)
1157
+
1158
+ if shapecast() then
1159
+ startDissipation()
1160
+ return nil
1161
+ end
1162
+
1163
+ if elapsed >= lifetime then
1164
+ return nil
1165
+ end
1166
+
1167
+ return getSpeedDelta(deltaTime, true)
1168
+ end, speedTween, scope, utility.RENDER_PRIORITY + scope.depth)
1169
+
1170
+ startDissipation()
1171
+ end,
1172
+ true,
1173
+ utility.RENDER_PRIORITY + scope.depth
1174
+ )
1175
+ )
1176
+ end)
1177
+ )
1178
+ end
1179
+
1180
+ TS.Promise.all(promises):await()
1181
+ end
1182
+
1183
+ return lightning