@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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: scan_vfx
|
|
4
|
+
-- Deskripsi: Memindai efek visual (ParticleEmitter, Beam, Trail, Light)
|
|
5
|
+
-- pada objek target di Roblox Studio dan mengubahnya menjadi tabel murni
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
|
|
10
|
+
local Serializer = require(serializerMod)
|
|
11
|
+
|
|
12
|
+
local function detectCategory(name: string, data: any): string
|
|
13
|
+
local lower = string.lower(name)
|
|
14
|
+
if lower:find("slash") or lower:find("blade") or lower:find("sword") or lower:find("cut") then
|
|
15
|
+
return "Slash"
|
|
16
|
+
elseif lower:find("explosion") or lower:find("burst") or lower:find("blast") or lower:find("boom") or lower:find("fire") then
|
|
17
|
+
return "Explosion"
|
|
18
|
+
elseif lower:find("aura") or lower:find("glow") or lower:find("shield") or lower:find("barrier") then
|
|
19
|
+
return "Aura"
|
|
20
|
+
elseif lower:find("laser") or lower:find("beam") or lower:find("ray") then
|
|
21
|
+
return "Beam"
|
|
22
|
+
elseif lower:find("magic") or lower:find("spell") or lower:find("rune") or lower:find("circle") or lower:find("portal") then
|
|
23
|
+
return "Magic"
|
|
24
|
+
elseif lower:find("hit") or lower:find("impact") or lower:find("spark") or lower:find("clash") then
|
|
25
|
+
return "Impact"
|
|
26
|
+
elseif lower:find("projectile") or lower:find("bullet") or lower:find("arrow") or lower:find("missile") then
|
|
27
|
+
return "Projectile"
|
|
28
|
+
end
|
|
29
|
+
return "Ambient"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
33
|
+
if not targetInstance then
|
|
34
|
+
return { status = "error", error = "Target instance untuk scan_vfx tidak ditemukan: " .. tostring(targetPath) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
local parsedData = {}
|
|
38
|
+
if data and data ~= "" then
|
|
39
|
+
pcall(function()
|
|
40
|
+
if type(data) == "table" then
|
|
41
|
+
parsedData = data
|
|
42
|
+
else
|
|
43
|
+
parsedData = HttpService:JSONDecode(tostring(data))
|
|
44
|
+
end
|
|
45
|
+
end)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
local vfxName = parsedData.name or targetInstance.Name or "Unnamed_VFX"
|
|
49
|
+
local rawCat = parsedData.category or detectCategory(vfxName, parsedData)
|
|
50
|
+
local category = "Ambient"
|
|
51
|
+
if type(rawCat) == "string" and #rawCat > 0 then
|
|
52
|
+
category = string.upper(string.sub(rawCat, 1, 1)) .. string.lower(string.sub(rawCat, 2))
|
|
53
|
+
end
|
|
54
|
+
local tags = parsedData.tags or { string.lower(category) }
|
|
55
|
+
|
|
56
|
+
local ok, serialized = pcall(function()
|
|
57
|
+
return Serializer.serialize(targetInstance)
|
|
58
|
+
end)
|
|
59
|
+
|
|
60
|
+
if not ok or not serialized then
|
|
61
|
+
return { status = "error", error = "Gagal memindai VFX: " .. tostring(serialized) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if serialized.nodeCount == 0 or (serialized.stats.emitters == 0 and serialized.stats.beams == 0 and serialized.stats.trails == 0 and serialized.stats.lights == 0) then
|
|
65
|
+
return {
|
|
66
|
+
status = "error",
|
|
67
|
+
error = "Objek '" .. targetInstance.Name .. "' tidak memiliki komponen VFX (ParticleEmitter, Beam, Trail, atau Light)."
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
status = "success",
|
|
73
|
+
result = {
|
|
74
|
+
name = vfxName,
|
|
75
|
+
category = category,
|
|
76
|
+
tags = tags,
|
|
77
|
+
description = parsedData.description or ("Scanned VFX from " .. targetInstance:GetFullName()),
|
|
78
|
+
stats = serialized.stats,
|
|
79
|
+
nodeCount = serialized.nodeCount,
|
|
80
|
+
rootClass = serialized.rootClass,
|
|
81
|
+
data = serialized
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: scrub_timeline
|
|
4
|
+
-- Deskripsi: Headless Timeline Scrubbing & VFX Playhead Controller
|
|
5
|
+
-- Mengevaluasi pose rig dan memicu burst/scrub VFX pada timestamp spesifik (t)
|
|
6
|
+
-- secara deterministik langsung di Viewport Roblox Studio.
|
|
7
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
local HttpService = game:GetService("HttpService")
|
|
10
|
+
local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
|
|
11
|
+
local Kinematics = require(kinematicsMod)
|
|
12
|
+
local curvesMod = script.Parent:WaitForChild("nv_curves")
|
|
13
|
+
local Curves = require(curvesMod)
|
|
14
|
+
local motionMod = script.Parent:WaitForChild("nv_motion_generator")
|
|
15
|
+
local Motion = require(motionMod)
|
|
16
|
+
|
|
17
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
18
|
+
if not targetInstance then
|
|
19
|
+
return { status = "error", error = "Target instance untuk scrub_timeline tidak ditemukan: " .. tostring(targetPath) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local payload: { [string]: any } = {}
|
|
23
|
+
pcall(function()
|
|
24
|
+
if type(data) == "table" then payload = data
|
|
25
|
+
else payload = HttpService:JSONDecode(tostring(data)) end
|
|
26
|
+
end)
|
|
27
|
+
|
|
28
|
+
local timePos = tonumber(payload.time) or 0.0
|
|
29
|
+
local action = payload.action or "melee_slash"
|
|
30
|
+
local emitVfx = payload.emit_vfx == true or payload.emit == true
|
|
31
|
+
local emitCount = tonumber(payload.emit_count) or 20
|
|
32
|
+
|
|
33
|
+
local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
|
|
34
|
+
local appliedMotors: { string } = {}
|
|
35
|
+
|
|
36
|
+
-- 1. Scrub Rig Joint Poses jika target adalah Rig
|
|
37
|
+
if rigInfo then
|
|
38
|
+
local keyframes: { Motion.KeyframeData }
|
|
39
|
+
if action == "walk_cycle" then
|
|
40
|
+
keyframes = Motion.generateWalkCycle(rigInfo, payload.parameters)
|
|
41
|
+
elseif action == "punch" then
|
|
42
|
+
keyframes = Motion.generatePunch(rigInfo, payload.parameters)
|
|
43
|
+
elseif action == "idle" or action == "idle_breath" then
|
|
44
|
+
keyframes = Motion.generateIdle(rigInfo, payload.parameters)
|
|
45
|
+
else
|
|
46
|
+
keyframes = Motion.generateMeleeSlash(rigInfo, payload.parameters)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if #keyframes > 0 then
|
|
50
|
+
table.sort(keyframes, function(a, b) return a.time < b.time end)
|
|
51
|
+
local totalDur = keyframes[#keyframes].time
|
|
52
|
+
local clampedTime = math.clamp(timePos, 0, totalDur)
|
|
53
|
+
|
|
54
|
+
-- Cari segmen [kA, kB]
|
|
55
|
+
local kA = keyframes[1]
|
|
56
|
+
local kB = keyframes[#keyframes]
|
|
57
|
+
for i = 1, #keyframes - 1 do
|
|
58
|
+
if clampedTime >= keyframes[i].time and clampedTime <= keyframes[i + 1].time then
|
|
59
|
+
kA = keyframes[i]
|
|
60
|
+
kB = keyframes[i + 1]
|
|
61
|
+
break
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
local segDur = kB.time - kA.time
|
|
66
|
+
local alpha = segDur > 1e-5 and ((clampedTime - kA.time) / segDur) or 0
|
|
67
|
+
local curvedAlpha = Curves.evaluate(payload.easing or "QuadInOut", alpha)
|
|
68
|
+
|
|
69
|
+
for motorName, cfA in pairs(kA.poses) do
|
|
70
|
+
local cfB = kB.poses[motorName] or cfA
|
|
71
|
+
local interpCF = Curves.slerp(cfA, cfB, curvedAlpha)
|
|
72
|
+
local m = rigInfo.motors[motorName]
|
|
73
|
+
if m then
|
|
74
|
+
pcall(function()
|
|
75
|
+
m.C0 = interpCF
|
|
76
|
+
table.insert(appliedMotors, motorName)
|
|
77
|
+
end)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
-- 2. Scrub & Trigger VFX Emitters
|
|
84
|
+
local emittersTriggered = 0
|
|
85
|
+
local beamsToggled = 0
|
|
86
|
+
|
|
87
|
+
local function checkVfx(inst: Instance)
|
|
88
|
+
if inst:IsA("ParticleEmitter") then
|
|
89
|
+
if emitVfx then
|
|
90
|
+
pcall(function()
|
|
91
|
+
inst:Emit(emitCount)
|
|
92
|
+
emittersTriggered += 1
|
|
93
|
+
end)
|
|
94
|
+
end
|
|
95
|
+
elseif inst:IsA("Beam") or inst:IsA("Trail") then
|
|
96
|
+
if payload.enabled ~= nil then
|
|
97
|
+
pcall(function()
|
|
98
|
+
inst.Enabled = payload.enabled
|
|
99
|
+
beamsToggled += 1
|
|
100
|
+
end)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
checkVfx(targetInstance)
|
|
106
|
+
for _, desc in ipairs(targetInstance:GetDescendants()) do
|
|
107
|
+
checkVfx(desc)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
status = "success",
|
|
112
|
+
result = {
|
|
113
|
+
time = timePos,
|
|
114
|
+
action = action,
|
|
115
|
+
motorsPosed = #appliedMotors,
|
|
116
|
+
emittersTriggered = emittersTriggered,
|
|
117
|
+
beamsToggled = beamsToggled,
|
|
118
|
+
message = string.format("Timeline di-scrub ke t=%.2fs (Pose: %d joint, VFX: %d emitter burst)", timePos, #appliedMotors, emittersTriggered)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
@@ -33,6 +33,79 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
33
33
|
targetInstance[prop] = BrickColor.new(val)
|
|
34
34
|
elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
|
|
35
35
|
targetInstance[prop] = CFrame.new(unpack(val))
|
|
36
|
+
elseif typeof(currentVal) == "ColorSequence" or (type(val) == "table" and val.t == "ColorSequence") then
|
|
37
|
+
local kps = {}
|
|
38
|
+
local rawList = (type(val) == "table" and val.t == "ColorSequence") and val.v or val
|
|
39
|
+
if type(rawList) == "table" then
|
|
40
|
+
for _, kp in ipairs(rawList) do
|
|
41
|
+
local t = kp[1] or 0
|
|
42
|
+
local col = kp[2]
|
|
43
|
+
local c3
|
|
44
|
+
if typeof(col) == "Color3" then
|
|
45
|
+
c3 = col
|
|
46
|
+
elseif type(col) == "table" and col.t == "Color3" then
|
|
47
|
+
c3 = Color3.new(col.v[1], col.v[2], col.v[3])
|
|
48
|
+
elseif type(col) == "table" and #col >= 3 then
|
|
49
|
+
c3 = (col[1] > 1 or col[2] > 1 or col[3] > 1) and Color3.fromRGB(col[1], col[2], col[3]) or Color3.new(col[1], col[2], col[3])
|
|
50
|
+
else
|
|
51
|
+
c3 = Color3.new(1, 1, 1)
|
|
52
|
+
end
|
|
53
|
+
table.insert(kps, ColorSequenceKeypoint.new(math.clamp(t, 0, 1), c3))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
if #kps >= 2 then
|
|
57
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
58
|
+
-- Pastikan keypoint pertama t=0 dan terakhir t=1
|
|
59
|
+
if kps[1].Time ~= 0 then
|
|
60
|
+
kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
|
|
61
|
+
end
|
|
62
|
+
if kps[#kps].Time ~= 1 then
|
|
63
|
+
kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
|
|
64
|
+
end
|
|
65
|
+
-- Pastikan waktu meningkat secara monoton murni
|
|
66
|
+
for i = 2, #kps do
|
|
67
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
68
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
69
|
+
kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
targetInstance[prop] = ColorSequence.new(kps)
|
|
73
|
+
elseif #kps == 1 then
|
|
74
|
+
targetInstance[prop] = ColorSequence.new(kps[1].Value)
|
|
75
|
+
end
|
|
76
|
+
elseif typeof(currentVal) == "NumberSequence" or (type(val) == "table" and val.t == "NumberSequence") then
|
|
77
|
+
local kps = {}
|
|
78
|
+
local rawList = (type(val) == "table" and val.t == "NumberSequence") and val.v or val
|
|
79
|
+
if type(rawList) == "table" then
|
|
80
|
+
for _, kp in ipairs(rawList) do
|
|
81
|
+
if type(kp) == "table" then
|
|
82
|
+
local t = kp[1] or 0
|
|
83
|
+
local v = kp[2] or 0
|
|
84
|
+
local env = kp[3] or 0
|
|
85
|
+
table.insert(kps, NumberSequenceKeypoint.new(math.clamp(t, 0, 1), v, env))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
if #kps >= 2 then
|
|
90
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
91
|
+
-- Pastikan keypoint pertama t=0 dan terakhir t=1
|
|
92
|
+
if kps[1].Time ~= 0 then
|
|
93
|
+
kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
|
|
94
|
+
end
|
|
95
|
+
if kps[#kps].Time ~= 1 then
|
|
96
|
+
kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
|
|
97
|
+
end
|
|
98
|
+
-- Pastikan waktu meningkat secara monoton murni
|
|
99
|
+
for i = 2, #kps do
|
|
100
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
101
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
102
|
+
kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
targetInstance[prop] = NumberSequence.new(kps)
|
|
106
|
+
elseif #kps == 1 then
|
|
107
|
+
targetInstance[prop] = NumberSequence.new(kps[1].Value)
|
|
108
|
+
end
|
|
36
109
|
elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
|
|
37
110
|
local enumType = tostring(currentVal.EnumType)
|
|
38
111
|
local successEnum, enumValue = pcall(function() return Enum[enumType][val] end)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: solve_limb_ik
|
|
4
|
+
-- Deskripsi: Semantic Tool untuk AI Agent menghitung orientasi sendi
|
|
5
|
+
-- lengan / kaki menuju koordinat sasaran 3D secara analitis O(1).
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
|
|
10
|
+
local Kinematics = require(kinematicsMod)
|
|
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 solve_limb_ik tidak ditemukan: " .. tostring(targetPath) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
|
|
18
|
+
if not rigInfo then
|
|
19
|
+
return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local payload: { [string]: any } = {}
|
|
23
|
+
pcall(function()
|
|
24
|
+
if type(data) == "table" then payload = data
|
|
25
|
+
else payload = HttpService:JSONDecode(tostring(data)) end
|
|
26
|
+
end)
|
|
27
|
+
|
|
28
|
+
local limb = tostring(payload.limb or "RightArm")
|
|
29
|
+
local goalArr = payload.goal
|
|
30
|
+
if not goalArr or type(goalArr) ~= "table" or #goalArr < 3 then
|
|
31
|
+
return { status = "error", error = "Parameter 'goal' [X, Y, Z] wajib diisi." }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local goalPos = Vector3.new(goalArr[1], goalArr[2], goalArr[3])
|
|
35
|
+
local polePos: Vector3? = nil
|
|
36
|
+
if payload.pole and type(payload.pole) == "table" and #payload.pole >= 3 then
|
|
37
|
+
polePos = Vector3.new(payload.pole[1], payload.pole[2], payload.pole[3])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local apply = payload.apply_to_rig ~= false
|
|
41
|
+
local solved = Kinematics.solveLimb(rigInfo, limb, goalPos, polePos, apply)
|
|
42
|
+
|
|
43
|
+
if not solved then
|
|
44
|
+
return { status = "error", error = "Gagal menyelesaikan IK untuk anggota tubuh: " .. limb }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
local resultC0: { [string]: any } = {}
|
|
48
|
+
for mName, cf in pairs(solved) do
|
|
49
|
+
resultC0[mName] = { t = "CFrame", v = { cf:GetComponents() } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
status = "success",
|
|
54
|
+
result = {
|
|
55
|
+
limb = limb,
|
|
56
|
+
rigType = rigInfo.isR15 and "R15" or "R6",
|
|
57
|
+
applied = apply,
|
|
58
|
+
targetGoal = { goalPos.X, goalPos.Y, goalPos.Z },
|
|
59
|
+
solvedMotors = resultC0
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
@@ -95,7 +95,7 @@ function UIManager.init(pluginInstance, studioId)
|
|
|
95
95
|
|
|
96
96
|
-- Version
|
|
97
97
|
local versionLabel = Instance.new("TextLabel")
|
|
98
|
-
versionLabel.Text = "v2.
|
|
98
|
+
versionLabel.Text = "v2.4.1"
|
|
99
99
|
versionLabel.Font = Enum.Font.Gotham
|
|
100
100
|
versionLabel.TextSize = 11
|
|
101
101
|
versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)
|