@adiholick/nvstudio-mcp 2.3.0 → 2.4.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.
@@ -0,0 +1,138 @@
1
+ --!strict
2
+ -- ══════════════════════════════════════════════════════════════════════
3
+ -- NVStudio Subsystem: nv_joint_math
4
+ -- Deskripsi: Modul Matematika Kinematika Sambungan (Joints) & Transformasi
5
+ -- Menyediakan kalkulasi C0/C1 presisi, pemindahan poros tanpa dislokasi,
6
+ -- rotasi poros, penyelarasan koordinat, dan pencerminan simetri bilateral.
7
+ -- ══════════════════════════════════════════════════════════════════════
8
+
9
+ local JointMath = {}
10
+
11
+ local EPSILON = 1e-6
12
+
13
+ -- ── 1. Hitung Offset Awal C0 & C1 ─────────────────────────────────────────────
14
+ -- Menghubungkan Part0 dan Part1 tanpa mengubah posisi relatif fisik saat ini
15
+ function JointMath.computeInitialOffsets(part0: BasePart, part1: BasePart): (CFrame, CFrame)
16
+ local c0 = part0.CFrame:ToObjectSpace(part1.CFrame)
17
+ local c1 = CFrame.identity
18
+ return c0, c1
19
+ end
20
+
21
+ -- ── 2. Geser Poros Sendi di Koordinat Dunia (World Delta) ─────────────────────
22
+ -- Menggeser titik pivot tanpa mendislokasi posisi part anak di dunia 3D
23
+ function JointMath.offsetPivot(joint: JointInstance, deltaWorld: Vector3): boolean
24
+ local part0 = joint.Part0
25
+ local part1 = joint.Part1
26
+ if not part0 or not part1 then return false end
27
+
28
+ local origJointWorld = part0.CFrame * joint.C0
29
+ local desiredWorld = origJointWorld + deltaWorld
30
+
31
+ local newC0 = part0.CFrame:ToObjectSpace(desiredWorld)
32
+ local diff = joint.C0:ToObjectSpace(newC0)
33
+ local newC1 = joint.C1 * diff
34
+
35
+ joint.C0 = newC0
36
+ joint.C1 = newC1
37
+ return true
38
+ end
39
+
40
+ -- ── 3. Tetapkan Posisi Poros ke Titik Dunia Absolut (Set World Position) ──────
41
+ -- Memindahkan titik tumpu sendi tepat ke koordinat 3D yang diinginkan
42
+ function JointMath.setPivotWorld(joint: JointInstance, targetWorldPos: Vector3): boolean
43
+ local part0 = joint.Part0
44
+ local part1 = joint.Part1
45
+ if not part0 or not part1 then return false end
46
+
47
+ local origJointWorld = part0.CFrame * joint.C0
48
+ local targetWorldCF = CFrame.new(targetWorldPos) * origJointWorld.Rotation
49
+
50
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
51
+ local diff = joint.C0:ToObjectSpace(newC0)
52
+ local newC1 = joint.C1 * diff
53
+
54
+ joint.C0 = newC0
55
+ joint.C1 = newC1
56
+ return true
57
+ end
58
+
59
+ -- ── 4. Rotasi Poros Sendi Tanpa Merusak Rig ───────────────────────────────────
60
+ -- Memutar orientasi poros sendi sebesar sudut Euler radian
61
+ function JointMath.rotatePivot(joint: JointInstance, rotEulerRad: Vector3): boolean
62
+ local part0 = joint.Part0
63
+ local part1 = joint.Part1
64
+ if not part0 or not part1 then return false end
65
+
66
+ local rotCF = CFrame.Angles(rotEulerRad.X, rotEulerRad.Y, rotEulerRad.Z)
67
+ joint.C0 = joint.C0 * rotCF
68
+ joint.C1 = joint.C1 * rotCF
69
+ return true
70
+ end
71
+
72
+ -- ── 5. Reset Poros Sendi ke Pusat Part Anak ───────────────────────────────────
73
+ -- Mengembalikan poros sendi ke origin geometris Part1
74
+ function JointMath.resetPivot(joint: JointInstance): boolean
75
+ local part0 = joint.Part0
76
+ local part1 = joint.Part1
77
+ if not part0 or not part1 then return false end
78
+
79
+ joint.C0 = part0.CFrame:ToObjectSpace(part1.CFrame)
80
+ joint.C1 = CFrame.identity
81
+ return true
82
+ end
83
+
84
+ -- ── 6. Selaraskan Orientasi Poros ke Part0 atau Part1 ─────────────────────────
85
+ -- Menjaga posisi poros di dunia tetap sama, tetapi rotasinya mengikuti part referensi
86
+ function JointMath.alignJoint(joint: JointInstance, alignTo: "Part0" | "Part1"): boolean
87
+ local part0 = joint.Part0
88
+ local part1 = joint.Part1
89
+ if not part0 or not part1 then return false end
90
+
91
+ local pivotWorldPos = (part0.CFrame * joint.C0).Position
92
+ local refCF = alignTo == "Part0" and part0.CFrame or part1.CFrame
93
+ local targetWorldCF = CFrame.new(pivotWorldPos) * refCF.Rotation
94
+
95
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
96
+ local diff = joint.C0:ToObjectSpace(newC0)
97
+ local newC1 = joint.C1 * diff
98
+
99
+ joint.C0 = newC0
100
+ joint.C1 = newC1
101
+ return true
102
+ end
103
+
104
+ -- ── 7. Refleksi CFrame Sumbu X Simetris (Bilateral Mirroring) ──────────────────
105
+ -- Mencerminkan CFrame melewati bidang sagittal (sumbu X)
106
+ function JointMath.mirrorCFrame(cf: CFrame): CFrame
107
+ local x, y, z,
108
+ r00, r01, r02,
109
+ r10, r11, r12,
110
+ r20, r21, r22 = cf:GetComponents()
111
+
112
+ -- Refleksi koordinat posisi dan rotasi M_x * R * M_x
113
+ return CFrame.new(
114
+ -x, y, z,
115
+ r00, -r01, -r02,
116
+ -r10, r11, r12,
117
+ -r20, r21, r22
118
+ )
119
+ end
120
+
121
+ -- ── 8. Cerminkan Sendi Bilateral Kiri <-> Kanan ────────────────────────────────
122
+ -- Menerapkan C0 dan C1 yang telah dicerminkan dari sumber ke sendi target
123
+ function JointMath.mirrorJoint(sourceJoint: JointInstance, targetJoint: JointInstance): boolean
124
+ if not sourceJoint or not targetJoint then return false end
125
+ targetJoint.C0 = JointMath.mirrorCFrame(sourceJoint.C0)
126
+ targetJoint.C1 = JointMath.mirrorCFrame(sourceJoint.C1)
127
+ return true
128
+ end
129
+
130
+ -- ── 9. Dapatkan Posisi Poros di Dunia ─────────────────────────────────────────
131
+ function JointMath.getJointWorldPosition(joint: JointInstance): Vector3?
132
+ local part0 = joint.Part0
133
+ if not part0 then return nil end
134
+ return (part0.CFrame * joint.C0).Position
135
+ end
136
+
137
+ return JointMath
138
+
@@ -0,0 +1,248 @@
1
+ --!strict
2
+ -- ══════════════════════════════════════════════════════════════════════
3
+ -- NVStudio Subsystem: nv_kinematics
4
+ -- Deskripsi: Mesin Kinematika Analitis 2-Link LimbIK & Abstraksi R6/R15
5
+ -- Menyelesaikan posisi siku/lutut menggunakan Hukum Cosinus O(1) tertutup,
6
+ -- ekstensi SoftIK anti-hentakan kaku, dan stabilisasi rotasi leher/kaki.
7
+ -- ══════════════════════════════════════════════════════════════════════
8
+
9
+ local Kinematics = {}
10
+ local EPSILON = 1e-6
11
+
12
+ -- ── 1. SoftIK Algorithm ───────────────────────────────────────────────────────
13
+ local function softDistance(dist: number, reach: number, softPercent: number?): number
14
+ if dist <= 0 or reach <= 0 then return 0 end
15
+ local pct = math.clamp(softPercent or 90, 0, 100) / 100
16
+ local onset = reach * pct
17
+ if dist <= onset then
18
+ return dist
19
+ end
20
+ local zone = reach - onset
21
+ if zone <= 0 then
22
+ return math.min(dist, reach)
23
+ end
24
+ local x = (dist - onset) / zone
25
+ local eased = x < 1e-4 and (x * (1 - x * 0.5)) or (1 - math.exp(-x))
26
+ return math.min(dist, reach, onset + zone * eased)
27
+ end
28
+
29
+ -- ── 2. Rotation Between Two Vectors ───────────────────────────────────────────
30
+ function Kinematics.rotationBetween(from: Vector3, to: Vector3): CFrame
31
+ if from.Magnitude < EPSILON or to.Magnitude < EPSILON then
32
+ return CFrame.identity
33
+ end
34
+ from = from.Unit
35
+ to = to.Unit
36
+ local dot = math.clamp(from:Dot(to), -1, 1)
37
+ local cross = from:Cross(to)
38
+ if cross.Magnitude < EPSILON then
39
+ if dot > 0 then
40
+ return CFrame.identity
41
+ end
42
+ local basis = math.abs(from.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
43
+ return CFrame.fromAxisAngle(from:Cross(basis).Unit, math.pi)
44
+ end
45
+ return CFrame.fromAxisAngle(cross.Unit, math.atan2(cross.Magnitude, dot))
46
+ end
47
+
48
+ -- ── 3. Analytical 2-Bone Limb Solver (Law of Cosines) ─────────────────────────
49
+ function Kinematics.solveTwoLink(
50
+ root: Vector3,
51
+ middle: Vector3,
52
+ tip: Vector3,
53
+ goal: Vector3,
54
+ pole: Vector3?,
55
+ softPercent: number?
56
+ ): (Vector3, Vector3)
57
+ local upperLen = (middle - root).Magnitude
58
+ local lowerLen = (tip - middle).Magnitude
59
+ if upperLen < EPSILON or lowerLen < EPSILON then
60
+ return middle, tip
61
+ end
62
+
63
+ local delta = goal - root
64
+ local rawDist = delta.Magnitude
65
+ local dir = rawDist > EPSILON and delta.Unit or (tip - root).Unit
66
+ local reach = upperLen + lowerLen
67
+
68
+ -- Soften distance
69
+ local softened = softDistance(rawDist, reach, softPercent or 90)
70
+ local dist = math.clamp(softened, math.max(math.abs(upperLen - lowerLen), EPSILON), reach - 1e-4)
71
+
72
+ -- Bend direction towards pole
73
+ local polePos = pole or (middle + Vector3.new(0, 0, -1))
74
+ local bend = polePos - root
75
+ bend = bend - dir * bend:Dot(dir)
76
+ if bend.Magnitude < EPSILON then
77
+ local basis = math.abs(dir.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
78
+ bend = basis - dir * basis:Dot(dir)
79
+ end
80
+ bend = bend.Unit
81
+
82
+ -- Law of Cosines: Upper arm projection along direction vector
83
+ local along = (upperLen * upperLen - lowerLen * lowerLen + dist * dist) / (2 * dist)
84
+ local heightSq = upperLen * upperLen - along * along
85
+ local height = math.sqrt(math.max(0, heightSq))
86
+
87
+ local solvedMiddle = root + dir * along + bend * height
88
+ local solvedTip = root + dir * dist
89
+ return solvedMiddle, solvedTip
90
+ end
91
+
92
+ -- ── 4. Rig Discovery & Abstraction (R6 & R15) ──────────────────────────────────
93
+ export type RigInfo = {
94
+ model: Model,
95
+ isR15: boolean,
96
+ rootPart: BasePart,
97
+ motors: { [string]: Motor6D },
98
+ parts: { [string]: BasePart }
99
+ }
100
+
101
+ function Kinematics.inspectRig(model: Model): RigInfo?
102
+ if not model or not model:IsA("Model") then return nil end
103
+ local rootPart = model:FindFirstChild("HumanoidRootPart") :: BasePart?
104
+ if not rootPart then
105
+ rootPart = model.PrimaryPart or model:FindFirstChildWhichIsA("BasePart")
106
+ end
107
+ if not rootPart then return nil end
108
+
109
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
110
+ local isR15 = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15) or false
111
+ if not humanoid then
112
+ isR15 = model:FindFirstChild("UpperTorso") ~= nil
113
+ end
114
+
115
+ local motors: { [string]: Motor6D } = {}
116
+ local parts: { [string]: BasePart } = {}
117
+
118
+ for _, desc in ipairs(model:GetDescendants()) do
119
+ if desc:IsA("Motor6D") then
120
+ motors[desc.Name] = desc
121
+ elseif desc:IsA("BasePart") then
122
+ parts[desc.Name] = desc
123
+ end
124
+ end
125
+
126
+ return {
127
+ model = model,
128
+ isR15 = isR15,
129
+ rootPart = rootPart,
130
+ motors = motors,
131
+ parts = parts
132
+ }
133
+ end
134
+
135
+ -- ── 5. Solve Limb IK on R15 / R6 Rig ──────────────────────────────────────────
136
+ function Kinematics.solveLimb(
137
+ rigInfo: RigInfo,
138
+ limbName: string, -- "LeftArm" | "RightArm" | "LeftLeg" | "RightLeg"
139
+ targetWorldPos: Vector3,
140
+ poleWorldPos: Vector3?,
141
+ applyToRig: boolean?
142
+ ): { [string]: CFrame }?
143
+ local m = rigInfo.motors
144
+ local p = rigInfo.parts
145
+
146
+ if rigInfo.isR15 then
147
+ local upperMotorName, lowerMotorName, endMotorName
148
+ local upperPartName, lowerPartName, endPartName
149
+ local defaultPoleOffset = Vector3.new(0, 0, -2)
150
+
151
+ if limbName == "LeftArm" then
152
+ upperMotorName, lowerMotorName, endMotorName = "LeftShoulder", "LeftElbow", "LeftWrist"
153
+ upperPartName, lowerPartName, endPartName = "LeftUpperArm", "LeftLowerArm", "LeftHand"
154
+ defaultPoleOffset = Vector3.new(-1, 0, -2)
155
+ elseif limbName == "RightArm" then
156
+ upperMotorName, lowerMotorName, endMotorName = "RightShoulder", "RightElbow", "RightWrist"
157
+ upperPartName, lowerPartName, endPartName = "RightUpperArm", "RightLowerArm", "RightHand"
158
+ defaultPoleOffset = Vector3.new(1, 0, -2)
159
+ elseif limbName == "LeftLeg" then
160
+ upperMotorName, lowerMotorName, endMotorName = "LeftHip", "LeftKnee", "LeftAnkle"
161
+ upperPartName, lowerPartName, endPartName = "LeftUpperLeg", "LeftLowerLeg", "LeftFoot"
162
+ defaultPoleOffset = Vector3.new(0, 0, 2)
163
+ elseif limbName == "RightLeg" then
164
+ upperMotorName, lowerMotorName, endMotorName = "RightHip", "RightKnee", "RightAnkle"
165
+ upperPartName, lowerPartName, endPartName = "RightUpperLeg", "RightLowerLeg", "RightFoot"
166
+ defaultPoleOffset = Vector3.new(0, 0, 2)
167
+ else
168
+ return nil
169
+ end
170
+
171
+ local upperMotor = m[upperMotorName]
172
+ local lowerMotor = m[lowerMotorName]
173
+ local upperPart = p[upperPartName]
174
+ local lowerPart = p[lowerPartName]
175
+ local endPart = p[endPartName]
176
+
177
+ if not upperMotor or not lowerMotor or not upperPart or not lowerPart or not endPart then
178
+ return nil
179
+ end
180
+
181
+ local rootPos = upperPart.Position
182
+ local midPos = lowerPart.Position
183
+ local tipPos = endPart.Position
184
+ local pole = poleWorldPos or (midPos + defaultPoleOffset)
185
+
186
+ local solvedMid, solvedTip = Kinematics.solveTwoLink(rootPos, midPos, tipPos, targetWorldPos, pole, 90)
187
+
188
+ -- Hitung C0 baru untuk upper dan lower
189
+ local upperParentCF = upperMotor.Part0.CFrame
190
+ local upperGoalWorldCF = CFrame.lookAt(rootPos, solvedMid, pole - rootPos)
191
+ local upperC0 = upperParentCF:Inverse() * upperGoalWorldCF * upperMotor.C1
192
+
193
+ local lowerParentCF = solvedMid ~= rootPos and CFrame.lookAt(rootPos, solvedMid) or upperPart.CFrame
194
+ local lowerGoalWorldCF = CFrame.lookAt(solvedMid, solvedTip, pole - solvedMid)
195
+ local lowerC0 = lowerParentCF:Inverse() * lowerGoalWorldCF * lowerMotor.C1
196
+
197
+ if applyToRig then
198
+ upperMotor.C0 = upperC0
199
+ lowerMotor.C0 = lowerC0
200
+ end
201
+
202
+ return {
203
+ [upperMotorName] = upperC0,
204
+ [lowerMotorName] = lowerC0
205
+ }
206
+ else
207
+ -- Mode R6: 1 bone per limb, arahkan ke targetWorldPos
208
+ local motorName, partName
209
+ if limbName == "LeftArm" then motorName, partName = "Left Shoulder", "Left Arm"
210
+ elseif limbName == "RightArm" then motorName, partName = "Right Shoulder", "Right Arm"
211
+ elseif limbName == "LeftLeg" then motorName, partName = "Left Hip", "Left Leg"
212
+ elseif limbName == "RightLeg" then motorName, partName = "Right Hip", "Right Leg"
213
+ else return nil end
214
+
215
+ local motor = m[motorName]
216
+ local part = p[partName]
217
+ if not motor or not part or not motor.Part0 then return nil end
218
+
219
+ local rootPos = part.Position
220
+ local goalCF = CFrame.lookAt(rootPos, targetWorldPos)
221
+ local c0 = motor.Part0.CFrame:Inverse() * goalCF * motor.C1
222
+
223
+ if applyToRig then
224
+ motor.C0 = c0
225
+ end
226
+ return { [motorName] = c0 }
227
+ end
228
+ end
229
+
230
+ -- ── 6. Head Look-At Stabilizer ────────────────────────────────────────────────
231
+ function Kinematics.solveLookAt(rigInfo: RigInfo, targetWorldPos: Vector3, applyToRig: boolean?): CFrame?
232
+ local neckMotor = rigInfo.motors["Neck"]
233
+ local headPart = rigInfo.parts["Head"]
234
+ if not neckMotor or not headPart or not neckMotor.Part0 then return nil end
235
+
236
+ local headPos = headPart.Position
237
+ local lookDir = (targetWorldPos - headPos).Unit
238
+ local targetWorldCF = CFrame.lookAt(headPos, headPos + lookDir)
239
+ local neckC0 = neckMotor.Part0.CFrame:Inverse() * targetWorldCF * neckMotor.C1
240
+
241
+ if applyToRig then
242
+ neckMotor.C0 = neckC0
243
+ end
244
+ return neckC0
245
+ end
246
+
247
+ return Kinematics
248
+
@@ -0,0 +1,279 @@
1
+ --!strict
2
+ -- ══════════════════════════════════════════════════════════════════════
3
+ -- NVStudio Subsystem: nv_motion_generator
4
+ -- Deskripsi: Generator Gerakan Karakter Alami (Human-like Motion Synthesis)
5
+ -- Menghasilkan pose kunci realistis (Anticipation, Follow-through, Easing)
6
+ -- untuk berjalan, berlari, tebasan pedang, tinju, idle, dan lompat.
7
+ -- ══════════════════════════════════════════════════════════════════════
8
+
9
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
10
+ local Curves = require(curvesMod)
11
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
12
+ local Kinematics = require(kinematicsMod)
13
+
14
+ local MotionGenerator = {}
15
+
16
+ export type KeyframeData = {
17
+ time: number, -- 0.0 to duration
18
+ poses: { [string]: CFrame } -- motorName -> C0 CFrame
19
+ }
20
+
21
+ -- ── Helper Angles ─────────────────────────────────────────────────────────────
22
+ local function anglesDeg(x: number, y: number, z: number): CFrame
23
+ return CFrame.Angles(math.rad(x), math.rad(y), math.rad(z))
24
+ end
25
+
26
+ -- ── 1. Generator: Walk Cycle ──────────────────────────────────────────────────
27
+ function MotionGenerator.generateWalkCycle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
28
+ local opts = options or {}
29
+ local duration = tonumber(opts.duration) or 1.0
30
+ local intensity = tonumber(opts.intensity) or 1.0
31
+ local isR15 = rigInfo.isR15
32
+
33
+ -- Base offsets per motor
34
+ local m = rigInfo.motors
35
+ local defaultC0 = {}
36
+ for name, motor in pairs(m) do
37
+ defaultC0[name] = motor.C0
38
+ end
39
+
40
+ local legAngle = 26 * intensity
41
+ local armAngle = 22 * intensity
42
+ local torsoBob = 0.12 * intensity
43
+ local torsoTwist = 6 * intensity
44
+
45
+ local kfTimes = { 0.0, 0.25, 0.50, 0.75, 1.0 }
46
+ local keyframes: { KeyframeData } = {}
47
+
48
+ for _, tFrac in ipairs(kfTimes) do
49
+ local tSec = tFrac * duration
50
+ local poses: { [string]: CFrame } = {}
51
+
52
+ if isR15 then
53
+ local legPhase = math.sin(tFrac * math.pi * 2)
54
+ local armPhase = -legPhase
55
+
56
+ -- Torso movement (vertical bounce on each step)
57
+ local bobY = math.abs(math.cos(tFrac * math.pi * 2)) * torsoBob
58
+ local rootC0 = defaultC0["Root"] or CFrame.identity
59
+ poses["Root"] = rootC0 * CFrame.new(0, -bobY, 0) * anglesDeg(4, legPhase * torsoTwist, 0)
60
+
61
+ -- Left Leg & Right Leg
62
+ local lHip = defaultC0["LeftHip"] or CFrame.identity
63
+ local rHip = defaultC0["RightHip"] or CFrame.identity
64
+ local lKnee = defaultC0["LeftKnee"] or CFrame.identity
65
+ local rKnee = defaultC0["RightKnee"] or CFrame.identity
66
+
67
+ poses["LeftHip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
68
+ poses["RightHip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
69
+
70
+ -- Knee bends when leg swings back
71
+ local lKneeBend = math.clamp(-legPhase * legAngle * 1.5, 0, 45)
72
+ local rKneeBend = math.clamp(legPhase * legAngle * 1.5, 0, 45)
73
+ poses["LeftKnee"] = lKnee * anglesDeg(lKneeBend, 0, 0)
74
+ poses["RightKnee"] = rKnee * anglesDeg(rKneeBend, 0, 0)
75
+
76
+ -- Left Arm & Right Arm (opposite to legs)
77
+ local lShoulder = defaultC0["LeftShoulder"] or CFrame.identity
78
+ local rShoulder = defaultC0["RightShoulder"] or CFrame.identity
79
+ poses["LeftShoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, -4)
80
+ poses["RightShoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 4)
81
+ else
82
+ -- R6 Rig
83
+ local legPhase = math.sin(tFrac * math.pi * 2)
84
+ local armPhase = -legPhase
85
+
86
+ local lShoulder = defaultC0["Left Shoulder"] or CFrame.identity
87
+ local rShoulder = defaultC0["Right Shoulder"] or CFrame.identity
88
+ local lHip = defaultC0["Left Hip"] or CFrame.identity
89
+ local rHip = defaultC0["Right Hip"] or CFrame.identity
90
+
91
+ poses["Left Shoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, 0)
92
+ poses["Right Shoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 0)
93
+ poses["Left Hip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
94
+ poses["Right Hip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
95
+ end
96
+
97
+ table.insert(keyframes, { time = tSec, poses = poses })
98
+ end
99
+
100
+ return keyframes
101
+ end
102
+
103
+ -- ── 2. Generator: Melee Slash (Combat Attack) ─────────────────────────────────
104
+ function MotionGenerator.generateMeleeSlash(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
105
+ local opts = options or {}
106
+ local duration = tonumber(opts.duration) or 0.8
107
+ local isR15 = rigInfo.isR15
108
+
109
+ local defaultC0 = {}
110
+ for name, motor in pairs(rigInfo.motors) do
111
+ defaultC0[name] = motor.C0
112
+ end
113
+
114
+ -- Keyframes: 0 (Ready), 0.25 (Wind-up Anticipation), 0.50 (Slash Snap), 0.75 (Follow-Through), 1.0 (Recover)
115
+ local points = {
116
+ {
117
+ t = 0.0,
118
+ armRot = anglesDeg(10, 0, 5),
119
+ torsoRot = anglesDeg(0, 0, 0),
120
+ lArmRot = anglesDeg(5, 0, -5)
121
+ },
122
+ {
123
+ t = 0.25, -- Wind-up
124
+ armRot = anglesDeg(-110, -35, 40),
125
+ torsoRot = anglesDeg(-5, -25, 0),
126
+ lArmRot = anglesDeg(20, 15, -15)
127
+ },
128
+ {
129
+ t = 0.50, -- Slash Strike
130
+ armRot = anglesDeg(65, 30, -25),
131
+ torsoRot = anglesDeg(10, 35, 0),
132
+ lArmRot = anglesDeg(-25, -10, -20)
133
+ },
134
+ {
135
+ t = 0.75, -- Follow-through overshoot
136
+ armRot = anglesDeg(75, 40, -30),
137
+ torsoRot = anglesDeg(8, 40, 0),
138
+ lArmRot = anglesDeg(-15, -5, -15)
139
+ },
140
+ {
141
+ t = 1.0, -- Return
142
+ armRot = anglesDeg(10, 0, 5),
143
+ torsoRot = anglesDeg(0, 0, 0),
144
+ lArmRot = anglesDeg(5, 0, -5)
145
+ }
146
+ }
147
+
148
+ local keyframes: { KeyframeData } = {}
149
+ for _, pt in ipairs(points) do
150
+ local poses: { [string]: CFrame } = {}
151
+ if isR15 then
152
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.armRot
153
+ poses["LeftShoulder"] = (defaultC0["LeftShoulder"] or CFrame.identity) * pt.lArmRot
154
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torsoRot
155
+ else
156
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.armRot
157
+ poses["Left Shoulder"] = (defaultC0["Left Shoulder"] or CFrame.identity) * pt.lArmRot
158
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torsoRot
159
+ end
160
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
161
+ end
162
+
163
+ return keyframes
164
+ end
165
+
166
+ -- ── 3. Generator: Punch Combo ─────────────────────────────────────────────────
167
+ function MotionGenerator.generatePunch(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
168
+ local opts = options or {}
169
+ local duration = tonumber(opts.duration) or 0.6
170
+ local isR15 = rigInfo.isR15
171
+
172
+ local defaultC0 = {}
173
+ for name, motor in pairs(rigInfo.motors) do
174
+ defaultC0[name] = motor.C0
175
+ end
176
+
177
+ local points = {
178
+ { t = 0.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) },
179
+ { t = 0.2, rArm = anglesDeg(-20, -10, 15), torso = anglesDeg(0, -15, 0) }, -- Coil back
180
+ { t = 0.45, rArm = anglesDeg(90, 10, -5), torso = anglesDeg(8, 25, 0) }, -- Thrust forward
181
+ { t = 0.7, rArm = anglesDeg(70, 5, -2), torso = anglesDeg(4, 15, 0) }, -- Recoil
182
+ { t = 1.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) }
183
+ }
184
+
185
+ local keyframes: { KeyframeData } = {}
186
+ for _, pt in ipairs(points) do
187
+ local poses: { [string]: CFrame } = {}
188
+ if isR15 then
189
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.rArm
190
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torso
191
+ else
192
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.rArm
193
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torso
194
+ end
195
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
196
+ end
197
+
198
+ return keyframes
199
+ end
200
+
201
+ -- ── 4. Generator: Idle Breathing ──────────────────────────────────────────────
202
+ function MotionGenerator.generateIdle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
203
+ local opts = options or {}
204
+ local duration = tonumber(opts.duration) or 2.5
205
+ local isR15 = rigInfo.isR15
206
+
207
+ local defaultC0 = {}
208
+ for name, motor in pairs(rigInfo.motors) do
209
+ defaultC0[name] = motor.C0
210
+ end
211
+
212
+ local keyframes: { KeyframeData } = {}
213
+ local steps = 8
214
+ for i = 0, steps do
215
+ local frac = i / steps
216
+ local timeSec = frac * duration
217
+ local breathCycle = math.sin(frac * math.pi * 2)
218
+
219
+ local poses: { [string]: CFrame } = {}
220
+ if isR15 then
221
+ poses["Root"] = (defaultC0["Root"] or CFrame.identity) * CFrame.new(0, breathCycle * 0.04, 0)
222
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * anglesDeg(breathCycle * 1.5, 0, 0)
223
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.0, 0, 0)
224
+ else
225
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * CFrame.new(0, 0, breathCycle * 0.04)
226
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.2, 0, 0)
227
+ end
228
+
229
+ table.insert(keyframes, { time = timeSec, poses = poses })
230
+ end
231
+
232
+ return keyframes
233
+ end
234
+
235
+ -- ── 5. Dense Sampler: Interpolasi Keyframes Menggunakan SLERP & Easing ────────
236
+ function MotionGenerator.sampleSequence(
237
+ keyframes: { KeyframeData },
238
+ fps: number,
239
+ easing: string?
240
+ ): { [number]: { [string]: CFrame } }
241
+ if #keyframes == 0 then return {} end
242
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
243
+
244
+ local totalDuration = keyframes[#keyframes].time
245
+ local totalFrames = math.max(1, math.floor(totalDuration * fps))
246
+ local sampled: { [number]: { [string]: CFrame } } = {}
247
+
248
+ for f = 0, totalFrames do
249
+ local curTime = (f / totalFrames) * totalDuration
250
+
251
+ -- Cari segment keyframe [prev, next]
252
+ local kA = keyframes[1]
253
+ local kB = keyframes[#keyframes]
254
+ for i = 1, #keyframes - 1 do
255
+ if curTime >= keyframes[i].time and curTime <= keyframes[i + 1].time then
256
+ kA = keyframes[i]
257
+ kB = keyframes[i + 1]
258
+ break
259
+ end
260
+ end
261
+
262
+ local segDuration = kB.time - kA.time
263
+ local rawAlpha = segDuration > 1e-5 and ((curTime - kA.time) / segDuration) or 0
264
+ local curvedAlpha = Curves.evaluate(easing or "QuadInOut", rawAlpha)
265
+
266
+ local framePoses: { [string]: CFrame } = {}
267
+ for motorName, cfA in pairs(kA.poses) do
268
+ local cfB = kB.poses[motorName] or cfA
269
+ framePoses[motorName] = Curves.slerp(cfA, cfB, curvedAlpha)
270
+ end
271
+
272
+ sampled[f] = framePoses
273
+ end
274
+
275
+ return sampled
276
+ end
277
+
278
+ return MotionGenerator
279
+