@adiholick/nvstudio-mcp 2.3.0 → 2.4.0
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.
- package/README.md +17 -3
- package/agent-plugin/rules/AGENTS.md +31 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +14 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/dist/index.js +625 -0
- package/dist/roblox-bridge.js +108 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +2 -2
- package/public/index.html +913 -14
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3008 -115
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/set_properties.lua +73 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -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
|
+
|