@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.
@@ -1,5 +1,5 @@
1
1
  -- ══════════════════════════════════════════════════════════════════════
2
- -- NVStudio MCP Plugin v2.3.0
2
+ -- NVStudio MCP Plugin v2.4.0
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
+