@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.
- package/README.md +17 -3
- package/agent-plugin/rules/AGENTS.md +56 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +217 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
-- ══════════════════════════════════════════════════════════════════════
|
|
2
|
-
-- NVStudio MCP Plugin v2.
|
|
2
|
+
-- NVStudio MCP Plugin v2.4.1
|
|
3
3
|
-- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
|
|
4
4
|
-- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
|
|
5
5
|
-- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: animate_character
|
|
4
|
+
-- Deskripsi: Semantic Tool untuk AI Agent membuat animasi karakter halus
|
|
5
|
+
-- Menggunakan NV-Animation Engine (Walk, Slash, Punch, Idle, atau Custom)
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local dispatcherMod = script.Parent:WaitForChild("nv_anim_dispatcher")
|
|
10
|
+
local Dispatcher = require(dispatcherMod)
|
|
11
|
+
|
|
12
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
13
|
+
if not targetInstance then
|
|
14
|
+
return { status = "error", error = "Target karakter rig untuk animate_character tidak ditemukan: " .. tostring(targetPath) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local payload: { [string]: any } = {}
|
|
18
|
+
local ok, err = pcall(function()
|
|
19
|
+
if type(data) == "table" then
|
|
20
|
+
payload = data
|
|
21
|
+
else
|
|
22
|
+
payload = HttpService:JSONDecode(tostring(data))
|
|
23
|
+
end
|
|
24
|
+
end)
|
|
25
|
+
|
|
26
|
+
if not ok or type(payload) ~= "table" then
|
|
27
|
+
return { status = "error", error = "Format payload animasi tidak valid: " .. tostring(err) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
local action = tostring(payload.action or "walk_cycle")
|
|
31
|
+
local parameters = payload.parameters or {}
|
|
32
|
+
local keyPoses = payload.key_poses
|
|
33
|
+
|
|
34
|
+
return Dispatcher.createAnimation(targetInstance, action, parameters, keyPoses)
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: apply_vfx
|
|
4
|
+
-- Deskripsi: Menerapkan tabel data VFX murni ke instance target di Studio
|
|
5
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
local HttpService = game:GetService("HttpService")
|
|
8
|
+
local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
|
|
9
|
+
local Serializer = require(serializerMod)
|
|
10
|
+
|
|
11
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
12
|
+
if not targetInstance then
|
|
13
|
+
return { status = "error", error = "Target instance untuk apply_vfx tidak ditemukan: " .. tostring(targetPath) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
local payload = {}
|
|
17
|
+
local ok, err = pcall(function()
|
|
18
|
+
if type(data) == "table" then
|
|
19
|
+
payload = data
|
|
20
|
+
else
|
|
21
|
+
payload = HttpService:JSONDecode(tostring(data))
|
|
22
|
+
end
|
|
23
|
+
end)
|
|
24
|
+
|
|
25
|
+
if not ok or type(payload) ~= "table" then
|
|
26
|
+
return { status = "error", error = "Format payload VFX tidak valid: " .. tostring(err) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
-- Extract treeData (bisa di payload.data atau langsung di payload)
|
|
30
|
+
local treeData = payload.data or payload
|
|
31
|
+
if not treeData.nodes then
|
|
32
|
+
return { status = "error", error = "Payload tidak memiliki struktur 'nodes' yang valid untuk deserialisasi." }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
local scale = tonumber(payload.scale) or 1
|
|
36
|
+
local enabled = payload.enabled
|
|
37
|
+
|
|
38
|
+
local desResult = Serializer.deserialize(treeData, targetInstance, {
|
|
39
|
+
scale = scale,
|
|
40
|
+
enabled = enabled
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if desResult.status == "error" then
|
|
44
|
+
return {
|
|
45
|
+
status = "error",
|
|
46
|
+
error = "Gagal merekonstruksi VFX: " .. table.concat(desResult.errors or {}, "; ")
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
status = "success",
|
|
52
|
+
result = {
|
|
53
|
+
message = string.format("Berhasil menerapkan VFX '%s' (%d node) ke '%s'", tostring(treeData.rootName or "VFX"), desResult.createdCount, targetInstance:GetFullName()),
|
|
54
|
+
createdCount = desResult.createdCount,
|
|
55
|
+
errors = desResult.errors
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: edit_rig
|
|
4
|
+
-- Deskripsi: Semantic Tool untuk AI Agent mengelola rig, Motor6D, Weld,
|
|
5
|
+
-- C0/C1 pivot compensation, auto-rigging, dan pengelasan model.
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local dispatcherMod = script.Parent:WaitForChild("nv_rig_dispatcher")
|
|
10
|
+
local Dispatcher = require(dispatcherMod)
|
|
11
|
+
|
|
12
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
13
|
+
if not targetInstance then
|
|
14
|
+
return { status = "error", error = "Target instance untuk edit_rig tidak ditemukan: " .. tostring(targetPath) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local payload: { [string]: any } = {}
|
|
18
|
+
local ok, err = pcall(function()
|
|
19
|
+
if type(data) == "table" then
|
|
20
|
+
payload = data
|
|
21
|
+
else
|
|
22
|
+
payload = HttpService:JSONDecode(tostring(data))
|
|
23
|
+
end
|
|
24
|
+
end)
|
|
25
|
+
|
|
26
|
+
if not ok or type(payload) ~= "table" then
|
|
27
|
+
return { status = "error", error = "Format payload edit_rig tidak valid: " .. tostring(err) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
local action = tostring(payload.action or "inspect")
|
|
31
|
+
local parameters = payload.parameters or payload
|
|
32
|
+
|
|
33
|
+
return Dispatcher.dispatch(targetInstance, action, parameters)
|
|
34
|
+
end
|
|
35
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- NVStudio Subsystem: nv_anim_dispatcher
|
|
4
|
+
-- Deskripsi: Headless Modular Command Dispatcher untuk Pembuatan Animasi
|
|
5
|
+
-- Mengatur inisialisasi rig, analytical IK, generasi keyframe kurva halus,
|
|
6
|
+
-- dan ekspor resmi ke KeyframeSequence di Roblox Studio.
|
|
7
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
|
|
10
|
+
local Kinematics = require(kinematicsMod)
|
|
11
|
+
local curvesMod = script.Parent:WaitForChild("nv_curves")
|
|
12
|
+
local Curves = require(curvesMod)
|
|
13
|
+
local motionMod = script.Parent:WaitForChild("nv_motion_generator")
|
|
14
|
+
local Motion = require(motionMod)
|
|
15
|
+
|
|
16
|
+
local Dispatcher = {}
|
|
17
|
+
|
|
18
|
+
-- ── 1. Helper: Temukan Rig dari Instance atau Path ────────────────────────────
|
|
19
|
+
function Dispatcher.getRigInfo(targetInstance: Instance?): Kinematics.RigInfo?
|
|
20
|
+
if not targetInstance then return nil end
|
|
21
|
+
local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
|
|
22
|
+
if not model then return nil end
|
|
23
|
+
return Kinematics.inspectRig(model)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
-- ── 2. Buat KeyframeSequence Resmi di Roblox ─────────────────────────────────
|
|
27
|
+
function Dispatcher.buildKeyframeSequence(
|
|
28
|
+
rigInfo: Kinematics.RigInfo,
|
|
29
|
+
clipName: string,
|
|
30
|
+
keyframes: { Motion.KeyframeData },
|
|
31
|
+
options: { loop: boolean?, priority: Enum.AnimationPriority? }?
|
|
32
|
+
): KeyframeSequence
|
|
33
|
+
local opts = options or {}
|
|
34
|
+
local isR15 = rigInfo.isR15
|
|
35
|
+
|
|
36
|
+
local seq = Instance.new("KeyframeSequence")
|
|
37
|
+
seq.Name = clipName
|
|
38
|
+
seq.Loop = opts.loop ~= false
|
|
39
|
+
seq.Priority = opts.priority or Enum.AnimationPriority.Action
|
|
40
|
+
|
|
41
|
+
-- Urutkan keyframes berdasarkan waktu
|
|
42
|
+
table.sort(keyframes, function(a, b) return a.time < b.time end)
|
|
43
|
+
|
|
44
|
+
for _, kfData in ipairs(keyframes) do
|
|
45
|
+
local kf = Instance.new("Keyframe")
|
|
46
|
+
kf.Time = kfData.time
|
|
47
|
+
kf.Name = string.format("Frame_%.2f", kfData.time)
|
|
48
|
+
|
|
49
|
+
-- Root Pose (HumanoidRootPart)
|
|
50
|
+
local rootPose = Instance.new("Pose")
|
|
51
|
+
rootPose.Name = "HumanoidRootPart"
|
|
52
|
+
rootPose.Weight = 1
|
|
53
|
+
rootPose.EasingStyle = Enum.PoseEasingStyle.Cubic
|
|
54
|
+
rootPose.EasingDirection = Enum.PoseEasingDirection.InOut
|
|
55
|
+
|
|
56
|
+
local function createPose(name: string, cf: CFrame?, parent: Instance?): Pose
|
|
57
|
+
local p = Instance.new("Pose")
|
|
58
|
+
p.Name = name
|
|
59
|
+
p.Weight = 1
|
|
60
|
+
p.EasingStyle = Enum.PoseEasingStyle.Cubic
|
|
61
|
+
p.EasingDirection = Enum.PoseEasingDirection.InOut
|
|
62
|
+
if cf then p.CFrame = cf end
|
|
63
|
+
if parent then p.Parent = parent end
|
|
64
|
+
return p
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if isR15 then
|
|
68
|
+
-- LowerTorso pose
|
|
69
|
+
local lowerTorsoPose = createPose("LowerTorso", kfData.poses["Root"], nil)
|
|
70
|
+
|
|
71
|
+
-- UpperTorso pose
|
|
72
|
+
local upperTorsoPose = createPose("UpperTorso", kfData.poses["Waist"], nil)
|
|
73
|
+
|
|
74
|
+
-- Head
|
|
75
|
+
if kfData.poses["Neck"] then
|
|
76
|
+
createPose("Head", kfData.poses["Neck"], upperTorsoPose)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
-- Arms
|
|
80
|
+
for _, side in ipairs({ "Left", "Right" }) do
|
|
81
|
+
local shoulderPose = createPose(side .. "UpperArm", kfData.poses[side .. "Shoulder"], nil)
|
|
82
|
+
local elbowPose = shoulderPose
|
|
83
|
+
if kfData.poses[side .. "Elbow"] then
|
|
84
|
+
elbowPose = createPose(side .. "LowerArm", kfData.poses[side .. "Elbow"], shoulderPose)
|
|
85
|
+
end
|
|
86
|
+
if kfData.poses[side .. "Wrist"] then
|
|
87
|
+
createPose(side .. "Hand", kfData.poses[side .. "Wrist"], elbowPose)
|
|
88
|
+
end
|
|
89
|
+
shoulderPose.Parent = upperTorsoPose
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
-- Legs
|
|
93
|
+
for _, side in ipairs({ "Left", "Right" }) do
|
|
94
|
+
local hipPose = createPose(side .. "UpperLeg", kfData.poses[side .. "Hip"], nil)
|
|
95
|
+
local kneePose = hipPose
|
|
96
|
+
if kfData.poses[side .. "Knee"] then
|
|
97
|
+
kneePose = createPose(side .. "LowerLeg", kfData.poses[side .. "Knee"], hipPose)
|
|
98
|
+
end
|
|
99
|
+
if kfData.poses[side .. "Ankle"] then
|
|
100
|
+
createPose(side .. "Foot", kfData.poses[side .. "Ankle"], kneePose)
|
|
101
|
+
end
|
|
102
|
+
hipPose.Parent = lowerTorsoPose
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
upperTorsoPose.Parent = lowerTorsoPose
|
|
106
|
+
lowerTorsoPose.Parent = rootPose
|
|
107
|
+
else
|
|
108
|
+
-- R6 Pose Structure
|
|
109
|
+
local torsoPose = createPose("Torso", kfData.poses["RootJoint"], nil)
|
|
110
|
+
|
|
111
|
+
if kfData.poses["Neck"] then
|
|
112
|
+
createPose("Head", kfData.poses["Neck"], torsoPose)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
for _, limb in ipairs({ "Left Shoulder", "Right Shoulder", "Left Hip", "Right Hip" }) do
|
|
116
|
+
if kfData.poses[limb] then
|
|
117
|
+
local partName = limb == "Left Shoulder" and "Left Arm"
|
|
118
|
+
or limb == "Right Shoulder" and "Right Arm"
|
|
119
|
+
or limb == "Left Hip" and "Left Leg"
|
|
120
|
+
or "Right Leg"
|
|
121
|
+
createPose(partName, kfData.poses[limb], torsoPose)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
torsoPose.Parent = rootPose
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
-- Lampirkan VFX KeyframeMarkers jika ada event di frame ini
|
|
129
|
+
if opts.vfx_events and type(opts.vfx_events) == "table" then
|
|
130
|
+
for _, evt in ipairs(opts.vfx_events) do
|
|
131
|
+
local evtTime = tonumber(evt.time) or 0
|
|
132
|
+
if math.abs(evtTime - kfData.time) <= 0.08 then
|
|
133
|
+
local marker = Instance.new("KeyframeMarker")
|
|
134
|
+
marker.Name = evt.marker_name or "VFX"
|
|
135
|
+
marker.Value = evt.value or evt.action or "Emit"
|
|
136
|
+
marker.Parent = kf
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
rootPose.Parent = kf
|
|
142
|
+
kf.Parent = seq
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return seq
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
-- ── 3. Eksekusi Perintah Pembuatan Animasi ────────────────────────────────────
|
|
149
|
+
function Dispatcher.createAnimation(
|
|
150
|
+
targetInstance: Instance,
|
|
151
|
+
action: string,
|
|
152
|
+
parameters: { [string]: any }?,
|
|
153
|
+
keyPoses: { any }?
|
|
154
|
+
): { [string]: any }
|
|
155
|
+
local rigInfo = Dispatcher.getRigInfo(targetInstance)
|
|
156
|
+
if not rigInfo then
|
|
157
|
+
return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
local params = parameters or {}
|
|
161
|
+
local clipName = params.clipName or (action .. "_" .. (rigInfo.isR15 and "R15" or "R6"))
|
|
162
|
+
local keyframes: { Motion.KeyframeData }
|
|
163
|
+
|
|
164
|
+
if action == "walk_cycle" or action == "walk" then
|
|
165
|
+
keyframes = Motion.generateWalkCycle(rigInfo, params)
|
|
166
|
+
elseif action == "melee_slash" or action == "slash" or action == "sword" then
|
|
167
|
+
keyframes = Motion.generateMeleeSlash(rigInfo, params)
|
|
168
|
+
elseif action == "punch" or action == "combat_punch" then
|
|
169
|
+
keyframes = Motion.generatePunch(rigInfo, params)
|
|
170
|
+
elseif action == "idle" or action == "idle_breath" then
|
|
171
|
+
keyframes = Motion.generateIdle(rigInfo, params)
|
|
172
|
+
elseif action == "custom" and keyPoses and #keyPoses > 0 then
|
|
173
|
+
-- Parser custom keyposes dari input agent
|
|
174
|
+
keyframes = {}
|
|
175
|
+
for _, kp in ipairs(keyPoses) do
|
|
176
|
+
local motorCfs: { [string]: CFrame } = {}
|
|
177
|
+
if kp.poses then
|
|
178
|
+
for mName, cVal in pairs(kp.poses) do
|
|
179
|
+
if type(cVal) == "table" and cVal.t == "CFrame" then
|
|
180
|
+
motorCfs[mName] = CFrame.new(table.unpack(cVal.v))
|
|
181
|
+
elseif type(cVal) == "table" and cVal.angles then
|
|
182
|
+
local a = cVal.angles
|
|
183
|
+
motorCfs[mName] = CFrame.Angles(math.rad(a[1] or 0), math.rad(a[2] or 0), math.rad(a[3] or 0))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
table.insert(keyframes, {
|
|
188
|
+
time = tonumber(kp.time) or 0,
|
|
189
|
+
poses = motorCfs
|
|
190
|
+
})
|
|
191
|
+
end
|
|
192
|
+
else
|
|
193
|
+
return {
|
|
194
|
+
status = "error",
|
|
195
|
+
error = "Action tidak dikenal: '" .. tostring(action) .. "'. Pilihan: walk_cycle, melee_slash, punch, idle_breath, custom."
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
-- Simpan KeyframeSequence ke AnimSaves pada Rig
|
|
200
|
+
local animSaves = rigInfo.model:FindFirstChild("AnimSaves")
|
|
201
|
+
if not animSaves then
|
|
202
|
+
animSaves = Instance.new("Model")
|
|
203
|
+
animSaves.Name = "AnimSaves"
|
|
204
|
+
animSaves.Parent = rigInfo.model
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
local kfSeq = Dispatcher.buildKeyframeSequence(rigInfo, clipName, keyframes, {
|
|
208
|
+
loop = params.loop,
|
|
209
|
+
priority = Enum.AnimationPriority.Action,
|
|
210
|
+
vfx_events = params.vfx_events
|
|
211
|
+
})
|
|
212
|
+
kfSeq.Parent = animSaves
|
|
213
|
+
|
|
214
|
+
-- Terapkan pose pertama ke karakter di Studio untuk live feedback
|
|
215
|
+
if keyframes[1] and keyframes[1].poses then
|
|
216
|
+
for mName, cf in pairs(keyframes[1].poses) do
|
|
217
|
+
local m = rigInfo.motors[mName]
|
|
218
|
+
if m then
|
|
219
|
+
pcall(function() m.C0 = cf end)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
status = "success",
|
|
226
|
+
result = {
|
|
227
|
+
clipName = clipName,
|
|
228
|
+
action = action,
|
|
229
|
+
rigType = rigInfo.isR15 and "R15" or "R6",
|
|
230
|
+
keyframeCount = #keyframes,
|
|
231
|
+
duration = keyframes[#keyframes].time,
|
|
232
|
+
savedPath = kfSeq:GetFullName(),
|
|
233
|
+
message = string.format("Animasi '%s' berhasil dibuat dan disimpan ke '%s'", clipName, kfSeq:GetFullName())
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
return Dispatcher
|
|
239
|
+
|