@adiholick/nvstudio-mcp 2.2.3 → 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 +57 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +118 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +841 -3
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +152 -4
- package/dist/roblox-docs.js +264 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +6 -4
- package/public/index.html +1590 -180
- 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/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/insert_asset.lua +85 -12
- 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 +3824 -60
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +83 -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.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,128 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ============================================================================
|
|
3
|
+
-- Skill Module: batch_create_instances
|
|
4
|
+
-- Deskripsi: Pembangun Hierarki Instance Massal & Deklaratif (Tree / Batch List)
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
|
|
7
|
+
local HttpService = game:GetService("HttpService")
|
|
8
|
+
|
|
9
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
10
|
+
local success, config = pcall(function()
|
|
11
|
+
if type(data) == "table" then return data end
|
|
12
|
+
return HttpService:JSONDecode(tostring(data))
|
|
13
|
+
end)
|
|
14
|
+
|
|
15
|
+
if not success or type(config) ~= "table" then
|
|
16
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_create_instances." }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
20
|
+
local defaultParent = targetInstance or game.Workspace
|
|
21
|
+
|
|
22
|
+
if config.target and ctx and ctx.getInstanceFromPath then
|
|
23
|
+
local resolved = ctx.getInstanceFromPath(config.target)
|
|
24
|
+
if resolved then defaultParent = resolved end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
local createdList = {}
|
|
28
|
+
local errors = {}
|
|
29
|
+
local createdCount = 0
|
|
30
|
+
|
|
31
|
+
local function applyProps(inst: Instance, props: any)
|
|
32
|
+
if not props or type(props) ~= "table" or not next(props) then return end
|
|
33
|
+
local propJson = HttpService:JSONEncode(props)
|
|
34
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
35
|
+
if res and res.status == "error" then
|
|
36
|
+
table.insert(errors, string.format("[%s] Error properti: %s", inst.Name, tostring(res.result or res.error)))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
-- 1. MODE HIERARCHICAL TREE (Pohon Bersarang)
|
|
41
|
+
local function buildTreeNode(parentInst: Instance, node: any): Instance?
|
|
42
|
+
local className = node.className or node.ClassName
|
|
43
|
+
if not className or type(className) ~= "string" then
|
|
44
|
+
table.insert(errors, "Node pohon tidak memiliki ClassName yang valid.")
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
local ok, inst = pcall(function()
|
|
49
|
+
local newObj = Instance.new(className)
|
|
50
|
+
if node.name or node.Name then
|
|
51
|
+
newObj.Name = tostring(node.name or node.Name)
|
|
52
|
+
end
|
|
53
|
+
newObj.Parent = parentInst
|
|
54
|
+
return newObj
|
|
55
|
+
end)
|
|
56
|
+
|
|
57
|
+
if not ok or not inst then
|
|
58
|
+
table.insert(errors, string.format("Gagal membuat instance '%s': %s", className, tostring(inst)))
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
createdCount = createdCount + 1
|
|
63
|
+
table.insert(createdList, inst:GetFullName())
|
|
64
|
+
|
|
65
|
+
-- Terapkan properti
|
|
66
|
+
applyProps(inst, node.properties or node.Properties)
|
|
67
|
+
|
|
68
|
+
-- Rekursif untuk seluruh child
|
|
69
|
+
local children = node.children or node.Children
|
|
70
|
+
if children and type(children) == "table" then
|
|
71
|
+
for _, childNode in ipairs(children) do
|
|
72
|
+
buildTreeNode(inst, childNode)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return inst
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
-- Eksekusi Mode Tree jika disediakan
|
|
80
|
+
if config.tree and type(config.tree) == "table" then
|
|
81
|
+
buildTreeNode(defaultParent, config.tree)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
-- 2. MODE FLAT ARRAY (Daftar Instance Terpisah)
|
|
85
|
+
local instancesArray = config.instances or config.items
|
|
86
|
+
if instancesArray and type(instancesArray) == "table" then
|
|
87
|
+
for idx, item in ipairs(instancesArray) do
|
|
88
|
+
local parentInst = defaultParent
|
|
89
|
+
if item.parent and ctx and ctx.getInstanceFromPath then
|
|
90
|
+
local resParent = ctx.getInstanceFromPath(item.parent)
|
|
91
|
+
if resParent then parentInst = resParent end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
local className = item.className or item.ClassName
|
|
95
|
+
if className then
|
|
96
|
+
local ok, inst = pcall(function()
|
|
97
|
+
local newObj = Instance.new(className)
|
|
98
|
+
if item.name or item.Name then
|
|
99
|
+
newObj.Name = tostring(item.name or item.Name)
|
|
100
|
+
end
|
|
101
|
+
newObj.Parent = parentInst
|
|
102
|
+
return newObj
|
|
103
|
+
end)
|
|
104
|
+
|
|
105
|
+
if ok and inst then
|
|
106
|
+
createdCount = createdCount + 1
|
|
107
|
+
table.insert(createdList, inst:GetFullName())
|
|
108
|
+
applyProps(inst, item.properties or item.Properties)
|
|
109
|
+
else
|
|
110
|
+
table.insert(errors, string.format("Item #%d (%s) gagal dibuat: %s", idx, tostring(className), tostring(inst)))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
local status = (createdCount > 0 or #errors == 0) and "success" or "error"
|
|
117
|
+
return {
|
|
118
|
+
status = status,
|
|
119
|
+
result = {
|
|
120
|
+
totalCreated = createdCount,
|
|
121
|
+
created = createdList,
|
|
122
|
+
hasErrors = (#errors > 0),
|
|
123
|
+
errors = errors,
|
|
124
|
+
message = string.format("Berhasil membuat %d instance secara batch.", createdCount)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ============================================================================
|
|
3
|
+
-- Skill Module: batch_set_properties
|
|
4
|
+
-- Deskripsi: Modifikasi Properti Massal untuk Beberapa Instance Sekaligus
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
|
|
7
|
+
local HttpService = game:GetService("HttpService")
|
|
8
|
+
|
|
9
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
10
|
+
local success, config = pcall(function()
|
|
11
|
+
if type(data) == "table" then return data end
|
|
12
|
+
return HttpService:JSONDecode(tostring(data))
|
|
13
|
+
end)
|
|
14
|
+
|
|
15
|
+
if not success or type(config) ~= "table" then
|
|
16
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_set_properties." }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
20
|
+
local updates = config.updates or config
|
|
21
|
+
if type(updates) ~= "table" then
|
|
22
|
+
return { status = "error", error = "Parameter 'updates' harus berupa array pembaruan properti." }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
local updatedCount = 0
|
|
26
|
+
local successfulTargets = {}
|
|
27
|
+
local errors = {}
|
|
28
|
+
|
|
29
|
+
for idx, updateItem in ipairs(updates) do
|
|
30
|
+
local inst = targetInstance
|
|
31
|
+
local instPath = updateItem.target or updateItem.path
|
|
32
|
+
if instPath and ctx and ctx.getInstanceFromPath then
|
|
33
|
+
local resolved = ctx.getInstanceFromPath(instPath)
|
|
34
|
+
if resolved then
|
|
35
|
+
inst = resolved
|
|
36
|
+
else
|
|
37
|
+
table.insert(errors, string.format("Item #%d: Target path '%s' tidak ditemukan.", idx, tostring(instPath)))
|
|
38
|
+
continue
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
local props = updateItem.properties or updateItem.props
|
|
43
|
+
if inst and props and type(props) == "table" then
|
|
44
|
+
local propJson = HttpService:JSONEncode(props)
|
|
45
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
46
|
+
if res and res.status == "error" then
|
|
47
|
+
table.insert(errors, string.format("[%s]: %s", inst.Name, tostring(res.result or res.error)))
|
|
48
|
+
else
|
|
49
|
+
updatedCount = updatedCount + 1
|
|
50
|
+
table.insert(successfulTargets, inst:GetFullName())
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
table.insert(errors, string.format("Item #%d: Instance atau properties tidak valid.", idx))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
local status = (updatedCount > 0 or #errors == 0) and "success" or "error"
|
|
58
|
+
return {
|
|
59
|
+
status = status,
|
|
60
|
+
result = {
|
|
61
|
+
totalUpdated = updatedCount,
|
|
62
|
+
updatedInstances = successfulTargets,
|
|
63
|
+
hasErrors = (#errors > 0),
|
|
64
|
+
errors = errors,
|
|
65
|
+
message = string.format("Berhasil memperbarui properti untuk %d instance.", updatedCount)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
@@ -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
|
+
|
|
@@ -1,23 +1,96 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
-- ============================================================================
|
|
3
|
+
-- Skill Module: insert_asset (Upgraded with Asset Security Scanner & Script Stripper)
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
local HttpService = game:GetService("HttpService")
|
|
7
|
+
local InsertService = game:GetService("InsertService")
|
|
8
|
+
|
|
9
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
10
|
+
|
|
11
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
12
|
+
local assetIdStr = ""
|
|
13
|
+
local stripScripts = true -- Default TRUE demi keamanan mutlak
|
|
14
|
+
local scanSecurity = true
|
|
15
|
+
|
|
16
|
+
if typeof(data) == "string" then
|
|
17
|
+
local success, parsed = pcall(function()
|
|
18
|
+
return HttpService:JSONDecode(data)
|
|
19
|
+
end)
|
|
20
|
+
if success and typeof(parsed) == "table" then
|
|
21
|
+
assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
|
|
22
|
+
if parsed.strip_scripts ~= nil then stripScripts = parsed.strip_scripts end
|
|
23
|
+
if parsed.scan_security ~= nil then scanSecurity = parsed.scan_security end
|
|
24
|
+
else
|
|
25
|
+
assetIdStr = data
|
|
26
|
+
end
|
|
27
|
+
elseif typeof(data) == "table" then
|
|
28
|
+
assetIdStr = tostring(data.asset_id or data.assetId or "")
|
|
29
|
+
if data.strip_scripts ~= nil then stripScripts = data.strip_scripts end
|
|
30
|
+
if data.scan_security ~= nil then scanSecurity = data.scan_security end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
local rawId = string.gsub(assetIdStr, "rbxassetid://", "")
|
|
34
|
+
rawId = string.gsub(rawId, "%s+", "")
|
|
4
35
|
local assetId = tonumber(rawId)
|
|
5
|
-
|
|
36
|
+
|
|
37
|
+
if not assetId or assetId <= 0 then
|
|
6
38
|
return { status = "error", error = "Asset ID harus berupa angka yang valid." }
|
|
7
39
|
end
|
|
8
|
-
|
|
40
|
+
|
|
9
41
|
local success, model = pcall(function()
|
|
10
42
|
return InsertService:LoadAsset(assetId)
|
|
11
43
|
end)
|
|
12
|
-
|
|
13
|
-
if success
|
|
14
|
-
|
|
15
|
-
|
|
44
|
+
|
|
45
|
+
if not success or not model then
|
|
46
|
+
return {
|
|
47
|
+
status = "error",
|
|
48
|
+
error = "Gagal memuat aset. Pastikan Asset ID benar dan model berstatus publik di Creator Store."
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
local securityNotes = {}
|
|
53
|
+
local removedScripts = {}
|
|
54
|
+
|
|
55
|
+
-- 1. Scan keamanan jika diaktifkan
|
|
56
|
+
if scanSecurity then
|
|
57
|
+
local scanResult = SecurityScanner.scanInstance(model, true)
|
|
58
|
+
if scanResult.riskLevel == "DANGEROUS" then
|
|
59
|
+
table.insert(securityNotes, string.format("⚠️ PERINGATAN: Aset ini memiliki %d ancaman BERBAHAYA (Backdoor/Obfuscation/Webhook)!", scanResult.highThreats))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
-- 2. Sanitasi: Hapus skrip jika strip_scripts = true
|
|
64
|
+
if stripScripts then
|
|
65
|
+
removedScripts = SecurityScanner.stripScripts(model)
|
|
66
|
+
if #removedScripts > 0 then
|
|
67
|
+
table.insert(securityNotes, string.format("🛡️ Keamanan: %d skrip bawaan telah dihapus secara otomatis demi keamanan game Anda.", #removedScripts))
|
|
16
68
|
else
|
|
17
|
-
|
|
69
|
+
table.insert(securityNotes, "🛡️ Keamanan: Aset bersih, tidak ditemukan skrip berbahaya.")
|
|
18
70
|
end
|
|
19
|
-
return { status = "success", result = "Aset berhasil dimasukkan ke " .. model.Parent.Name .. "." }
|
|
20
71
|
else
|
|
21
|
-
|
|
72
|
+
table.insert(securityNotes, "ℹ️ Catatan: strip_scripts = false, skrip bawaan model dipertahankan.")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
-- 3. Masukkan model ke target
|
|
76
|
+
local parentTarget = targetInstance
|
|
77
|
+
if not parentTarget or parentTarget == game or parentTarget.ClassName == "DataModel" then
|
|
78
|
+
parentTarget = game.Workspace
|
|
22
79
|
end
|
|
80
|
+
|
|
81
|
+
model.Parent = parentTarget
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
status = "success",
|
|
85
|
+
result = {
|
|
86
|
+
message = string.format("Aset '%s' (ID: %d) berhasil dimasukkan ke %s.", model.Name, assetId, parentTarget.Name),
|
|
87
|
+
assetName = model.Name,
|
|
88
|
+
assetId = assetId,
|
|
89
|
+
parent = parentTarget:GetFullName(),
|
|
90
|
+
stripScriptsApplied = stripScripts,
|
|
91
|
+
removedScriptsCount = #removedScripts,
|
|
92
|
+
removedScripts = removedScripts,
|
|
93
|
+
securityNotes = table.concat(securityNotes, "\n"),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
23
96
|
end
|
|
@@ -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
|
+
|