@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.
Files changed (42) hide show
  1. package/README.md +57 -47
  2. package/agent-plugin/mcp_config.json +8 -0
  3. package/agent-plugin/plugin.json +6 -0
  4. package/agent-plugin/rules/AGENTS.md +118 -0
  5. package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
  6. package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
  7. package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
  8. package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
  9. package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
  10. package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
  11. package/dist/build-plugin.js +55 -0
  12. package/dist/index.js +841 -3
  13. package/dist/luau-validator.js +99 -34
  14. package/dist/roblox-bridge.js +152 -4
  15. package/dist/roblox-docs.js +264 -0
  16. package/dist/vfx-database.js +507 -0
  17. package/package.json +6 -4
  18. package/public/index.html +1590 -180
  19. package/studio-plugin/Main.server.lua +1 -1
  20. package/studio-plugin/animate_character.lua +36 -0
  21. package/studio-plugin/apply_vfx.lua +59 -0
  22. package/studio-plugin/batch_create_instances.lua +128 -0
  23. package/studio-plugin/batch_set_properties.lua +69 -0
  24. package/studio-plugin/edit_rig.lua +35 -0
  25. package/studio-plugin/insert_asset.lua +85 -12
  26. package/studio-plugin/nv_anim_dispatcher.lua +239 -0
  27. package/studio-plugin/nv_auto_rigger.lua +392 -0
  28. package/studio-plugin/nv_curves.lua +260 -0
  29. package/studio-plugin/nv_joint_math.lua +138 -0
  30. package/studio-plugin/nv_kinematics.lua +248 -0
  31. package/studio-plugin/nv_motion_generator.lua +279 -0
  32. package/studio-plugin/nv_rig_dispatcher.lua +319 -0
  33. package/studio-plugin/nv_vfx_serializer.lua +363 -0
  34. package/studio-plugin/nvstudio_mcp.rbxmx +3824 -60
  35. package/studio-plugin/preview_asset.lua +99 -0
  36. package/studio-plugin/scan_security.lua +81 -0
  37. package/studio-plugin/scan_vfx.lua +85 -0
  38. package/studio-plugin/scrub_timeline.lua +122 -0
  39. package/studio-plugin/security_scanner.lua +346 -0
  40. package/studio-plugin/set_properties.lua +83 -0
  41. package/studio-plugin/solve_limb_ik.lua +63 -0
  42. package/studio-plugin/ui_manager.lua +1 -1
@@ -1,5 +1,5 @@
1
1
  -- ══════════════════════════════════════════════════════════════════════
2
- -- NVStudio MCP Plugin v2.2.2
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
- return function(targetInstance, data, ctx, targetPath)
2
- local InsertService = game:GetService("InsertService")
3
- local rawId = tostring(data or ""):gsub("rbxassetid://", ""):gsub("%s+", "")
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
- if not assetId then
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 and model then
14
- if targetInstance == game or targetInstance.ClassName == "DataModel" then
15
- model.Parent = game.Workspace
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
- model.Parent = targetInstance
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
- return { status = "error", error = "Gagal memuat aset. Pastikan Asset ID benar dan akun/plugin Anda memiliki izin (Ownership/Public)." }
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
+